Skip to content

Commit 4609f5b

Browse files
jkyberneeesclaude
andcommitted
feat(memory): pluggable semantic embeddings via go-vector v1.3.0 HTTPEmbedder
Replace the hardwired RandomProjections usage across all memory similarity paths with a textEmbedder seam. The default stays RP (local, zero-cost, unchanged behavior); a new memory.embedding config section routes episode recall, episode dedup, the non-LLM ranker, and fact merge-on-write through any OpenAI-compatible embeddings API (Ollama, llama.cpp, OpenAI, ...) for real semantic similarity instead of bag-of-words overlap. - internal/memory/embedder.go: EmbeddingConfig + textEmbedder interface; rpTextEmbedder (corpus-fitted, bigram-featurized) and httpTextEmbedder (stateless, raw text, text->vector cache, batch misses-only refits). - episode_index.go: index owns a textEmbedder; persisted vectors carry an embedding-space fingerprint (episodes_index_meta.json) so switching provider/model/dims rebuilds automatically while legacy RP gobs keep loading; failed rebuilds back off 30s so a down backend is never re-hit on every loop turn. - episodes.go: embedder factory on EpisodeStore; findDuplicate and the embedder ranker fail safe (no dedup / recency order) on embed errors. - merge.go: MergeDetector classifies over the configured embedder; embed failures degrade to "nobody" (add without merge) instead of misclassifying. - config: memory.embedding overlay in resolveMemory + NewMemoryManager; ${ENV_VAR} expansion for base_url/api_key; 10s default timeout. - docs: CONFIG.md field reference + MEMORY.md mechanics/privacy notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 71c0609 commit 4609f5b

14 files changed

Lines changed: 1092 additions & 119 deletions

docs/CONFIG.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,15 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
206206
"auto_approve_episodes": false,
207207
"episode_dedup_threshold": 0.92,
208208
"max_episodes": 500,
209-
"episode_ttl_days": 0
209+
"episode_ttl_days": 0,
210+
"embedding": {
211+
"provider": "http",
212+
"base_url": "http://localhost:11434/v1",
213+
"model": "nomic-embed-text",
214+
"api_key": "${OPENAI_API_KEY}",
215+
"dims": 0,
216+
"timeout_seconds": 10
217+
}
210218
}
211219
}
212220
```
@@ -231,6 +239,39 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
231239
| `episode_dedup_threshold` | 0.92 | Cosine similarity above which a newly written episode is treated as a near-duplicate of an existing one and **replaces** it (newest wins). An untrusted episode never replaces a trusted/approved one. `0` disables dedup. |
232240
| `max_episodes` | 500 | Maximum number of stored episodes. On each write, episodes beyond this count are evicted oldest-first (both the summary file and the index entry). `0` disables the cap. |
233241
| `episode_ttl_days` | 0 | Evict episodes older than this many days. `0` (default) disables TTL-based eviction. |
242+
| `embedding` | *(unset)* | Semantic embedding backend for episode recall, episode dedup, the non-LLM episode ranker, and fact merge-on-write. Unset = local RandomProjections (lexical bag-of-words — fast, zero-cost, but no real semantics). See below. |
243+
244+
### `embedding` — real semantic embeddings (optional)
245+
246+
By default every similarity computation in memory uses go-vector
247+
**RandomProjections**: a local, zero-dependency bag-of-words embedder. It is
248+
fast but purely lexical — *"fixed the auth bug"* and *"repaired login issue"*
249+
share no tokens and score ~0. Setting `embedding.provider` to `"http"` routes
250+
all of those paths through any **OpenAI-compatible embeddings API** instead
251+
(Ollama, llama.cpp server, LM Studio, vLLM, OpenAI, Voyage…), giving recall
252+
that matches by meaning.
253+
254+
| Field | Default | Description |
255+
|-------|---------|-------------|
256+
| `provider` | `"rp"` | `"rp"` = local RandomProjections; `"http"` = OpenAI-compatible embeddings API. An `"http"` config missing `base_url` or `model` silently falls back to `"rp"` so memory keeps working. |
257+
| `base_url` || API root, e.g. `http://localhost:11434/v1` (Ollama) or `https://api.openai.com/v1`. `${ENV_VAR}` expansion supported. |
258+
| `model` || Embedding model name, e.g. `nomic-embed-text`, `text-embedding-3-small`. |
259+
| `api_key` || Sent as `Authorization: Bearer <key>` when set. `${ENV_VAR}` expansion supported — keep secrets out of config files. |
260+
| `dims` | 0 | Expected vector dimensionality; `0` infers it from the first response (recommended). |
261+
| `timeout_seconds` | 10 | Per-request HTTP timeout. |
262+
263+
Operational notes:
264+
265+
- **Per-turn recall stays cheap.** Episode vectors are cached in a persisted
266+
index; a loop turn costs at most one embedding call (the query), bounded by
267+
`timeout_seconds`. If the backend is down, recall degrades to "no context"
268+
and rebuilds back off for 30s — the agent loop is never blocked.
269+
- **Switching backends is safe.** The persisted index records which embedding
270+
space it was built in; changing `provider`/`model`/`dims` automatically
271+
invalidates it and rebuilds on next use (one batch embedding call).
272+
- **Episode summaries leave the machine.** With `"http"`, episode summaries and
273+
fact entries are sent to the configured endpoint for embedding. Point it at a
274+
local server (Ollama/llama.cpp) if that must not happen.
234275

235276
### ⚠️ `extract_facts` — automatic fact learning (opt-in, off by default)
236277

docs/MEMORY.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ RP.embed(newEntry) → cos similarity vs each existing entry
9090

9191
This saves ~80% of LLM calls on memory writes.
9292

