fix(search): clarify score thresholds and warn above hybrid bound (#642)

This commit is contained in:
jinliyl 2026-10-19 17:54:56 +08:00 • committed by GitHub
parent 6f71a810c1
commit 1a8ff12df1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 108 additions and 1 deletions

View file

@ -208,6 +208,26 @@ fused_score = vector_weight / (60 + vector_rank)
The default `vector_weight=0.7` gives semantic recall more weight when embeddings are enabled, while keyword search can
still promote chunks with exact term matches.
### Score thresholds
Keep `min_score=0` (the default) for normal hybrid search and use `limit` to select the top results.
Positive `min_score` values filter the returned score before truncation, using the scale of the branches that
actually returned hits for that query:
| Branches with hits | Score compared with `min_score` |
| --- | --- |
| Vector only | Native vector-backend score; its range depends on the backend |
| BM25 only | Native BM25 score, without a fixed upper bound |
| Both | Weighted RRF rank score, at most `1/61` (approximately `0.01639`) |
RRF scores express rank contributions, not normalized relevance or probabilities. The maximum assumes the same chunk
ranks first in both lists; a vector-only contribution with the default weight is at most `0.7/61`.
Changing `vector_weight` changes individual contributions, while the two weights still sum to 1.
The score scale can change between queries when one branch has no hits, so a single positive threshold is not
comparable across all modes. For example, `min_score=0.05` removes every hybrid result even if its native vector or
BM25 score is high. Search logs a warning when both branches have hits and the threshold exceeds `1/61`.
The threshold is still applied: the warning does not lower it or restore filtered results.
## How BM25 Works
`keyword_search()` calls `keyword_index.retrieve(query, limit)`. Each chunk is a document in the BM25 index:

View file

@ -193,6 +193,23 @@ fused_score = vector_weight / (60 + vector_rank)
默认 `vector_weight=0.7`,所以启用 embedding 后语义召回权重更高;关键词仍能把精确词命中的 chunk 拉上来。
### 分数阈值
常规混合搜索建议保持 `min_score=0`(默认值),用 `limit` 选取排名靠前的结果。
正数 `min_score` 会在截断之前过滤返回分数,其尺度取决于本次查询实际有命中的分支:
| 有命中的分支 | 与 `min_score` 比较的分数 |
| --- | --- |
| 只有向量 | 向量后端的原始分数,范围取决于后端 |
| 只有 BM25 | BM25 原始分数,没有固定上限 |
| 两边都有 | 加权 RRF 排名分数,最大为 `1/61`(约 `0.01639`) |
RRF 分数表示排名贡献,不是归一化相关性或概率。最大值要求同一 chunk 在两个列表中都排第一;默认权重下,
仅来自向量分支的贡献最大为 `0.7/61`。调整 `vector_weight` 会改变各分支的贡献,但两个权重之和仍为 1。
当某个分支没有命中时,分数尺度会随查询切换,因此同一个正数阈值无法在所有模式下进行等价比较。
例如,`min_score=0.05` 会过滤掉所有混合结果,即使它们的原始向量或 BM25 分数很高。
当两边都有命中且阈值超过 `1/61` 时,搜索会记录警告。阈值仍会照常应用,不会因警告而降低或恢复已过滤结果。
## BM25 怎么工作
`keyword_search()` 调用 `keyword_index.retrieve(query, limit)`。BM25 索引里每个 chunk 是一篇文档:

View file

@ -461,7 +461,11 @@ jobs:
default: 5
min_score:
type: number
description: "min fused score"
description: >-
Minimum returned score; 0 disables filtering (recommended).
Uses native vector or BM25 scores when only one branch has hits,
and rank-based RRF scores (at most 1/61, about 0.01639) when both have hits.
The scale can change per query; this is not a normalized relevance threshold.
default: 0.0
tags:
type: array

View file

@ -339,6 +339,14 @@ class SearchStep(BaseStep):
fused = self._rrf_merge(vector_results, keyword_results, vector_weight)
if min_score > 0.0:
max_rrf_score = 1.0 / (_RRF_K + 1)
if hybrid and min_score > max_rrf_score:
self.logger.warning(
f"[{self.name}] min_score={min_score:g} exceeds the hybrid RRF maximum "
f"{max_rrf_score:.8g}; all hybrid results will be filtered out. "
"RRF scores are rank-based, not vector similarities or BM25 scores. "
"Keep min_score=0 to disable filtering; single-branch queries use native scores.",
)
fused = [c for c in fused if c.score >= min_score]
dedup: dict | None = None

View file

@ -0,0 +1,58 @@
"""Score-scale diagnostics preserve existing search threshold behavior."""
import asyncio
from unittest.mock import AsyncMock, Mock
import pytest
from reme.components.file_store import BaseFileStore
from reme.components.runtime_context import RuntimeContext
from reme.schema import FileChunk
from reme.steps.index import SearchStep
@pytest.mark.parametrize(
"mode,weight,min_score,expected_ids,warn",
[
("hybrid", 0.7, 0.05, [], True),
("hybrid", 0.2, 0.05, [], True),
("hybrid", 0.7, 1 / 61, ["match"], False),
("hybrid", 0.7, 0.01, ["match"], False),
("hybrid", 0.7, 0, ["match"], False),
("vector", 0.7, 0.05, ["match"], False),
("keyword", 0.7, 1, ["match"], False),
("vector", 1, 0.05, ["match"], False),
("keyword", 0, 1, ["match"], False),
("empty", 0.7, 0.05, [], False),
],
)
def test_search_score_threshold_warning(mode, weight, min_score, expected_ids, warn):
"""Warn only above the hybrid bound, preserving native scores and exact-bound hits."""
vector = FileChunk(
id="match",
path="daily/match.md",
text="matching memory",
start_line=1,
end_line=1,
scores={"vector": 0.9, "score": 0.9},
)
keyword = vector.model_copy(update={"scores": {"keyword": 8.0, "score": 8.0}})
store = Mock(spec=BaseFileStore)
store.vector_search = AsyncMock(return_value=[vector] if mode in {"hybrid", "vector"} else [])
store.keyword_search = AsyncMock(return_value=[keyword] if mode in {"hybrid", "keyword"} else [])
step = SearchStep(file_store=store, vector_weight=weight, expand_links=False)
step.logger = Mock()
response = asyncio.run(step(RuntimeContext(query="memory", min_score=min_score)))
assert response.success
assert [result["id"] for result in response.metadata["results"]] == expected_ids
assert step.logger.warning.call_count == int(warn)
if warn:
message = step.logger.warning.call_args.args[0]
assert "min_score=0.05 exceeds the hybrid RRF maximum" in message
assert "all hybrid results will be filtered out" in message
assert "min_score=0" in message
elif expected_ids:
expected_score = {"hybrid": 1 / 61, "vector": 0.9, "keyword": 8.0}[mode]
assert response.metadata["results"][0]["scores"]["score"] == pytest.approx(expected_score)