Skip to main content

Stage 5: Concept & Alias Extraction

The fifth stage runs an LLM over each stable fact to extract a concept, a context (from an ontology), and seed aliases. It matches against existing concepts via aliases scoped by context, or creates a new one. This is what builds the knowledge graph.

Entry point: (*ExtractConceptsWorker).Workbackend/internal/taskmanager/tasks/extract_concepts.go:155.

The working set

The worker loads stable facts that have no fact_concepts row and no fact_concept_skips row (ListStableFactsForConceptExtraction, one parallel wave per round — Concurrency × FactBatchSize facts, defaults 4 × 10 = 40). No advisory lock is needed; the fact_concepts unique index + NOT EXISTS filter make concurrent passes safe.

The context ontology

The worker loads the per-repo allowed context list from okt_system.repository_contexts (extract_concepts.go:204). The default is the embedded DBpedia L3 class list (backend/internal/providers/ontology/dbpedia_l3.json), plus any admin-custom contexts. This gives the LLM a fixed vocabulary of contexts to assign, keeping the graph structured rather than free-form. The L3 list can be refreshed via just dbpedia-pull.

Phase 1: parallel AI extraction

Per fact (extract_concepts.go:279):

  1. conceptExtractor.ExtractConcepts returns []ExtractedConcept{Concept, Context, SeedAliases}.
  2. On a miss path (no existing concept match), aliasProvider.GenerateAliases produces AliasResult{CanonicalName, Aliases}.

The concept-extraction prompt

conceptExtractionPromptbackend/internal/providers/decomposition/concept_extraction.go:13-59.

The prompt instructs the LLM to extract 1-2 word concepts (people, places, molecules, orgs, ideas), assign a context from the provided L3 ontology list, and emit 2-3 seed aliases.

The alias-generation prompt

aliasGenerationPromptbackend/internal/providers/decomposition/alias_generation.go:13-28.

This prompt generates the canonical name (strict charset: letters, digits, spaces, hyphens, apostrophes) plus 3-6 alternate aliases. The canonical name is the concept's display name; the aliases are what FindConceptByAlias searches against.

Phase 2: serial persistence

Per fact, in a short write tx (extract_concepts.go:323), linkFactToConcept (extract_concepts.go:582):

  • Match path: FindConceptByAlias does a text search scoped by (repository_id, lower(context)) against concept_aliases.lower(alias_text). On hit -> AddFactConcept + merge seed_aliases via AddConceptAlias (ON CONFLICT DO NOTHING — a free recall boost, no LLM call).
  • Miss path: CreateConcept (canonical_name + context), insert the canonical name + original concept text + generated aliases + seed aliases as concept_aliases, then AddFactConcept.

Per-fact LLM failures write a permanent fact_concept_skips row (recordSkip, extract_concepts.go:546) so the next pass doesn't retry forever.

Disambiguation by context

This is the key mechanism for building a clean knowledge graph. The same surface name under different contexts creates separate concept rows. Uniqueness is (repository_id, lower(canonical_name), lower(context)) (migration 0023).

For example:

  • "Apple" in context "Company" -> concept row 1
  • "Apple" in context "Molecule" -> concept row 2

FindConceptByAlias is scoped by context, so a fact mentioning "Apple" in a Company-context fact matches the Company concept, not the Molecule one. The context disambiguates the surface name without requiring the fact to spell out which "Apple" it means — the LLM assigns the context during extraction.

Grouping across contexts

While contexts create separate concept rows, several read paths group by lower(canonical_name) across contexts:

  • The concept_relations materialized view (migration 0030) groups by lower(canonical_name) across contexts for the relations view.
  • concept_syntheses groups by lower(canonical_name) so one authoritative definition folds all contexts.

This means a concept like "Einstein" that appears in contexts "Scientist" and "Person" gets one synthesis that covers both, while remaining as two distinct concept rows for fact linking.

Chain out

The worker fans out from extract_concepts:

  • embed_concepts (extract_concepts.go:379) — vectorize concepts into Qdrant.
  • summarize_concepts chunks (extract_concepts.go:404, gated on summarizationEnabled) — start the slow-accumulation layer.
  • refresh_concept_relations (extract_concepts.go:416, deduped per-database via River unique-args) — refresh the matview.

Key tables

TablePurpose
okt_repository.conceptsConcept nodes: canonical_name, context, description, embedded_at
okt_repository.concept_aliasesAliases for each concept; index on lower(alias_text) for lookup
okt_repository.fact_conceptsJunction: fact <-> concept
okt_repository.fact_concept_skipsPermanent skip markers for facts that failed concept extraction
okt_repository.concept_relationsMaterialized view: pairs of canonical names + shared fact count