Files
nexusAI/docs/services/entity-extraction.md
T
2026-08-16 23:25:40 -07:00

160 lines
6.8 KiB
Markdown

# 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:
```js
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
```json
{
"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.
## 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.