mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-22 05:26:20 +00:00
fix(search): clarify score thresholds and warn above hybrid bound (#642)
This commit is contained in:
parent
6f71a810c1
commit
1a8ff12df1
5 changed files with 108 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 是一篇文档:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
58
tests/unit/test_search_score_threshold.py
Normal file
58
tests/unit/test_search_score_threshold.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue