Order cross-database fusion by retrieval score

Rank interleaving guarantees every database slots regardless of content;
on domain-split collections it allocates no better than chance and costs
4.7pp recall@5 at four collections against score ordering (7.1pp at
eight). Hybrid scores are each database's own vector/FTS rank agreement,
which carries across databases; equal scores resolve by within-database
rank, and only a tie on both falls to configured order, leaving
permutation sensitivity at 0.02-0.26pp. Fused results carry the
candidate's own retrieval score, so the context-expansion re-sort
preserves fused order.
This commit is contained in:
Yiorgis Gozadinos 2026-09-01 08:34:37 +03:00
parent d06277408b
commit 82fe91bb76
No known key found for this signature in database
4 changed files with 49 additions and 39 deletions

View file

@ -9,8 +9,9 @@
### Fixed ### Fixed
- Cross-database fusion without a reranker breaks rank ties by retrieval - Cross-database fusion without a reranker orders the union by retrieval
score instead of database declaration order. score, with within-database rank breaking ties, instead of round-robin by
database declaration order. Fused results carry the retrieval score.
## [0.80.0] - 2026-08-31 ## [0.80.0] - 2026-08-31

View file

@ -227,7 +227,7 @@ results = await client.search("query") # every database
results = await client.search("query", sources=["papers"]) # one of them results = await client.search("query", sources=["papers"]) # one of them
``` ```
Candidates are combined into one ranked list with the configured reranker, or with reciprocal rank fusion when reranking is disabled. `SearchResult.source`, `Citation.source`, and `Document.source` contain the configured database name. The name is retained when a client covers only one named database. Databases configured through `lancedb.uri` are unnamed, so their `source` is `None`. Candidates are combined into one ranked list with the configured reranker, or by retrieval score when reranking is disabled, with within-database rank breaking score ties. `SearchResult.source`, `Citation.source`, and `Document.source` contain the configured database name. The name is retained when a client covers only one named database. Databases configured through `lancedb.uri` are unnamed, so their `source` is `None`.
The CLI labels results and citations only when the operation spans multiple databases. A command already narrowed with `--db-name` does not repeat the name on every result. The CLI labels results and citations only when the operation spans multiple databases. A command already narrowed with `--db-name` does not repeat the name on every result.

View file

