Skip to content

Commit d4bfc15

Browse files
Kodaxadevclaude
andcommitted
feat: hybrid semantic search behind CHRONOS_SEMANTIC flag (v4.1.0)
Opt-in local semantic retrieval — the scoped escape hatch documented in KNOWN_LIMITATIONS since v4.0. Off by default; flag-off recall behavior is byte-identical to v4.0 (asserted by test). - chronos/semantic.py: SemanticSearch — local ONNX embeddings via the new [semantic] extra (fastembed, bge-small-en-v1.5, ~70MB once, no API keys, no torch). Vectors persist in memory_embeddings with a content hash for stale detection and the model name for clean model switches. Startup backfill covers memories written/edited while the flag was off; writes are write-through; purge removes vectors. - chronos/ranking.py: recall pipeline extracted from memory.py (400-line gate) — hybrid candidate retrieval and Reciprocal Rank Fusion (k=60). Hybrid, not re-rank-only: re-ranking cannot fix the synonym problem when BM25 returns zero candidates. 'car' now finds 'automobile'. - memory.py is a thin facade; stats resource reports semantic vector count; README/docs updated, including the security section ('no network' is now honestly 'no network by default' — the one-time model download is the sole opt-in exception). - Tests: 9 new, using an injected deterministic fake embedder so CI never downloads a model; the fail-loud-when-extra-missing path runs in CI, and a real-model smoke test runs where the extra is installed (verified locally end-to-end: zero-lexical-overlap query ranked the semantic match first with the real ONNX model). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent dfb7017 commit d4bfc15

16 files changed

Lines changed: 793 additions & 198 deletions

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,37 @@ All notable changes to ChronosMCP are documented here. Entries are ordered newes
44

55
---
66

7+
## v4.1.0 — 2026-06-10
8+
9+
### Hybrid semantic search (opt-in)
10+
11+
`CHRONOS_SEMANTIC=1` + `pip install "chronosmcp[semantic]"` upgrades recall
12+
from lexical BM25 to **hybrid retrieval**: BM25 candidates and local-embedding
13+
nearest neighbors (fastembed / ONNX bge-small-en-v1.5, ~70MB downloaded once,
14+
no API keys, no torch) merged with Reciprocal Rank Fusion. "car" now finds a
15+
memory about an *automobile* with zero lexical overlap. Off by default —
16+
flag-off behavior is byte-identical to v4.0 and asserted by test.
17+
18+
Design notes:
19+
- Hybrid retrieval, not re-rank-only: re-ranking can't fix the synonym
20+
problem when BM25 returns zero candidates to re-rank.
21+
- RRF fusion (k=60) avoids score-normalization between BM25 and cosine.
22+
- Vectors persist in the new `memory_embeddings` table with a content hash;
23+
startup backfill (stale-checked) covers memories written or edited while
24+
the flag was off. Writes are write-through; purge removes vectors.
25+
- Brute-force numpy cosine — single-digit ms at personal scale, documented
26+
ceiling in KNOWN_LIMITATIONS.
27+
- Recall pipeline extracted to `chronos/ranking.py` (memory.py stays a thin
28+
facade under the 400-line limit).
29+
- Tests inject a deterministic fake embedder (CI downloads nothing); a
30+
real-model smoke test runs where the extra is installed. Verified locally
31+
end-to-end with the real ONNX model.
32+
33+
Honesty update: the README "no network" claim is now "no network by default" —
34+
the semantic flag's one-time model download is the sole, opt-in exception.
35+
36+
---
37+
738
## v4.0.0 — 2026-06-10
839

