homelab-codex-ws/packages/kb-retrieval/tests/test_retrieval.py
oskar f964631d02 refactor(kb): extract packages/kb-retrieval from documents-ingest
Module 5 phase 4 step 0 (docs/kb/modules/05-faza4-plan.md, §3, decision 1):
kb-query is a long-lived Docker service, documents-ingest is a venv job with
an `anthropic` dependency and CLI scripts it doesn't need. Move
embed_chunk/_vector_literal/cascade_query/flat_query into a shared package
with minimal deps (asyncpg, aiohttp only) so both can depend on the same
tested retrieval code without the service image pulling in the job's extras.

documents_ingest.chunk_embed/retrieval keep thin re-exports so nothing
importing the old paths breaks. Pure refactor: retrieval_eval.py run live
against kb-postgres@PIHA + Ollama@SOLARIA before/after gives byte-identical
`dist`/hit@3/gate results (still PASS) — zero behavior change in the cascade.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:06:02 +02:00

175 lines
7 KiB
Python

"""Unit tests for the retrieval cascade -- no DB, no real HTTP/Ollama."""
from __future__ import annotations
import pytest
from kb_retrieval.retrieval import (
DEFAULT_K,
DEFAULT_N,
cascade_query,
cascade_retrieve,
flat_query,
flat_retrieve,
)
class _FakeConn:
"""summaries: [(envelope_id, dist), ...] already in distance order (mirrors what the real
`ORDER BY embedding <=> $1` would hand back). chunks_by_envelope: envelope_id -> [(chunk_index,
text, dist), ...]. Both stage-1 and the flat path are served from the same fixture so a test
can assert the cascade excludes chunks the flat path would have surfaced."""
def __init__(self, summaries=None, chunks_by_envelope=None):
self._summaries = list(summaries or [])
self._chunks_by_envelope = chunks_by_envelope or {}
self.queries: list[tuple] = []
async def fetch(self, query, *params):
self.queries.append((query, params))
if "FROM document_summary" in query:
_, _model, limit = params
return [{"envelope_id": eid, "dist": dist} for eid, dist in self._summaries[:limit]]
if "FROM document_chunk" in query and "= ANY" in query:
_, envelope_ids, limit = params
rows = [
{"envelope_id": eid, "chunk_index": idx, "text": text, "dist": dist}
for eid in envelope_ids
for idx, text, dist in self._chunks_by_envelope.get(eid, [])
]
rows.sort(key=lambda r: r["dist"])
return rows[:limit]
if "FROM document_chunk" in query: # flat path: no envelope pre-filter
_, limit = params
rows = [
{"envelope_id": eid, "chunk_index": idx, "text": text, "dist": dist}
for eid, chunk_list in self._chunks_by_envelope.items()
for idx, text, dist in chunk_list
]
rows.sort(key=lambda r: r["dist"])
return rows[:limit]
raise AssertionError(f"unexpected query: {query}")
async def close(self):
pass
class _FakeEmbedResponse:
def __init__(self, payload):
self._payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def raise_for_status(self):
pass
async def json(self):
return self._payload
class _FakeSession:
def __init__(self):
self.post_calls: list[dict] = []
def post(self, url, json):
self.post_calls.append({"url": url, "json": json})
return _FakeEmbedResponse({"embedding": [0.01] * 1024})
class TestDefaults:
def test_plan_start_values(self):
# plan §6.1: "Start: N=10, k=5" -- a regression guard against silently drifting off
# the value the quality gate (docs/kb/modules/05-faza3-plan.md §6.2) was run against.
assert DEFAULT_N == 10
assert DEFAULT_K == 5
class TestFlatRetrieve:
async def test_ranks_across_all_envelopes(self):
conn = _FakeConn(
chunks_by_envelope={
"paperless:1": [(0, "a", 0.5)],
"paperless:2": [(0, "b", 0.1)],
}
)
result = await flat_retrieve(conn, "[0.1]", k=5)
assert [c["envelope_id"] for c in result] == ["paperless:2", "paperless:1"]
assert all(c["source"] == "flat" for c in result)
async def test_respects_k(self):
conn = _FakeConn(
chunks_by_envelope={"paperless:1": [(i, f"c{i}", i / 10) for i in range(10)]}
)
result = await flat_retrieve(conn, "[0.1]", k=3)
assert len(result) == 3
class TestCascadeRetrieve:
async def test_stage1_narrows_stage2(self):
# paperless:3 has the single closest chunk overall, but it never enters stage 1's
# top-N summaries -- the cascade must not surface it, unlike a flat scan would.
conn = _FakeConn(
summaries=[("paperless:1", 0.1), ("paperless:2", 0.2)],
chunks_by_envelope={
"paperless:1": [(0, "a", 0.3)],
"paperless:2": [(0, "b", 0.4)],
"paperless:3": [(0, "c", 0.01)],
},
)
result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=2, k=5)
envelope_ids = {c["envelope_id"] for c in result["chunks"]}
assert envelope_ids == {"paperless:1", "paperless:2"}
assert all(c["source"] == "cascade" for c in result["chunks"])
async def test_envelope_without_active_chunks_yields_no_chunks_for_it(self):
# e.g. every chunk in this envelope is excluded_reason-flagged (junk/duplicate) --
# the summary still exists and surfaces in stage 1, stage 2 just finds nothing there.
conn = _FakeConn(summaries=[("paperless:1", 0.1)], chunks_by_envelope={})
result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=10, k=5)
assert result["stage1_summaries"] == [{"envelope_id": "paperless:1", "dist": 0.1}]
assert result["chunks"] == []
async def test_n_larger_than_available_summaries_returns_all_of_them(self):
conn = _FakeConn(
summaries=[("paperless:1", 0.1), ("paperless:2", 0.2)],
chunks_by_envelope={"paperless:1": [(0, "a", 0.3)], "paperless:2": [(0, "b", 0.4)]},
)
result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=1000, k=5)
assert len(result["stage1_summaries"]) == 2
assert {c["envelope_id"] for c in result["chunks"]} == {"paperless:1", "paperless:2"}
async def test_no_summaries_short_circuits_before_stage2_query(self):
conn = _FakeConn(summaries=[], chunks_by_envelope={"paperless:1": [(0, "a", 0.1)]})
result = await cascade_retrieve(conn, "[0.1]", "claude-haiku-4-5", n=10, k=5)
assert result == {"stage1_summaries": [], "chunks": []}
assert len(conn.queries) == 1 # stage 2 never ran -- nothing to narrow into
class TestQueryEntryPoints:
async def test_flat_query_embeds_once_and_returns_query_text(self):
conn = _FakeConn(chunks_by_envelope={"paperless:1": [(0, "a", 0.2)]})
session = _FakeSession()
result = await flat_query(conn, session, "http://fake-ollama", "sernik z rodzynkami", k=5)
assert result["query"] == "sernik z rodzynkami"
assert len(session.post_calls) == 1
assert result["chunks"][0]["source"] == "flat"
async def test_cascade_query_embeds_once_shared_across_both_stages(self):
conn = _FakeConn(
summaries=[("paperless:1", 0.1)],
chunks_by_envelope={"paperless:1": [(0, "a", 0.2)]},
)
session = _FakeSession()
result = await cascade_query(
conn, session, "http://fake-ollama", "polisa PZU", summary_model="claude-haiku-4-5", n=10, k=5
)
assert result["query"] == "polisa PZU"
assert result["n"] == 10
assert result["k"] == 5
# one embed call total, reused for both the stage-1 and stage-2 SQL queries
assert len(session.post_calls) == 1
assert result["chunks"][0]["source"] == "cascade"