Spaces:
Running
Running
File size: 10,771 Bytes
e86dfae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | """
Retrieval quality evaluation against the golden set.
This measures the real retrieval pipeline — hybrid_search (pgvector + full-text
via RRF) followed by the cross-encoder reranker — and fails the build if
quality regresses below the configured floors.
Embeddings come from a small local sentence-transformers model rather than the
Gemini API, so the suite is deterministic and needs no API keys in CI. That is
a deliberate trade: it does not measure how good Gemini's embeddings are, it
measures whether the *pipeline* still works. Those are different questions, and
this is the one that catches regressions.
The regression it exists to catch already happened once: the lexical arm used
pg_trgm whole-string similarity, scored ~0.13 against a 0.30 threshold, and
returned zero rows for every query — so RRF silently fused one list with
nothing for the entire life of the feature. Nothing failed.
Measured finding — aggregate metrics alone do NOT catch that
------------------------------------------------------------
Stubbing the lexical arm to return [] and re-running this suite against a
130-chunk corpus leaves every aggregate metric unchanged: hit_rate@5 1.000,
MRR 1.000, nDCG@5 0.968. The cross-encoder reranker fully compensates, because
dense retrieval still surfaces the right chunk inside the top-20 over-fetch and
the reranker promotes it to the top.
Two consequences, both deliberate in the design below:
1. Aggregate scores cannot be the only gate. The direct guards
(test_lexical_arm_returns_rows, test_citation_lookalikes_are_distinguished)
assert the lexical arm returns rows at all — they are what actually fail
when it breaks, and they do.
2. On this corpus the lexical arm currently contributes no measurable lift
over vector+rerank. That is real information about the architecture, not
a gap in the tests. It says the hybrid arm earns its keep on recall for
queries the reranker never sees candidates for — which needs a larger and
harder corpus to demonstrate than is reasonable to run in CI.
Requires PostgreSQL with pgvector + migration 003. Run with:
pytest tests/eval/test_retrieval_eval.py -v -m retrieval
"""
from __future__ import annotations
import asyncio
import os
import uuid
from typing import Dict, List
import pytest
from tests.eval.golden_set import FULL_CORPUS, QUERIES, Query
from tests.eval.retrieval_metrics import aggregate, format_report
pytestmark = [pytest.mark.retrieval, pytest.mark.slow]
# Local, offline, deterministic. 384 dims, zero-padded to the 768-dim column —
# zero padding leaves cosine similarity mathematically unchanged.
EVAL_EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
TARGET_DIM = 768
TOP_K = 5
# Quality floors. Set below current measured values so ordinary noise does not
# fail the build, but high enough that a broken retrieval arm does. Raise them
# as the pipeline improves — that is the point of having them in version
# control.
THRESHOLDS: Dict[str, float] = {
f"hit_rate@{TOP_K}": 0.90,
"mrr": 0.75,
f"recall@{TOP_K}": 0.85,
f"ndcg@{TOP_K}": 0.75,
}
# Per-category floors. These catch one query class degrading while the blended
# average stays healthy — not the same thing as proving an arm is alive.
ARM_HIT_RATE_FLOOR = {
"lexical": 0.90,
"semantic": 0.75,
"mixed": 0.90,
}
# ── Fixtures ───────────────────────────────────────────────────────────────────
def _require_postgres() -> str:
url = os.getenv("DATABASE_URL", "")
if "postgres" not in url:
pytest.skip("Retrieval eval requires PostgreSQL with pgvector")
return url
@pytest.fixture(scope="module")
def embedder():
st = pytest.importorskip("sentence_transformers")
return st.SentenceTransformer(EVAL_EMBED_MODEL)
def _embed(embedder, texts: List[str]) -> List[List[float]]:
"""Embed and zero-pad to the column width."""
vectors = embedder.encode(texts, normalize_embeddings=True)
padded = []
for v in vectors:
v = list(map(float, v))
padded.append(v + [0.0] * (TARGET_DIM - len(v)))
return padded
@pytest.fixture(scope="module")
def seeded_corpus(embedder):
"""Load the golden corpus into PostgreSQL, and clean up afterwards."""
_require_postgres()
from app.db.session import SessionLocal
from app.models.document import Document, DocumentEmbedding, DocumentStatus
from app.models.user import User
db = SessionLocal()
user_id = f"eval_user_{uuid.uuid4().hex[:8]}"
db.add(User(clerk_user_id=user_id, is_active=True))
db.commit()
vectors = _embed(embedder, [c.text for c in FULL_CORPUS])
# One Document per source file, chunks attached to the right one.
by_document: Dict[str, Document] = {}
chunk_lookup: Dict[str, str] = {} # embedding row id -> golden chunk id
for chunk, vector in zip(FULL_CORPUS, vectors):
if chunk.document not in by_document:
doc = Document(
user_id=user_id,
title=chunk.document,
file_name=chunk.document,
file_size=1024,
file_type="application/pdf",
file_url=f"storage://{chunk.document}",
status=DocumentStatus.COMPLETED,
)
db.add(doc)
db.commit()
db.refresh(doc)
by_document[chunk.document] = doc
row = DocumentEmbedding(
document_id=by_document[chunk.document].id,
chunk_index=len(chunk_lookup),
chunk_text=chunk.text,
embedding=vector,
page_numbers=[chunk.page],
section_title=chunk.id,
)
db.add(row)
db.commit()
db.refresh(row)
chunk_lookup[str(row.id)] = chunk.id
yield {"user_id": user_id, "db": db, "lookup": chunk_lookup}
# Documents must be gone and committed before the user row, or the
# documents_user_id_fkey constraint rejects the delete.
for doc in by_document.values():
db.delete(doc)
db.commit()
db.query(User).filter(User.clerk_user_id == user_id).delete()
db.commit()
db.close()
def _retrieve(seeded, embedder, query: str, top_k: int = TOP_K) -> List[str]:
"""Run the real pipeline and map results back to golden chunk ids."""
from app.services.hybrid_search import hybrid_search
from app.services.reranker import rerank
query_vector = _embed(embedder, [query])[0]
candidates = asyncio.run(
hybrid_search(
query_text=query,
query_embedding=query_vector,
db=seeded["db"],
user_id=seeded["user_id"],
top_k=20,
)
)
if candidates:
candidates = rerank(query=query, chunks=candidates, top_k=top_k)
return [
seeded["lookup"][c["id"]] for c in candidates if c["id"] in seeded["lookup"]
]
def _evaluate(seeded, embedder, queries: List[Query]) -> List[dict]:
return [
{
"question": q.question,
"kind": q.kind,
"retrieved": _retrieve(seeded, embedder, q.question),
"relevant": set(q.relevant),
}
for q in queries
]
# ── The eval ───────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def evaluation(seeded_corpus, embedder):
return _evaluate(seeded_corpus, embedder, QUERIES)
def test_overall_retrieval_quality(evaluation):
"""Aggregate metrics must clear the configured floors."""
metrics = aggregate(evaluation, k=TOP_K)
print(format_report(metrics, THRESHOLDS))
failures = [
f"{name}={metrics[name]:.3f} < {floor:.3f}"
for name, floor in THRESHOLDS.items()
if metrics[name] < floor
]
assert not failures, "Retrieval quality regressed: " + "; ".join(failures)
@pytest.mark.parametrize("kind", sorted(ARM_HIT_RATE_FLOOR))
def test_quality_by_query_category(evaluation, kind):
"""
End-to-end quality per query category, scored separately.
Note what this does and does not do. It catches a category degrading —
e.g. paraphrase queries collapsing after an embedding model change — which
a single blended average would mask. It does NOT prove the lexical arm is
working: see the module docstring, the reranker compensates for a dead arm
well enough that these stay at 1.000. The direct guards below are what
cover that.
"""
subset = [r for r in evaluation if r["kind"] == kind]
assert subset, f"No {kind} queries in the golden set"
metrics = aggregate(subset, k=TOP_K)
observed = metrics[f"hit_rate@{TOP_K}"]
floor = ARM_HIT_RATE_FLOOR[kind]
missed = [
r["question"]
for r in subset
if not (set(r["retrieved"][:TOP_K]) & r["relevant"])
]
assert observed >= floor, (
f"{kind} hit_rate@{TOP_K}={observed:.3f} < {floor:.3f}. " f"Missed: {missed}"
)
def test_lexical_arm_returns_rows(seeded_corpus):
"""
Direct guard on the exact defect that shipped: the lexical query returning
an empty list for a query whose terms are verbatim in the corpus.
"""
from app.services.hybrid_search import fulltext_search
hits = fulltext_search(
query_text="30 CFR 75.323 methane return air split",
db=seeded_corpus["db"],
user_id=seeded_corpus["user_id"],
top_k=10,
)
assert hits, (
"Lexical search returned nothing for terms present verbatim in the "
"corpus — the inverted index or migration 003 is missing."
)
assert seeded_corpus["lookup"][hits[0]["id"]] == "ventilation-methane-limits"
def test_citation_lookalikes_are_distinguished(seeded_corpus):
"""
30 CFR 75.323 and 30 CFR 75.400 embed almost identically. Only the lexical
arm can tell them apart, so this is the sharpest test that it contributes.
"""
from app.services.hybrid_search import fulltext_search
for citation, expected in [
("30 CFR 75.323", "ventilation-methane-limits"),
("30 CFR 75.400", "ventilation-accumulation"),
]:
hits = fulltext_search(
query_text=citation,
db=seeded_corpus["db"],
user_id=seeded_corpus["user_id"],
top_k=5,
)
assert hits, f"No lexical hit for {citation}"
top = seeded_corpus["lookup"][hits[0]["id"]]
assert top == expected, f"{citation} ranked {top} first, expected {expected}"
|