940
Major release driven by a full adversarial audit of v3.3 (code quality, security,

README.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,27 @@ task suggestions (`suggest_next_tasks`) are worth it to you.
132132
| Variable | Default | Description |
133133
|---|---|---|
134134
| `CHRONOS_DB_PATH` | `./chronos.db` | Path to the SQLite database file |
135+
| `CHRONOS_SEMANTIC` | unset | Set to `1` for hybrid semantic search (needs the `[semantic]` extra) |
136+
| `CHRONOS_SEMANTIC_MODEL` | `BAAI/bge-small-en-v1.5` | Embedding model when semantic search is on |
135137
| `CHRONOS_ENABLE_GRAPH` | unset | Set to `1` to enable the experimental graph layer |
136138

139+
### Optional: semantic search
140+
141+
By default search is lexical (BM25 + porter stemming) — zero extra
142+
dependencies, instant startup. If you want "car" to find a memory about an
143+
*automobile*:
144+
145+
```bash
146+
pip install "chronosmcp[semantic]" # local ONNX model, no API keys, no torch
147+
```
148+
149+
then set `CHRONOS_SEMANTIC=1` in the server env. Retrieval becomes **hybrid**:
150+
BM25 candidates and embedding nearest-neighbors (a ~70MB local model,
151+
downloaded once) are merged with Reciprocal Rank Fusion, so exact-term matches
152+
and meaning matches both surface. Results gain a `semantic_similarity` field.
153+
First startup on an existing database embeds your whole corpus — minutes,
154+
once; everything after is write-through and milliseconds.
155+
137156
---
138157

139158
## Security Model — read this once
@@ -152,8 +171,10 @@ reassuring:
152171
descriptions instruct Claude accordingly.
153172
- **Injection-safe queries.** Free text never reaches FTS5 MATCH syntax or SQL —
154173
queries are reduced to quoted terms, and every statement is parameterized.
155-
- **No network.** The server makes zero outbound connections. Verify it: the only
156-
imports are `mcp`, `numpy`, and the standard library.
174+
- **No network by default.** The base server makes zero outbound connections —
175+
the only imports are `mcp`, `numpy`, and the standard library. The one
176+
exception is opt-in: `CHRONOS_SEMANTIC=1` downloads its embedding model from
177+
Hugging Face once, after which inference is fully local.
157178

158179
---
159180

@@ -178,9 +199,10 @@ faded become prune candidates. The system's beliefs converge toward what you
178199
actually use and verify — and every confidence change is written to an audit
179200
table, so you can always ask *why* it believes what it believes.
180201

181-
Search is lexical (BM25 + stemming), not semantic — "JWT expiry" won't match
182-
"token timeout" unless words overlap. That's a deliberate zero-dependency
183-
trade-off, documented with the rest in
202+
Out of the box, search is lexical (BM25 + stemming) — a deliberate
203+
zero-dependency trade-off. The optional `[semantic]` extra upgrades recall to
204+
hybrid lexical+vector retrieval with a local model (see Configuration);
205+
remaining boundaries are documented in
184206
[docs/KNOWN_LIMITATIONS.md](docs/KNOWN_LIMITATIONS.md).
185207

186208
---

chronos/db.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ def db_path() -> str:
117117
memory_id TEXT NOT NULL,
118118
purged_at TEXT NOT NULL
119119
)""",
120+
# --- v4.1: semantic vectors (optional, populated only when
121+
# CHRONOS_SEMANTIC=1). content_hash detects stale vectors after edits
122+
# made while the flag was off; model allows clean model switches. ---
123+
"""CREATE TABLE IF NOT EXISTS memory_embeddings (
124+
memory_id TEXT PRIMARY KEY,
125+
vector BLOB NOT NULL,
126+
dim INTEGER NOT NULL,
127+
model TEXT NOT NULL,
128+
content_hash TEXT NOT NULL
129+
)""",
120130
]
121131

122132
# v4.0: FTS5 full-text index + sync triggers.

chronos/lifecycle.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ def purge(memory_id: str) -> dict:
103103
db.execute(
104104
"DELETE FROM search_feedback WHERE memory_id = ?", (memory_id,)
105105
)
106+
db.execute(
107+
"DELETE FROM memory_embeddings WHERE memory_id = ?", (memory_id,)
108+
)
106109
# Legacy v3.x structural-vector table, present only in old DBs
107110
legacy = db.execute(
108111
"SELECT name FROM sqlite_master "

chronos/memory.py

Lines changed: 48 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,46 @@
11
# chronos/memory.py
2-
# Responsibility: High-level memory operations — remember, recall, get, update.
3-
# Lifecycle transitions (forget/restore/purge) live in lifecycle.py;
4-
# time-travel lives in time_travel.py. This is the primary interface
5-
# between MCP tools and the storage layer.
2+
# Responsibility: High-level memory operations — remember, get, update —
3+
# and the MemoryStore facade that tool handlers close over.
4+
# Delegation map (each module owns its logic, this file stays thin):
5+
# recall pipeline → ranking.py
6+
# forget / restore / purge → lifecycle.py
7+
# time-travel → time_travel.py
8+
# semantic vectors (opt-in) → semantic.py
69
#
7-
# Architecture (v4.0):
10+
# Architecture (v4.x):
811
# - memories table → persistent text storage (db.py)
912
# - memories_fts → BM25 full-text index, trigger-synced (db.py, search.py)
13+
# - memory_embeddings→ semantic vectors, write-through ONLY when a
14+
# SemanticSearch is injected (CHRONOS_SEMANTIC=1)
1015
#
11-
# The v3.x in-memory TF-IDF index and structural memory vectors are gone:
12-
# the index now lives in the same SQLite file and commits atomically with
13-
# content writes. MemoryStore holds no state of its own.
14-
#
15-
# Token budget design:
16-
# recall() reports a token_estimate per result. Compression is applied
17-
# ONLY when the caller passes token_budget — results are never silently
18-
# truncated (v3.3 trimmed anything over 150 tokens unconditionally, and
19-
# offered no way to fetch full content; both fixed in v4.0).
16+
# MemoryStore holds no state of its own beyond the optional semantic handle.
2017

2118
import json
2219
from datetime import datetime
2320
from typing import List, Optional
2421

25-
from chronos import lifecycle
26-
from chronos.beliefs import CONFIDENCE_DEFAULT, STABILITY_DEFAULT, BeliefEngine
27-
from chronos.compression import compress_results
22+
from chronos import lifecycle, ranking
2823
from chronos.db import get_db
29-
from chronos.search import estimate_tokens, search_memories
24+
from chronos.search import estimate_tokens
3025
from chronos.time_travel import query_at as _time_travel_query_at
3126
from chronos.uuid7 import uuid7
3227

33-
# Approximate tokens consumed by the recall response wrapper itself
34-
_RECALL_OVERHEAD_TOKENS = 40
35-
36-
# Recency decay: score multiplier = 1 + recency_weight * boost.
37-
# Default 0.3 gives a mild boost to fresh/confident memories without
38-
# inverting rankings on high-scoring older ones. Set to 0.0 to disable.
39-
_DEFAULT_RECENCY_WEIGHT = 0.3
40-
41-
42-
def _recency_factor(created_at_iso: str) -> float:
43-
"""
44-
Returns 1/(1+days_old) — approaches 1 for brand-new, approaches 0 for old.
45-
Falls back to 0.0 if the timestamp cannot be parsed.
46-
Legacy fallback used when FSRS data is unavailable.
47-
"""
48-
try:
49-
created = datetime.fromisoformat(created_at_iso)
50-
now = datetime.now()
51-
days = max(0.0, (now - created.replace(tzinfo=None)).total_seconds() / 86400)
52-
return 1.0 / (1.0 + days)
53-
except (ValueError, TypeError):
54-
return 0.0
55-
56-
57-
def _fsrs_boost(stability: float, last_reviewed: str, created_at: str,
58-
confidence: float) -> float:
59-
"""
60-
FSRS-aware ranking boost. Combines retention probability with confidence.
61-
62-
Returns a value in [0, 1]. Higher when the memory is both confident AND
63-
well-retained:
64-
boost = retention * confidence_factor
65-
retention = (1 + days/(9*S))^(-1) [FSRS forgetting curve]
66-
confidence_factor = 0.5 + 0.5*confidence [0.01→0.505, 0.99→0.995]
67-
68-
Confidence alone doesn't override staleness, and vice versa.
69-
"""
70-
review_ts = last_reviewed or created_at
71-
days = BeliefEngine.days_since(review_ts)
72-
stab = stability if stability and stability > 0 else STABILITY_DEFAULT
73-
retention = BeliefEngine.compute_retention(stab, days)
74-
75-
conf = confidence if confidence is not None else CONFIDENCE_DEFAULT
76-
confidence_factor = 0.5 + 0.5 * conf
77-
78-
return retention * confidence_factor
79-
8028

8129
class MemoryStore:
8230
"""
83-
Full lifecycle of free-text memories. Stateless — all persistence and
84-
indexing live in SQLite (see db.py). Constructed once at startup and
85-
injected into tool handlers via closure.
31+
Full lifecycle of free-text memories. All persistence and indexing live
32+
in SQLite (see db.py). Constructed once at startup and injected into
33+
tool handlers via closure.
34+
35+
semantic: optional SemanticSearch. When provided, remember() and
36+
update() write-through to the embedding index, and recall() runs in
37+
hybrid (BM25 ∪ vector, RRF-fused) mode. None = pure lexical v4.0
38+
behavior.
8639
"""
8740

41+
def __init__(self, semantic=None) -> None:
42+
self.semantic = semantic
43+
8844
# ------------------------------------------------------------------
8945
# remember
9046
# ------------------------------------------------------------------
@@ -98,7 +54,8 @@ def remember(
9854
) -> dict:
9955
"""
10056
Store a free-text memory. The FTS trigger indexes it in the same
101-
transaction as the insert.
57+
transaction as the insert; the semantic index (when enabled) is
58+
updated write-through immediately after.
10259
10360
source: provenance label — where this memory's content came from
10461
(e.g. 'user', 'claude', 'web', 'document'). Recall results echo it
@@ -124,6 +81,9 @@ def remember(
12481
)
12582
db.commit()
12683