@ -154,16 +154,16 @@ async def _fuse(
A configured reranker scores the union directly, which is what makes ranking A configured reranker scores the union directly, which is what makes ranking
across databases tractable: it compares query against document and does not across databases tractable: it compares query against document and does not
care where a candidate came from. Without one, reciprocal rank fusion over the care where a candidate came from. Without one, the union is ordered by the
per-database rankings, since scores from separate indexes are not comparable. raw retrieval score. Scores from separate indexes are not calibrated, but a
The corpora are disjoint, so every database's rank-r candidate carries the hybrid score is each database's own rank agreement (lancedb fuses vector and
same RRF score; those ties break on the raw retrieval score, which is not FTS with RRF inside the database), which carries across databases; rank
calibrated across indexes but only ever orders candidates within one rank interleaving instead guarantees every database slots regardless of content,
and never lifts a candidate above another rank. A hybrid score is itself which on domain-split collections allocates no better than chance. Equal
rank-derived (each database fuses its own vector and FTS rankings with scores resolve by within-database rank the candidate nothing in its own
lancedb's RRFReranker), so there the tiebreak compares rank agreement, not database beat wins and only a tie on both falls to configured order. The
relevance magnitude, and databases agreeing exactly still tie, resolving returned score is the candidate's own, so downstream re-sorts (context
deterministically to configured order. expansion) preserve this order.
""" """
owned = [ owned = [
(client, chunk, score) (client, chunk, score)
@ -203,8 +203,8 @@ async def _fuse(
for client, candidates in zip(clients, per_source, strict=True): for client, candidates in zip(clients, per_source, strict=True):
for rank, (chunk, score) in enumerate(candidates): for rank, (chunk, score) in enumerate(candidates):
scored.append((1.0 / (_RRF_K + rank + 1), score, client, chunk)) scored.append((1.0 / (_RRF_K + rank + 1), score, client, chunk))
scored.sort(key=lambda item: (item[0], item[1]), reverse=True) scored.sort(key=lambda item: (item[1], item[0]), reverse=True)
return [(client, chunk, fused) for fused, _, client, chunk in scored[:limit]] return [(client, chunk, score) for _, score, client, chunk in scored[:limit]]
# Reciprocal rank fusion's smoothing constant, the value the literature uses. # Reciprocal rank fusion's smoothing constant, the value the literature uses.

View file

@ -473,9 +473,10 @@ class TestNarrowingToOneDatabase:
assert results == [] assert results == []
class TestReciprocalRankFusion: class TestFusionWithoutAReranker:
"""Without a reranker, scores from separate indexes are not comparable, so """Without a reranker, the union is ordered by retrieval score; score ties
fusion ranks by position. These pin what that produces.""" resolve by within-database rank, and only a tie on both falls to configured
order. These pin what that produces."""
@staticmethod @staticmethod
def _ranked(source: str, count: int, top: float) -> list[tuple[Chunk, float]]: def _ranked(source: str, count: int, top: float) -> list[tuple[Chunk, float]]:
@ -502,38 +503,46 @@ class TestReciprocalRankFusion:
return [(owner.source, chunk.id, score) for owner, chunk, score in fused] return [(owner.source, chunk.id, score) for owner, chunk, score in fused]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_databases_interleave_by_rank(self, tmp_path): async def test_the_score_orders_the_union(self, tmp_path):
"""Each contributes its rank-1 before either contributes its rank-2.""" """A stronger database takes consecutive slots; breadth is not
guaranteed."""
fused = await self._fuse_over(tmp_path, self._lopsided(3), 10) fused = await self._fuse_over(tmp_path, self._lopsided(3), 10)
assert [(source, cid) for source, cid, _ in fused] == [ assert [(source, cid) for source, cid, _ in fused] == [
("alpha", "a0"), ("alpha", "a0"),
("beta", "b0"),
("alpha", "a1"), ("alpha", "a1"),
("beta", "b1"),
("alpha", "a2"), ("alpha", "a2"),
("beta", "b0"),
("beta", "b1"),
("beta", "b2"), ("beta", "b2"),
] ]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_score_is_the_reciprocal_of_the_rank(self, tmp_path): async def test_the_score_is_the_retrieval_score(self, tmp_path):
"""The fused score is the candidate's own, so re-sorting downstream
(context expansion) preserves the fused order."""
fused = await self._fuse_over(tmp_path, self._lopsided(2), 10) fused = await self._fuse_over(tmp_path, self._lopsided(2), 10)
assert [score for _, _, score in fused] == [ assert [score for _, _, score in fused] == [0.9, 0.89, 0.2, 0.19]
1 / 61,
1 / 61,
1 / 62,
1 / 62,
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ranks_tie_and_the_retrieval_score_breaks_them(self, tmp_path): async def test_score_ties_break_by_rank_within_the_database(self, tmp_path):
"""Every rank ties across databases, so the tiebreak decides all of it, """Equal scores can sit at different ranks: rank depends on what the
and it must be relevance, not the order databases were configured in.""" rest of a database scored. The candidate nothing in its own database
per_source = [self._ranked("a", 2, 0.2), self._ranked("b", 2, 0.9)] beat wins the tie."""
per_source = [
[
(Chunk(id="a0", content="a 0"), 0.9),
(Chunk(id="a1", content="a 1"), 0.5),
],
[
(Chunk(id="b0", content="b 0"), 0.5),
(Chunk(id="b1", content="b 1"), 0.3),
],
]
fused = await self._fuse_over(tmp_path, per_source, 10) fused = await self._fuse_over(tmp_path, per_source, 10)
assert [source for source, _, _ in fused] == ["beta", "alpha", "beta", "alpha"] assert [cid for _, cid, _ in fused] == ["a0", "b0", "a1", "b1"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_configured_order_does_not_matter(self, tmp_path): async def test_the_configured_order_does_not_matter(self, tmp_path):
@ -561,12 +570,12 @@ class TestReciprocalRankFusion:
assert [source for source, _, _ in fused] == ["alpha", "beta", "alpha", "beta"] assert [source for source, _, _ in fused] == ["alpha", "beta", "alpha", "beta"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_retrieval_score_never_overrides_the_rank(self, tmp_path): async def test_rank_never_overrides_the_score(self, tmp_path):
"""Raw scores are not calibrated across indexes, so a database with """A database's rank-2 with a higher score precedes another's rank-0:
inflated scores still contributes one candidate per rank.""" allocation is content-driven, not round-robin."""
fused = await self._fuse_over(tmp_path, self._lopsided(2), 10) fused = await self._fuse_over(tmp_path, self._lopsided(2), 10)
assert [cid for _, cid, _ in fused] == ["a0", "b0", "a1", "b1"] assert [cid for _, cid, _ in fused] == ["a0", "a1", "b0", "b1"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_limit_cuts_the_fused_list(self, tmp_path): async def test_the_limit_cuts_the_fused_list(self, tmp_path):
@ -575,8 +584,8 @@ class TestReciprocalRankFusion:
assert [(source, cid) for source, cid, _ in fused] == [ assert [(source, cid) for source, cid, _ in fused] == [
("alpha", "a0"), ("alpha", "a0"),
("beta", "b0"),
("alpha", "a1"), ("alpha", "a1"),
("alpha", "a2"),
] ]