8.9 KiB
Phase 09 — Story: Retrieval Quality — Multi-Format Ingestion + Hybrid Search
Story: .agent/user_stories/retrieval-quality.md
Context: .agent/PLAN.md §3 (data flow), §5 (data model), §6 (retrieval), §11 (import)
Goal
Fix "RAG retrieval is terrible": ingest the full text-format set (not
just .md), purge vendored-cache junk from the index, and replace
pure-cosine top-4 with hybrid (vector + Postgres FTS, RRF-fused)
retrieval so name-your-tool questions find the right document.
Owner permission (recorded per phase protocol)
"I'm giving you explicit permission to update the locked decisions and proceed with writing all 3 of these phases" — Reese, 2026-08-21.
This phase revises anchors A9 (content scope: *.md only →
md, markdown, txt, yaml, yml, json, py + hidden-dir skip), A7
(pure-cosine top-4 → hybrid RRF retrieval; the whole-document context
contract is preserved), A8 (gate: LOW only when best cosine <
threshold and zero FTS hits; threshold re-tuned 0.30 → 0.62 default).
PLAN.md anchors were updated 2026-08-21 under this permission.
Evidence (measured 2026-08-21 against the live KB + embed model)
- "How did I install gitlab?": the best
gitlab.mdchunk ranks 7th (cosine 0.804) — outside the top-4 window. Ranks 1–6: a vendored-cache README (.esphome/.espressif/…/esp-tflite-micro/README.md, 0.838) and generic templates (project_readme_template.md,templates/…/foobar. md, 0.81–0.82). The LLM therefore answered from junk docs and honestly reported "no notes on gitlab". - Corpus cosine range: 0.41–0.84 — the old 0.30 gate never discriminated.
- FTS:
plainto_tsquery('english','gitlab')matches exactly gitlab.md's 4 chunks and nothing else. - ~470 of 672 indexed docs live under dot-prefixed path components (vendored caches) that A9's exclusion list doesn't cover.
Dependencies
Phases 01–07 (02 importer, 03 retriever, 04 gate especially). Independent of 08 (backend + E2E only). Phase 10 builds on the new multi-format corpus.
Implementation steps
- Config (
app/config.py):import_extensions(csv, defaultmd,markdown,txt,yaml,yml,json,py; envBOR_IMPORT_EXTENSIONS),hybrid_vector_candidates(30,BOR_HYBRID_VECTOR_CANDIDATES),hybrid_lexical_candidates(30,BOR_HYBRID_LEXICAL_CANDIDATES),rrf_k(60,BOR_RRF_K),relevance_thresholddefault 0.62 (re-tuned; env override stays). Intests/e2e/conftest.py'sapp_serverfixture setBOR_RELEVANCE_THRESHOLD=0.30— the mock's token-overlap embeddings need their own calibration; this keeps stories 02–07's E2E suites green. - Chunker (
app/rag/chunker.py): add achunk_document(content, path)dispatcher by lowercased suffix + per-format functions — stdlib only, no new dependencies:yaml/yml: split on---document separators and top-level keys (indent-0key:lines); every chunk keeps its key line as anchor.json:json.dumps(obj, indent=2)then split at top-level keys (track brace depth); unparseable JSON → paragraph packing.py: stdlibasttop-level node line ranges → split at defs/classes; oversized functions fall back to line packing.txt: paragraph packing (reuse_paragraph_blocks/_pack_blocks).- All formats honor
HARD_MAX_CHARS(1200 — the aipi ~1024-token request cap) and the target/overlap settings; themdpath stays byte-for-byte unchanged (existing chunker tests must stay green).
- Importer (
app/rag/importer.py,scripts/import_docs.py): extension filter (case-insensitive, config-driven); skip any path containing a dot-prefixed component (hidden dirs); chunker dispatch by suffix;--prunenow also drops docs whose files no longer match the filter (this is how the ~470 junk docs leave the index); summary log gains per-format counts (formats=md:203,yaml:267,…). - Migration
0002_hybrid_retrieval.py(alembic):chunks.tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED+CREATE INDEX … USING gin (chunks.tsv).query_log.fts_hits INT(nullable; pre-existing rows stay NULL).
- Retriever (
app/rag/retriever.py) — hybrid path:retrieve(db, question, question_embedding): vector top-N (cosine, as today) ∪ lexical top-N —to_tsquery('english', <OR-joined stemmed tokens of the question>)(skip pure-stopword/no-token questions → empty lexical list), ordered byts_rank— fused with RRF:score = Σ 1/(k + rank)over the lists a chunk appears in (single-list chunks get one term; k from config).RetrievedChunkgainscosine(for the gate) andfts_hit: boolalongsidescore(now the fused score, used for ranking);select_documents/weak_hit_titleskeep working offscore.- Deterministic tie-break:
(−fused, −cosine, document.path, chunk.position).
- Chat flow + gate (
app/api/chat.py): pass the raw question intoretrieve; LOW only whenbest_cosine < threshold and fts_hits == 0(fts_hits= count of lexical candidates matched); per-turn log line gainsfts_hits=…(PLAN §9);query_logrow storesfts_hits. - Eval script
scripts/eval_retrieval.py:uv run python -m scripts.eval_retrieval "q1" "q2" …(or--from-file questions.txt) — embeds via aipi, runs the hybrid search, prints top-5 docs per question with cosine/fts/fused scores + the gate verdict. RequiresAIPI_KEYin the environment (same convention asllm_probe.py). - Re-import the live KB (one-time; expect ~15–40 min of embedding
batches — the importer logs per file):
uv run python -m scripts.import_docs --prune. Expect ~470 hidden-dir docs pruned and ~500 docs indexed (md + new formats). Then verify with the eval script:- "How did I install gitlab?" → top doc
active/container_gitlab/ gitlab.md(the compose yaml should land in the top-2). - "How is my Kubernetes cluster set up?" → kubernetes docs.
- "sourdough starter" → LOW (deflect). If the gitlab case isn't #1, iterate the fusion (k, candidate counts, token handling) — not the threshold — until it is, and record the final numbers in the phase report.
- "How did I install gitlab?" → top doc
- E2E fixtures (
tests/fixtures/docs/): addhomelab/container_gitlab/gitlab.md(H1 "Gitlab", docker install steps, "gitlab" repeated),homelab/container_gitlab/ gitlab-compose.yaml(services: gitlab: …), a.pynote, a.jsonnote, a.txtnote, and.hidden/junk.md(must never be imported). Follow the existing in-process seeding pattern fromtests/e2e/test_import_documents.py. - README: import workflow section — supported formats, hidden-dir
skip,
scripts/eval_retrieval.py, threshold tuning; note that the Sources count drops after the prune (intended cleanup).
Testing & Quality
- Unit: chunker per format (yaml top-level +
---split, json top-level keys + pretty-print + unparseable fallback, py ast split + oversized-func fallback, txt paragraphs, dispatch, 1200-cap) with md output unchanged; importer (hidden-dir skip, extension filter, prune-when-filtered-out, per-format summary); retriever (RRF math: both-lists / one-list / tie-break; OR tsquery construction incl. no-token and stopword-only questions; gate:cosine ≥ T→ HIGH;cosine < T+fts>0→ HIGH;cosine < T+fts=0→ LOW; boundary exactlyT→ HIGH). - Integration:
/api/chathybrid against a seeded temp schema — keyword question grounded +fts_hitsinquery_log; off-topic deflected withfts_hits=0; migration up clean. - Coverage:
uv run pytest --cov=app --cov-report=term-missing— >90% onapp/. - No regressions: existing story E2E suites (02–07) green in isolation after the change (the conftest threshold override is what keeps them green — verify each one).
Playwright Execution Phase
Run ONLY this story's suite:
uv run pytest tests/e2e/test_retrieval_quality.py -v --no-cov
Implements the story mapping: multi-format fixture import (hidden doc
excluded, /api/docs counts); "How did I install gitlab?" → grounded,
not deflected, gitlab chip, query_log row; keyword-only question beats
vector ranking (FTS-OR gate end to end); "sourdough" → deflected bubble +
≥2 chips.
Success criteria
- live eval: "How did I install gitlab?" →
gitlab.mdis the top doc - zero dot-prefixed path components in
documentsafter re-import - off-topic still deflects; on-topic still grounds (new + existing E2E)
- unit + integration green, coverage >90%, ruff + pyright green
- README documents formats / hidden-dir skip / eval / tuning
- committed
Commit
git add -A && git commit --no-gpg-sign -m "feat(rag): hybrid FTS+vector retrieval and multi-format ingestion — name-your-tool questions find the right document"