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:
2026-06-27 17:52:27 +00:00
parent 6e9dfd137c
commit 5c9748ffb4
23 changed files with 1526 additions and 164 deletions

View File

@@ -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