ADR-017: Semantic Learning via Embedding Index
Date: 2026-08-14
Status: Accepted
Context
Issue #313 asked for a persistent embedding index feeding EpisodeLearner's
semantic-first path. The audit that preceded this work found the pieces the
issue assumed did not exist in the expected shape:
SemanticPatternMatcher(#312) persistspattern:rows with JSON outcome content and matches via an in-memory linear cosine scan over records loaded atinitialize()— the real LanceDB ANN path (IVectorStore.search) is never used by the matcher.- The matcher is dead code in production: nothing constructs it, so
EpisodeLearner's semantic-first branch can never fire in
agenthood run. - LongTermMemory rows (
ltm:learnings/*) were written with zero vectors, so they are invisible to any similarity query. - A real-store test uncovered that filtered ANN searches silently returned
nothing:
toSqlFilterwrapped the filter value in quotes outside the LIKE literal and inserted a space after the key colon that JSON metadata never contains.
Decision
- New primitive, not matcher reuse.
EmbeddingIndex(src/evals/ EmbeddingIndex.ts) owns persistence and ANN similarity:storePatternupserts by a key derived from the pattern text (delete-then-add, no duplicate rows),findSimilarqueriesIVectorStore.searchwith alearned_patternmetadata filter, threshold (default 0.85, inclusive), and limit. Content is the pattern text, not the JSON outcome — the learner only consumes.patternfrom matches, so text storage is sufficient and keeps the primitive provider-agnostic. - SemanticPatternMatcher is retained unchanged. Its tests encode a
contract (JSON-outcome content, initialize + linear scan) that delegation
would rewrite. It stays exported for compatibility; EpisodeLearner no
longer calls it. Both write
pattern:rows derived from the same hash — the learner uses the index exclusively in production, so the overlap is inert. - EpisodeLearner queries the index before the hash fallback. The
semantic-first ordering already existed in
storeOutcome; it now callsindex.findSimilar(embed(episode))and, on no match, storesembed(pattern)viaindex.storePattern. Any failure (no provider, Anthropic's unsupported embed, store down) degrades to the hash fallback — learning never blocks a run. - Versioned re-index migration. A marker row (
__index_version__, metadata typeindex_version, zero vector) records the index format.reindexLegacyPatternsre-embedsltm:learnings/*andltm:antipatterns/*rows aspattern:rows and writes the marker. It runs best-effort atApplicationContext.create, is idempotent (upserts), and retries next process if embedding is unavailable. LTM rows are left untouched — the migration is additive. - Fix
toSqlFilter. Values are embedded inside the LIKE literal with their JSON quoting (%"type":"value"%), so filtered searches actually match.IVectorStore.deletenow types itsstring | Recordunion, which the implementation already supported.
Alternatives Considered
| Option | Pros | Cons | Why Rejected |
|---|---|---|---|
| Delegate SemanticPatternMatcher to EmbeddingIndex | Removes linear-scan duplication | Rewrites the matcher's tested contract (JSON content, initialize-load) | Tests encode behavior that predates the index; churn without user value |
| Single-writer consolidation (matcher deleted) | No dual storage | Breaking API; matcher is exported and documented | Retained for backward compatibility per issue scope |
| Migration rewrites LTM rows in place | One canonical copy | Mutates the memory store semantics; LTM is key-value, not similarity | Additive pattern rows keep LTM intact |
Consequences
- Every filtered vector search in the codebase now works (LongTermMemory retrieve, DecisionSearch, index queries) instead of silently returning [].
agenthood rungains production semantic learning when a provider withembedis configured; otherwise behavior is unchanged.- The migration costs one embed call per legacy pattern; it is capped by being best-effort and deferred across processes when the provider is down.
- ResidualMemory production wiring remains out of scope (deferred follow-up); the learner already tolerates its absence.