93-
**Implementation:** `internal/memory/merge.go` imports `github.com/BackendStack21/go-vector/pkg/vector` for `RandomProjections` and `Cosine`. The RP embedder is fit on existing facts when the detector is created, and re-fit whenever facts change.
93+
**Implementation:** `internal/memory/merge.go` imports `github.com/BackendStack21/go-vector/pkg/vector` for `RandomProjections` and `Cosine`. The embedder is fit on existing facts when the detector is created, and re-fit whenever facts change. With `memory.embedding` configured (see *Pluggable Embeddings* below), the same classification runs over real semantic vectors from an OpenAI-compatible API instead of RP.
9494

9595
### Durability & Statefulness
9696

@@ -207,11 +207,53 @@ The episode index (`episodes/index.json`) is cached in memory after the first re
207207

208208
### Search Ranking
209209

210-
Episode search uses **RandomProjections** (go-vector) for semantic similarity by default:
210+
Episode search uses **RandomProjections** (go-vector) for similarity by default:
211211

212212
1. Fit RP embedder on episode summaries + query (64 dims, ~1ms)
213213
2. Embed each summary and the query into 64-dimensional vectors
214214
3. Score by cosine similarity between query vector and each summary vector
215215
4. Return top-3 results sorted by score
216216

217217
This is zero LLM calls per search, ~1ms per search. Set `llm_search: true` in config to switch to LLM-based ranking (uses SimpleCall to rank episodes by relevance — higher quality, higher latency + token cost).
218+
219+
### Pluggable Embeddings (`memory.embedding`)
220+
221+
RandomProjections is lexical: two texts only score as similar when they share
222+
vocabulary. *"fixed the auth bug"* vs *"repaired login issue"* → cosine ≈ 0,
223+
so recall misses semantically related episodes. The `memory.embedding` config
224+
section replaces RP with a real embedding model behind any OpenAI-compatible
225+
API (Ollama, llama.cpp server, LM Studio, vLLM, OpenAI, …):
226+
227+
```json
228+
{
229+
"memory": {
230+
"embedding": {
231+
"provider": "http",
232+
"base_url": "http://localhost:11434/v1",
233+
"model": "nomic-embed-text"
234+
}
235+
}
236+
}
237+
```
238+
239+
One config switches **every** similarity path: per-turn episode recall, the
240+
explicit `memory(search=...)` candidate retrieval, episode write-time dedup,
241+
the non-LLM episode ranker, and fact merge-on-write classification.
242+
243+
Mechanics (`internal/memory/embedder.go`):
244+
245+
- All paths go through the `textEmbedder` seam — `rp` (default, corpus-fitted,
246+
bigram-featurized) or `http` (stateless, raw text, cached).
247+
- The persisted episode vector index records its embedding space in
248+
`episodes_index_meta.json`; changing provider/model/dims invalidates and
249+
rebuilds it automatically. Pre-existing RP indexes (no meta file) keep
250+
loading without a rebuild.
251+
- The HTTP embedder caches text→vector, so an index rebuild after a new
252+
episode only embeds texts it hasn't seen — one batch call.
253+
- Failure mode: embedding errors degrade to "no recall context" / "add fact
254+
without merge check"; a failed index rebuild backs off for 30s so a down
255+
backend is not re-hit every loop turn. The agent loop is never blocked.
256+
257+
See `docs/CONFIG.md``embedding` for all fields and the privacy note
258+
(summaries are sent to the configured endpoint — use a local server if that
259+
matters).

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/BackendStack21/go-mcp v1.1.0
7-
github.com/BackendStack21/go-vector v1.2.0
7+
github.com/BackendStack21/go-vector v1.3.0
88
golang.org/x/net v0.54.0
99
golang.org/x/term v0.43.0
1010
)

go.sum

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
github.com/BackendStack21/go-mcp v1.1.0 h1:NQStOkqUWjzzzmySnfFHvPKhbKr92iBuPJjkNpb0MIU=
22
github.com/BackendStack21/go-mcp v1.1.0/go.mod h1:RKFw6nrl6ySQqqrR8KtG7HYZ/heyyjT8SjiEtlbTMY8=
3-
github.com/BackendStack21/go-vector v1.1.1 h1:sycI+a/ifT2DD3kdH0HleWtjKmIVNutvL/pgOL9qTA8=
4-
github.com/BackendStack21/go-vector v1.1.1/go.mod h1:+IzfAFO4m6xrjsOhZsiTAbMbm8+hX0d9C1uD0SGPzHc=
5-
github.com/BackendStack21/go-vector v1.2.0 h1:Zq6J/x7/OlAmVYC5t6VX64cf6V9rkSU2sR6nz49Fu+I=
6-
github.com/BackendStack21/go-vector v1.2.0/go.mod h1:+IzfAFO4m6xrjsOhZsiTAbMbm8+hX0d9C1uD0SGPzHc=
3+
github.com/BackendStack21/go-vector v1.3.0 h1:VT1cwPAUzkg3Rt0fXA+jTzW472jgObqs85/TcPs4N7Q=
4+
github.com/BackendStack21/go-vector v1.3.0/go.mod h1:TkxZEqKGeN38QNWUoQt4xJcf6n2kI1pY7nu6ibXK1Yo=
75
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
86
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
97
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=

internal/config/loader.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,9 @@ func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
924924
if cfg.EpisodeTTLDays > 0 {
925925
def.EpisodeTTLDays = cfg.EpisodeTTLDays
926926
}
927+
if cfg.Embedding != nil {
928+
def.Embedding = cfg.Embedding
929+
}
927930
return def
928931
}
929932

0 commit comments

Comments
 (0)