mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
70d98c7a27
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.
43 lines
1.2 KiB
Python
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) == []
|