feat(rag): hybrid FTS+vector retrieval and multi-format ingestion — name-your-tool questions find the right document
This commit is contained in:
@@ -94,7 +94,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 3
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
@@ -163,12 +163,19 @@ def test_chat_writes_query_log_row(client, db, seeded_kb: FakeRagLLM) -> None:
|
||||
assert row.question == QUESTION
|
||||
assert row.deflected is False
|
||||
total_chunks = db.scalar(select(func.count()).select_from(Chunk))
|
||||
assert row.chunk_hits == min(get_settings().top_k_chunks, total_chunks)
|
||||
# chunk_hits is the fused candidate set (cosine top-N ∪ FTS top-N).
|
||||
assert 1 <= row.chunk_hits <= total_chunks
|
||||
assert row.top_score > 0.0 # genuine token-overlap cosine, best hit
|
||||
assert row.top_score >= get_settings().relevance_threshold # why the gate answered
|
||||
assert row.top_score <= 1.0
|
||||
assert "docs/homelab/kubernetes.md" in row.sources
|
||||
assert row.latency_ms >= 0
|
||||
# Why the gate answered (A8 revised): cosine over the threshold OR a
|
||||
# lexical hit. The mock-calibrated threshold (0.30, see tests/conftest.py)
|
||||
# makes the cosine branch true here; the FTS branch is covered too —
|
||||
# "kubernetes" / "cluster" match the doc's tsvector.
|
||||
thr = get_settings().relevance_threshold
|
||||
assert row.top_score >= thr or (row.fts_hits or 0) > 0
|
||||
assert (row.fts_hits or 0) >= 1 # the lexical branch really fired
|
||||
|
||||
|
||||
def test_off_topic_question_deflects_honestly(client, db, seeded_kb: FakeRagLLM) -> None:
|
||||
@@ -202,14 +209,46 @@ def test_off_topic_question_deflects_honestly(client, db, seeded_kb: FakeRagLLM)
|
||||
assert "Talos Linux" not in system["content"] # full doc content never sent
|
||||
assert "<documents>" not in system["content"]
|
||||
|
||||
# Durable record: deflected=true + the weak top_score.
|
||||
# Durable record: deflected=true + the weak top_score. Deflection is
|
||||
# only reached when the cosine is under the threshold AND no chunk
|
||||
# FTS-matches the question — so fts_hits must be zero here.
|
||||
row = db.scalars(select(QueryLog)).one()
|
||||
assert row.question == OFF_TOPIC
|
||||
assert row.deflected is True
|
||||
assert 0.0 < row.top_score < get_settings().relevance_threshold
|
||||
assert row.fts_hits == 0
|
||||
assert row.chunk_hits >= 1
|
||||
|
||||
|
||||
def test_keyword_question_grounded_by_lexical_hit_despite_weak_cosine(
|
||||
client, db, seeded_kb: FakeRagLLM
|
||||
) -> None:
|
||||
"""Phase 09: a name-your-tool question the vector model barely ranks
|
||||
("kafkabridge" only appears in static-dns.json) must still be grounded
|
||||
via the FTS branch — LOW only fires at weak cosine AND zero hits."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_, _, frames = _stream_chat(client, "How does kafkabridge work?")
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
done = frames[-1]
|
||||
assert done["type"] == "done"
|
||||
assert done["deflected"] is False # weak cosine, but a lexical hit
|
||||
assert done["suggestions"] == []
|
||||
sources = done["sources"]
|
||||
assert sources and sources[0]["path"] == "homelab/networking/static-dns.json"
|
||||
|
||||
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
||||
assert "<relevance>HIGH</relevance>" in system["content"] # grounded prompt
|
||||
|
||||
row = db.scalars(select(QueryLog)).one()
|
||||
assert row.deflected is False
|
||||
assert row.top_score < get_settings().relevance_threshold # weak vector score
|
||||
assert (row.fts_hits or 0) >= 1 # …and it is the FTS hit that grounds it
|
||||
assert "docs/homelab/networking/static-dns.json" in row.sources
|
||||
|
||||
|
||||
def test_chat_empty_kb_streams_empty_sources(client, db) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user