174 lines
7.8 KiB
Markdown
174 lines
7.8 KiB
Markdown
# Entity Extraction
|
|
|
|
**Location:** `packages/memory-service/src/entities/extraction.js`
|
|
**Triggered by:** Episode creation (`POST /episodes`)
|
|
**Model:** the utility model served by the inference service (`/utility/complete`), configurable via `UTILITY_MODEL` on the inference service
|
|
|
|
## 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 | utility model | Served by inference-service `/utility/complete`, set via `UTILITY_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.
|
|
|
|
**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. |