Files
nexusAI/docs/services/entity-extraction.md
T
2026-08-17 05:31:19 -07:00

7.7 KiB

Entity Extraction

Location: packages/memory-service/src/entities/extraction.js
Triggered by: Episode creation (POST /episodes)
Model: qwen2.5:3b via Ollama (configurable via EXTRACTION_MODEL env var)

Purpose

After each episode is saved to SQLite, the extraction pipeline runs asynchronously in the background to identify named entities and the relationships between them. Results are written back to SQLite and embedded into Qdrant — the episode response is never delayed.

Trigger

createEpisode() in episodic/index.js calls extractAndStoreEntities() immediately after the SQLite insert, without awaiting it:

extractAndStoreEntities(userMessage, aiResponse, episode.id, projectId)
  .catch(err => logger.error(`Failed to extract entities for episode ${episode.id}:`, err.message));

If extraction throws, the episode is unaffected — the error is logged and swallowed.

Model Settings

Setting Value Notes
Model qwen2.5:3b Ollama, configurable via EXTRACTION_MODEL
Temperature 0.1 Low for consistent, deterministic output
num_predict 1500 Higher ceiling to accommodate entity + relationship JSON
format 'json' Ollama constrained decoding — enforces valid JSON output
Prompt format ChatML <|im_start|> / <|im_end|> tokens

Prompt Structure

The prompt is built by buildExtractionPrompt(). It includes:

  1. System message — declares the model's role as an entity and relationship extractor
  2. Instructions — entity types, field rules, relationship label format, required JSON schema
  3. Known entities block — last 20 entities from SQLite, by rowid DESC, used to encourage consistent name/type pairs across conversations
  4. Conversation — the raw user message and AI response, delimited clearly
<|im_start|>system
You are a named entity and relationship extractor. You output only valid JSON.
<|im_end|>
<|im_start|>user
Read the conversation below and extract all named entities and the relationships between them.
Entity types: person, place, project, technology, concept, organization
...
Return this exact JSON structure:
{ "entities": [...], "relationships": [...] }

Already known entities (use these exact name and type values if the same entity appears):
- "NexusAI" (project)
- "Alice" (person)

--- CONVERSATION ---
User: ...
Assistant: ...
--- END CONVERSATION ---
<|im_end|>
<|im_start|>assistant

Expected JSON Output

{
  "entities": [
    { "name": "Alice", "type": "person", "notes": "Software engineer working on NexusAI." },
    { "name": "NexusAI", "type": "project", "notes": "A modular AI assistant with persistent memory." }
  ],
  "relationships": [
    {
      "from": "Alice", "fromType": "person",
      "to": "NexusAI", "toType": "project",
      "label": "works_on",
      "notes": "Alice is the primary developer."
    }
  ]
}

Relationship labels use snake_case verbs (e.g. works_on, manages, uses, knows, located_in, part_of, created_by).

JSON Parsing

The raw model response is matched with /\{[\s\S]*\}/ before parsing — this tolerates any preamble or trailing prose the model emits alongside the JSON. If the match fails or JSON.parse throws, the function logs a warning and returns without writing anything.

Entity Processing

For each entity in parsed.entities:

  1. Validate name, type (must be in ENTITY_TYPES), and not a greeting via isIgnoredName(name)
  2. Call upsertEntity(name, type, notes):
    • Insert: creates new row with mention_count = 1, source = 'extraction'
    • Conflict on (name, type): increments mention_count, updates last_seen_at, preserves existing notes if new extraction returns null
  3. Add to entityMap keyed by "${name}::${type}" — used for relationship resolution below
  4. Call linkEntityToEpisode(entity.id, episodeId) — writes to entity_episodes join table
  5. Fire-and-forget: embed as "${name} (${type}): ${notes}" → store to Qdrant entities collection with { name, type, notes, projectId } in payload

Valid entity types: person, place, project, technology, concept, organization

Greeting filter: isIgnoredName(name) normalizes the candidate before matching — lowercased, punctuation stripped (/[^\w\s]/g), trimmed — then tests membership against the IGNORED_NAMES set. Normalizing means variants like "Good morning!" or " hello " are caught, not just exact lowercase matches. Current set: good morning, good night, good evening, good afternoon, hello, hi, hey, goodbye, bye, thanks, thank you, morning.

The extraction prompt also instructs the model not to emit greetings or conversational filler as entities, so the filter is a backstop rather than the sole defense — necessary because the model (qwen2.5:3b) will occasionally label a greeting as a concept or topic, both valid types that the ENTITY_TYPES check alone won't reject.

Note: the filter runs only at extraction time; it does not retroactively remove greetings stored before the filter (or an entry) existed. Pre-filter stragglers must be deleted manually via DELETE /entities/:id, which now also removes the Qdrant vector (see memory-service.md → Delete Behaviour). \w is ASCII-only, so non-ASCII greetings (e.g. buenos días) normalize imperfectly — switch to /[^\p{L}\p{N}\s]/gu if the set gains non-ASCII entries.

Regurgitation guard (mentionedIn): the extraction prompt feeds the model a "known entities" hint block (the 20 most-recent entities) for spelling/type consistency. The small model (qwen2.5:3b) will sometimes echo that list back as if those entities appeared in the conversation — most visibly on contentless turns (a greeting produced fake extractions of unrelated authors, game titles, etc.). After parsing, each extracted name is checked against the actual userMessage + aiResponse text (case- and whitespace-normalized substring); any name not present is dropped before upsert. Since the prompt constrains names to short proper nouns, a genuinely-discussed entity appears verbatim while a regurgitated hint does not. Relationships referencing a dropped entity fall away automatically (they resolve against the surviving entityMap). A prompt line also tells the model the hint list is spelling-only — a backstop, with mentionedIn as the deterministic guarantee.

Relationship Processing

After all entities are saved, relationships are processed:

  1. For each entry in parsed.relationships, look up both endpoints in entityMap using "${from}::${fromType}" and "${to}::${toType}" as keys
  2. If either endpoint is missing (filtered out, invalid type, or not in this extraction), the relationship is silently skipped
  3. Call upsertRelationship(fromId, toId, label, notes):
    • Insert: creates new row with mention_count = 1
    • Conflict on (from_id, to_id, label): increments mention_count, preserves existing notes if new is null

Relationships are unidirectional in storage. Bidirectionality is handled at query time by the graph traversal layer.

Project Scoping

projectId is threaded through from the episode creation call. It is stored in the Qdrant entity payload, which enables project-scoped entity search in orchestration. SQLite entities and relationships are global — scoping only applies at the Qdrant retrieval layer.

Error Behaviour

All steps after the initial model call are wrapped in a single outer try/catch. If Ollama is unreachable, returns a non-200 status, or the JSON cannot be parsed, the function logs at warn level and returns. There is no retry logic. Individual entity embedding failures are caught per-entity and logged at warn level without affecting other entities in the same batch.