feat(memory): hybrid retrieval — navigation index.md + RAG hardening
Expose a navigation layer to the agent and harden RAG, after analyzing the OKF note and testing on the real KB. - memory_search.search(): dedupe best-chunk-per-file (a relevant note can no longer be buried by another file's chunks) + keyword fallback tagged degraded:True when Ollama is unreachable (no more hard crash). - update_notes_index.py: emit per-folder index.md + root router; prune empty folders; fix latent subcategory->project bug. - Exclude generated index.md from RAG rglob (reindex/incremental) + indexer scans + heartbeat freshness check (prevents self-pollution / reindex thrash). - CLAUDE.md: reframe memory as hybrid (navigation first, RAG for fuzzy recall). - Delete stale orphan kb/youtube/index.json; correct the OKF source note. - Tests: dedup, keyword fallback, index.md exclusion. Plan + review in docs/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -440,6 +440,24 @@ class TestReindex:
|
||||
assert stats["files"] == 0
|
||||
assert stats["chunks"] == 0
|
||||
|
||||
@patch("src.memory_search.get_embedding", return_value=FAKE_EMBEDDING)
|
||||
def test_reindex_skips_generated_index_md(self, mock_emb, mem_iso):
|
||||
"""Generated nav files (index.md) must not be embedded as if they were notes."""
|
||||
sub = mem_iso["mem_dir"] / "kb"
|
||||
sub.mkdir()
|
||||
_write_md(sub, "real-note.md", "A real note.\n")
|
||||
_write_md(sub, "index.md", "# Index — kb/\n- generated nav\n")
|
||||
|
||||
stats = reindex()
|
||||
assert stats["files"] == 1 # only the real note
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
files = [r[0] for r in conn.execute("SELECT DISTINCT file_path FROM chunks").fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
assert not any(f.endswith("index.md") for f in files)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search
|
||||
@@ -540,6 +558,37 @@ class TestSearch:
|
||||
results = search("test")
|
||||
assert len(results) == 5
|
||||
|
||||
@patch("src.memory_search.get_embedding")
|
||||
def test_search_dedupes_to_best_chunk_per_file(self, mock_emb, mem_iso):
|
||||
"""A file with several chunks appears once, at its best score, so a
|
||||
relevant note can't be buried by another file's multiple chunks."""
|
||||
query_vec = [1.0, 0.0, 0.0] + [0.0] * (EMBEDDING_DIM - 3)
|
||||
mock_emb.return_value = query_vec
|
||||
# noisy.md has 3 mediocre chunks; relevant.md has 1 strong chunk.
|
||||
self._seed_db(mem_iso, [
|
||||
("noisy.md", "noise a", [0.6, 0.4, 0.0] + [0.0] * (EMBEDDING_DIM - 3)),
|
||||
("noisy.md", "noise b", [0.55, 0.45, 0.0] + [0.0] * (EMBEDDING_DIM - 3)),
|
||||
("noisy.md", "noise c", [0.5, 0.5, 0.0] + [0.0] * (EMBEDDING_DIM - 3)),
|
||||
("relevant.md", "the answer", [0.99, 0.01, 0.0] + [0.0] * (EMBEDDING_DIM - 3)),
|
||||
])
|
||||
results = search("q", top_k=2)
|
||||
files = [r["file"] for r in results]
|
||||
assert files.count("noisy.md") == 1 # deduped
|
||||
assert results[0]["file"] == "relevant.md" # not buried
|
||||
|
||||
@patch("src.memory_search.get_embedding", side_effect=ConnectionError("offline"))
|
||||
def test_search_falls_back_to_keyword_when_offline(self, mock_emb, mem_iso):
|
||||
"""When the embedding backend is down, search() returns keyword matches
|
||||
tagged degraded instead of raising."""
|
||||
self._seed_db(mem_iso, [
|
||||
("match.md", "open knowledge format for agents", FAKE_EMBEDDING),
|
||||
("other.md", "completely unrelated cooking recipe", FAKE_EMBEDDING),
|
||||
])
|
||||
results = search("knowledge format", top_k=3)
|
||||
assert results, "expected keyword fallback results, got none"
|
||||
assert results[0]["file"] == "match.md"
|
||||
assert all(r.get("degraded") for r in results)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI commands: memory search, memory reindex
|
||||
|
||||
Reference in New Issue
Block a user