Wire token-budget packing into the overview/rerank fallback path

This commit is contained in:
2026-08-10 12:02:31 +07:00
parent 60b4397032
commit 46469468bb
18 changed files with 768 additions and 38 deletions
+57
View File
@@ -0,0 +1,57 @@
import pytest
from rag.fusion import reciprocal_rank_fusion
from rag.models import RetrievalDocument, SearchHit, SourceRef
SOURCE = SourceRef(physical_page=1, precision="page")
def _hit(doc_id: str, score: float = 1.0) -> SearchHit:
return SearchHit(
RetrievalDocument(
doc_id=doc_id,
drug_id="drug",
kind="prose",
text=doc_id,
section_key="section",
source_refs=(SOURCE,),
),
score,
)
def test_rrf_promotes_candidate_supported_by_dense_and_lexical_rankings() -> None:
fused = reciprocal_rank_fusion(
[[_hit("dense-only"), _hit("shared")], [_hit("shared"), _hit("lexical-only")]],
rank_constant=60,
)
assert [hit.document.doc_id for hit in fused] == [
"shared", "dense-only", "lexical-only",
]
def test_rrf_deduplicates_a_document_within_one_ranking() -> None:
fused = reciprocal_rank_fusion(
[[_hit("duplicate"), _hit("duplicate")], [_hit("other")]],
rank_constant=10,
)
duplicate = next(hit for hit in fused if hit.document.doc_id == "duplicate")
assert duplicate.score == pytest.approx(1 / 11)
def test_rrf_limit_and_ties_are_deterministic() -> None:
fused = reciprocal_rank_fusion(
[[_hit("first")], [_hit("second")]], rank_constant=60, limit=1,
)
assert [hit.document.doc_id for hit in fused] == ["first"]
@pytest.mark.parametrize(
("kwargs", "message"),
[({"rank_constant": 0}, "rank_constant"), ({"limit": -1}, "limit")],
)
def test_rrf_rejects_invalid_configuration(kwargs, message: str) -> None:
with pytest.raises(ValueError, match=message):
reciprocal_rank_fusion([], **kwargs)
@@ -159,3 +159,51 @@ def test_find_by_indication_respects_the_limit():
hits = retriever.find_by_indication("đau", limit=2)
assert len(hits) == 2
def _section_payload(drug_id: str, section_key: str, text: str) -> dict:
return {
"chunk_id": f"{drug_id}__{section_key}__0", "drug_id": drug_id,
"drug_name": drug_id.upper(), "section_key": section_key,
"chunk_kind": "prose", "text": text,
"heading_physical_page": 100, "printed_page_range": [101, 101],
}
def test_search_lexical_ranks_by_distinct_matched_term_count():
client = _FakeScrollClient([
_section_payload("aspirin", "than_trong", "Thận trọng với suy thận."),
_section_payload(
"aspirin", "chong_chi_dinh",
"Không dùng cho người có loét dạ dày tá tràng đang hoạt động.",
),
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
hits = retriever.search_lexical(
"Thận trọng khi dùng aspirin cho bệnh nhân loét dạ dày", "aspirin", limit=5
)
assert [h.document.section_key for h in hits] == ["chong_chi_dinh", "than_trong"]
assert hits[0].score > hits[1].score
def test_search_lexical_drops_stopword_only_queries():
client = _FakeScrollClient([
_section_payload("aspirin", "than_trong", "Thận trọng với suy thận."),
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
assert retriever.search_lexical("là gì và của", "aspirin", limit=5) == []
def test_search_lexical_excludes_non_matching_sections():
client = _FakeScrollClient([
_section_payload("aspirin", "than_trong", "Thận trọng với suy thận."),
_section_payload("aspirin", "chi_dinh", "Giảm đau hạ sốt."),
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
hits = retriever.search_lexical("loét dạ dày", "aspirin", limit=5)
assert hits == []
@@ -59,10 +59,19 @@ def table_service(*, visual: bool = False) -> RetrievalService:
class _OverviewRetriever:
"""A fake with `find_by_drug`/`find_by_section` (the Qdrant adapter's
shape) — `InMemoryLexicalRetriever` doesn't implement either, so
`retrieve_framed`'s overview path is otherwise untestable."""
`retrieve_framed`'s overview path is otherwise untestable.
def __init__(self, documents: list[RetrievalDocument]) -> None:
`search_lexical` is scripted per test (`lexical_hits`), not real text
matching — this file is about `retrieve_framed`'s own wiring, not the
scorer (see `test_qdrant_adapter.py`/`test_section_routing.py` for
that)."""
def __init__(
self, documents: list[RetrievalDocument], lexical_hits: list[SearchHit] = ()
) -> None:
self._documents = documents
self._lexical_hits = lexical_hits
self.lexical_calls: list[tuple[str, str]] = []
def find_by_drug(self, drug_id: str) -> list[SearchHit]:
return [
@@ -80,6 +89,10 @@ class _OverviewRetriever:
def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
return []
def search_lexical(self, query: str, drug_id: str, limit: int) -> list[SearchHit]: # noqa: ARG002
self.lexical_calls.append((query, drug_id))
return list(self._lexical_hits)
_MONOGRAPH_SECTIONS = (
"ten_chung_quoc_te", "ma_atc", "loai_thuoc", "dang_thuoc_va_ham_luong",
@@ -89,7 +102,7 @@ _MONOGRAPH_SECTIONS = (
)
def _monograph_service() -> RetrievalService:
def _monograph_service(max_context_tokens: int = 6000) -> RetrievalService:
documents = [
RetrievalDocument(
doc_id=f"paracetamol::{section}::0", drug_id="paracetamol",
@@ -100,7 +113,7 @@ def _monograph_service() -> RetrievalService:
]
return RetrievalService(
_OverviewRetriever(documents), InMemoryParentStore([]),
EvidencePolicy(evidence_limit=3),
EvidencePolicy(evidence_limit=3, max_context_tokens=max_context_tokens),
)
@@ -123,13 +136,65 @@ def test_retrieve_framed_overview_answers_from_intro_sections_only():
def test_retrieve_framed_question_without_section_is_capped_even_without_rerank():
# No reranker configured: `_rerank` fails open and returns everything
# unfiltered. Hydration must still bound it — an ordering aid failing
# open must not also remove the size cap.
result = _monograph_service().retrieve_framed(
# unfiltered. Hydration must still bound it by TOKEN budget (2026-08-10:
# was a flat evidence_limit count, now pack_evidence) — an ordering aid
# failing open must not also remove the size cap.
result = _monograph_service(max_context_tokens=50).retrieve_framed(
"paracetamol", None, "thuốc này có tác dụng phụ gì", is_overview=False
)
assert result.decision == EvidenceDecision.ANSWERABLE
assert len(result.evidence) <= 3
assert len(result.evidence) < len(_MONOGRAPH_SECTIONS)
def test_retrieve_framed_packs_overview_by_token_budget_not_flat_count():
"""2026-08-10 pipeline audit priority #1: a tight token budget can admit
FEWER than the old flat evidence_limit=3 when blocks are long, and a
generous one can admit MORE when blocks are short — proving this is
genuinely token-driven, not a renamed count cap."""
tiny_budget_result = _monograph_service(max_context_tokens=20).retrieve_framed(
"paracetamol", None, "thuốc này có tác dụng phụ gì", is_overview=False
)
generous_budget_result = _monograph_service(max_context_tokens=6000).retrieve_framed(
"paracetamol", None, "thuốc này có tác dụng phụ gì", is_overview=False
)
assert len(tiny_budget_result.evidence) < 3
assert len(generous_budget_result.evidence) > 3
def test_retrieve_framed_pools_lexically_strong_neighbour_section():
"""The LIVE agent path (`RagAgent` -> understanding -> `retrieve_framed`)
must get the same neighbour-pooling `retrieve()` does — found live
2026-08-10 that the first version of this fix only wired into
`retrieve()`, which the real HTTP request path does not call at all;
`retrieve_framed` has its own separate `if section_key:` branch."""
documents = [
RetrievalDocument(
doc_id=f"aspirin::{section}::0", drug_id="aspirin",
kind="prose", section_key=section,
text=f"Nội dung mục {section}.", source_refs=(SOURCE,),
)
for section in ("than_trong", "chong_chi_dinh")
]
chong_chi_dinh_hit = SearchHit(
document=next(d for d in documents if d.section_key == "chong_chi_dinh"),
score=1.0,
)
retriever = _OverviewRetriever(documents, lexical_hits=[
SearchHit(document=chong_chi_dinh_hit.document, score=7.0),
])
service = RetrievalService(retriever, InMemoryParentStore([]), EvidencePolicy())
result = service.retrieve_framed(
"aspirin", "than_trong",
"Thận trọng khi dùng aspirin cho bệnh nhân loét dạ dày là gì?",
)
assert result.decision == EvidenceDecision.ANSWERABLE
returned_sections = {e.matched_doc_id.split("::")[1] for e in result.evidence}
assert returned_sections == {"than_trong", "chong_chi_dinh"}
assert retriever.lexical_calls == [
("Thận trọng khi dùng aspirin cho bệnh nhân loét dạ dày là gì?", "aspirin"),
]
def test_row_hit_hydrates_complete_parent_and_keeps_citation():
+90 -6
View File
@@ -56,6 +56,22 @@ class SectionAwareRetriever:
]
class LexicalAwareRetriever(SectionAwareRetriever):
"""Adds `search_lexical`, scripted per test rather than doing real
text matching — this suite is about `_pooled_neighbour_hits`' bounding
and threshold logic, not the lexical scorer itself (see
`adapters/qdrant.py`'s own coverage for that)."""
def __init__(self, docs: list[RetrievalDocument], lexical_hits: list[SearchHit]) -> None:
super().__init__(docs)
self._lexical_hits = lexical_hits
self.lexical_calls: list[tuple[str, str]] = []
def search_lexical(self, query: str, drug_id: str, limit: int) -> list[SearchHit]: # noqa: ARG002
self.lexical_calls.append((query, drug_id))
return self._lexical_hits
class SimilarityOnlyRetriever:
def __init__(self, docs: list[RetrievalDocument]) -> None:
self._docs = docs
@@ -78,7 +94,8 @@ CONTRA = [
INDICATION = [_doc("i1", "chi_dinh", "Giảm đau, hạ sốt, chống viêm.")]
PHARMACOLOGY = [_doc("p1", "duoc_ly_va_co_che_tac_dung", "Ức chế cyclooxygenase.")]
PRECAUTION = [_doc("t1", "than_trong", "Thận trọng với người suy thận.")]
ALL_DOCS = CONTRA + INDICATION + PHARMACOLOGY + PRECAUTION
INTERACTION = [_doc("x1", "tuong_tac_thuoc", "Tương tác với thuốc chống đông máu.")]
ALL_DOCS = CONTRA + INDICATION + PHARMACOLOGY + PRECAUTION + INTERACTION
def _service(retriever, resolver: SectionResolver | None) -> RetrievalService:
@@ -208,22 +225,89 @@ class TestSectionRouting:
assert retriever.section_calls == []
assert retriever.search_calls
def test_than_trong_also_pools_chong_chi_dinh(self) -> None:
def test_lexically_strong_neighbour_section_is_pooled_in(self) -> None:
"""A precaution some drug's own "thận trọng" text never mentions can
still be filed under "chống chỉ định" (found live: Aspirin + loét dạ
dày). Pooling both keeps that answerable instead of a false "not in
this source" clarify/abstain."""
retriever = SectionAwareRetriever(ALL_DOCS)
dày). A neighbour section with real term overlap (score above
threshold) gets pooled in whole, not just the matching fragment."""
retriever = LexicalAwareRetriever(
ALL_DOCS, lexical_hits=[SearchHit(CONTRA[1], 6.0)], # c2: "chong_chi_dinh"
)
result = _service(retriever, SectionResolver()).retrieve(
"Thận trọng khi dùng aspirin là gì?", "aspirin"
"Thận trọng khi dùng aspirin cho bệnh nhân loét dạ dày là gì?", "aspirin"
)
assert retriever.section_calls == [
("aspirin", "than_trong"), ("aspirin", "chong_chi_dinh"),
]
assert retriever.lexical_calls == [
("Thận trọng khi dùng aspirin cho bệnh nhân loét dạ dày là gì?", "aspirin"),
]
# Whole section pooled (c1-c5), not just the one matching chunk (c2).
assert {item.evidence_id for item in result.evidence} == {
"t1", "c1", "c2", "c3", "c4", "c5",
}
def test_weak_lexical_match_is_not_pooled(self) -> None:
"""One incidental token overlap must not drag in a whole tangential
section — only real term overlap (>= threshold) counts."""
retriever = LexicalAwareRetriever(
ALL_DOCS, lexical_hits=[SearchHit(CONTRA[1], 4.0)],
)
result = _service(retriever, SectionResolver()).retrieve(
"Thận trọng khi dùng aspirin là gì?", "aspirin"
)
assert retriever.section_calls == [("aspirin", "than_trong")]
assert {item.evidence_id for item in result.evidence} == {"t1"}
def test_lexical_pooling_is_bounded(self) -> None:
"""At most 2 neighbour sections pool in, even if more score above
threshold — unbounded pooling is precision loss dressed as recall."""
retriever = LexicalAwareRetriever(
ALL_DOCS,
lexical_hits=[
SearchHit(CONTRA[0], 7.0), # chong_chi_dinh
SearchHit(INDICATION[0], 6.0), # chi_dinh
SearchHit(INTERACTION[0], 5.0), # tuong_tac_thuoc — 3rd, over bound
],
)
result = _service(retriever, SectionResolver()).retrieve(
"Thận trọng khi dùng aspirin là gì?", "aspirin"
)
assert retriever.section_calls == [
("aspirin", "than_trong"),
("aspirin", "chong_chi_dinh"),
("aspirin", "chi_dinh"),
]
# tuong_tac_thuoc (3rd-ranked lexical hit) never pooled — the bound
# (2 neighbours) already used by chong_chi_dinh + chi_dinh.
assert "x1" not in {item.evidence_id for item in result.evidence}
def test_generic_pharmacology_section_is_never_pooled(self) -> None:
"""`duoc_ly_va_co_che_tac_dung` is the corpus's documented
false-positive attractor (see `sections.py`) — excluded outright,
even with a lexical score that would otherwise clear the threshold
(measured live: it scored a close second right behind the real
answer on the exact query that motivated this whole mechanism)."""
retriever = LexicalAwareRetriever(
ALL_DOCS, lexical_hits=[SearchHit(PHARMACOLOGY[0], 9.0)],
)
result = _service(retriever, SectionResolver()).retrieve(
"Thận trọng khi dùng aspirin là gì?", "aspirin"
)
assert retriever.section_calls == [("aspirin", "than_trong")]
assert "p1" not in {item.evidence_id for item in result.evidence}
def test_retriever_without_lexical_search_still_works(self) -> None:
"""No `search_lexical` on the retriever (interface segregation, same
pattern as `find_by_section`/`find_by_drug`) — pooling is skipped,
not an error."""
retriever = SectionAwareRetriever(ALL_DOCS)
result = _service(retriever, SectionResolver()).retrieve(
"Thận trọng khi dùng aspirin là gì?", "aspirin"
)
assert retriever.section_calls == [("aspirin", "than_trong")]
assert {item.evidence_id for item in result.evidence} == {"t1"}
def test_named_but_empty_section_falls_back(self) -> None:
"""A drug with no such section must not abstain — similarity still tries."""
retriever = SectionAwareRetriever(INDICATION + PHARMACOLOGY)