84+
if self.semantic is not None:
85+
self.semantic.embed_memory(mem_id, content.strip())
86+
12787
return {
12888
"id": mem_id,
12989
"project": project,
@@ -132,101 +92,30 @@ def remember(
13292
}
13393

13494
# ------------------------------------------------------------------
135-
# recall
95+
# recall — delegated to ranking.py
13696
# ------------------------------------------------------------------
13797

13898
def recall(
13999
self,
140100
query: str,
141101
project: Optional[str] = None,
142102
k: int = 5,
143-
recency_weight: float = _DEFAULT_RECENCY_WEIGHT,
103+
recency_weight: float = ranking.DEFAULT_RECENCY_WEIGHT,
144104
token_budget: Optional[int] = None,
145105
) -> dict:
146106
"""
147-
Retrieve the k most relevant memories for a query.
148-
149-
Ranking: BM25 over the FTS5 index (porter-stemmed), then re-ranked
150-
with an FSRS retention*confidence boost when recency_weight > 0.
151-
Scores are relative ranking values, not normalised [0,1].
152-
153-
Compression runs ONLY when token_budget is set. Without a budget,
154-
content is returned in full.
155-
156-
Returns:
157-
{
158-
results: [{id, project, content, score, token_estimate,
159-
source, confidence?}],
160-
total_tokens: int,
161-
count: int,
162-
query: str,
163-
compression_applied: [..] (only when token_budget was set)
164-
}
107+
Retrieve the k most relevant memories. Lexical BM25 by default;
108+
hybrid BM25 ∪ semantic with RRF fusion when semantic search is
109+
enabled. See ranking.recall() for the full pipeline.
165110
"""
166-
if not query or not query.strip():
167-
return {"results": [], "total_tokens": 0, "count": 0, "query": query}
168-
169-
# Over-fetch when re-ranking may reorder the top-k
170-
fetch_k = k * 3 if recency_weight > 0 else k
171-
with get_db() as db:
172-
rows = search_memories(db, query, project=project, k=fetch_k)
173-
174-
boosted = []
175-
for r in rows:
176-
score = r["score"]
177-
if recency_weight > 0:
178-
if r["stability"] is not None:
179-
boost = _fsrs_boost(
180-
r["stability"], r["last_reviewed"],
181-
r["created_at"], r["confidence"],
182-
)
183-
else:
184-
boost = _recency_factor(r["created_at"])
185-
score = score * (1.0 + recency_weight * boost)
186-
boosted.append((r, score))
187-
188-
boosted.sort(key=lambda x: x[1], reverse=True)
189-
boosted = boosted[:k]
190-
191-
results = []
192-
total_tokens = _RECALL_OVERHEAD_TOKENS
193-
194-
for r, score in boosted:
195-
tok_est = estimate_tokens(r["content"])
196-
total_tokens += tok_est
197-
entry = {
198-
"id": r["id"],
199-
"project": r["project"],
200-
"content": r["content"],
201-
"score": round(score, 5),
202-
"token_estimate": tok_est,
203-
"source": r["source"] or "user",
204-
}
205-
if r["confidence"] is not None:
206-
entry["confidence"] = round(r["confidence"], 4)
207-
results.append(entry)
208-
209-
# v4.0: compression is opt-in. No budget → full content, always.
210-
if token_budget is not None:
211-
compressed = compress_results(
212-
results,
213-
budget=token_budget,
214-
overhead=_RECALL_OVERHEAD_TOKENS,
215-
)
216-
return {
217-
"results": compressed["results"],
218-
"total_tokens": compressed["total_tokens"],
219-
"count": len(compressed["results"]),
220-
"query": query,
221-
"compression_applied": compressed["compression_applied"],
222-
}
223-
224-
return {
225-
"results": results,
226-
"total_tokens": total_tokens,
227-
"count": len(results),
228-
"query": query,
229-
}
111+
return ranking.recall(
112+
query,
113+
project=project,
114+
k=k,
115+
recency_weight=recency_weight,
116+
token_budget=token_budget,
117+
semantic=self.semantic,
118+
)
230119

231120
# ------------------------------------------------------------------
232121
# get — full single-memory retrieval (no truncation, ever)
@@ -279,8 +168,9 @@ def get(self, memory_id: str) -> dict:
279168
def update(self, memory_id: str, content: str) -> dict:
280169
"""
281170
Replace the content of an existing memory. The old content is
282-
snapshotted into memory_versions (time-travel), and the FTS trigger
283-
re-indexes the new content in the same transaction.
171+
snapshotted into memory_versions (time-travel), the FTS trigger
172+
re-indexes the new content in the same transaction, and the
173+
semantic vector (when enabled) is refreshed write-through.
284174
Raises ValueError if the memory does not exist or is forgotten.
285175
286176
Returns: {id, status, token_estimate}
@@ -320,6 +210,9 @@ def update(self, memory_id: str, content: str) -> dict:
320210
)
321211
db.commit()
322212

213+
if self.semantic is not None:
214+
self.semantic.embed_memory(memory_id, content.strip())
215+
323216
return {
324217
"id": memory_id,
325218
"status": "updated",

0 commit comments

Comments
 (0)