Files
vectorize-io__hindsight/hindsight-api-slim/tests/test_fusion_cap.py
T
Nicolò Boschi 70d98c7a27 fix(recall): gate VectorChord BM25 + add per-source candidate cap (#1707) (#1947)
VectorChord BM25 ranks *every* document via the `<&>` operator (which returns
the negative BM25 score), so a bare `ORDER BY ... LIMIT` padded each recall with
zero-score, non-matching rows. Unlike native tsvector — which has a boolean `@@`
match gate — the vchord arm had no gate, flooding RRF/reranking with weak
candidates and broadening answers (the #1707 regression).

- Gate the vchord BM25 arm on `-(search_vector <&> ...) > bm25_min_score`
  (default 0), the direct analogue of native's `@@` gate. Verified on a real
  VectorChord container: a query that returned 10 rows (2 real matches + 8 rows
  scoring exactly 0.0) now returns only the 2 genuine matches. Oracle's CONTAINS
  gate now shares the same configurable floor (behavior unchanged at 0).
- Add an optional per-source candidate cap applied to each arm (semantic, BM25,
  graph, temporal) before RRF, so one over-expanding backend cannot fill the
  reranker's global budget alone (HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE,
  default 0 = disabled). Verified live: cap=1 trims semantic 10->1, bm25 4->1.

New config: HINDSIGHT_API_BM25_MIN_SCORE, HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE.
2026-06-03 15:05:54 +02:00

43 lines
1.2 KiB
Python

"""Tests for per-source candidate capping before RRF fusion."""
from hindsight_api.engine.search.fusion import cap_per_source
from hindsight_api.engine.search.types import RetrievalResult
def _results(n: int) -> list[RetrievalResult]:
return [RetrievalResult(id=str(i), text=f"r{i}", fact_type="world") for i in range(n)]
def test_cap_truncates_to_top_n():
results = _results(10)
capped = cap_per_source(results, 3)
assert [r.id for r in capped] == ["0", "1", "2"]
def test_cap_preserves_order():
"""Capping must keep the caller's best-first ordering (it only slices)."""
results = _results(5)
capped = cap_per_source(results, 2)
assert capped == results[:2]
def test_cap_zero_disables():
results = _results(5)
# 0 means "unlimited" — return the list untouched (same object, no copy).
assert cap_per_source(results, 0) is results
def test_cap_negative_disables():
results = _results(5)
assert cap_per_source(results, -1) is results
def test_cap_at_or_above_length_is_noop():
results = _results(4)
assert cap_per_source(results, 4) is results
assert cap_per_source(results, 10) is results
def test_cap_empty_list():
assert cap_per_source([], 5) == []