BM25 和向量检索各有盲区,单独用哪一种都会漏掉结果。
BM25 是基于词频的统计模型,对关键词精确匹配极其敏感。如果用户查 「少女祈祷中...」,
BM25 无法找到语义相似但措辞不同的 「少女正在祈祷」。向量检索(kNN)擅长捕捉语义相似性,
但对于专有名词、角色名等精确词汇,向量空间里的距离并不能反映字面匹配的重要性——
「エルザ」 和 「爱莉莎」 在向量空间里可能很近,但如果业务要求精确角色匹配,
你需要 BM25 兜底。
在我们的 AI 翻译平台中,翻译记忆库(Translation Memory)检索需要同时:
最初的 V1 实现使用 script_score 手动加权融合:
# V1:人工调权重,脆弱且难以维护
score = bm25_score * 0.5 + cosine_similarity * 0.5
问题很快暴露:BM25 分数范围是 [0, +∞),余弦相似度是 [0, 1],
量纲不同导致权重系数失去物理意义——调参靠感觉,上线靠运气。
RRF 通过统一"排名位置"替代"原始分数",彻底解决量纲不兼容问题。
RRF(Reciprocal Rank Fusion)由 Cormack et al. 在 2009 年提出(SIGIR),
核心公式极其简洁:
RRF(d) = Σ 1 / (k + rank_r(d))
r∈R
其中:
R = 参与融合的检索器集合(如 BM25、kNN)rank_r(d) = 文档 d 在检索器 r 中的排名(从 1 开始)k = 平滑常数,默认 60(防止头部文档权重过于集中)关键洞察: RRF 只看排名,不看分数。无论 BM25 给出 23.7 分还是 0.3 分,
只要它排第 2,RRF 就用 1 / (60 + 2) ≈ 0.0161——分数绝对值对融合结果没有任何影响。
为什么 k=60 是个好默认值?
| k 值 | 效果 |
|---|---|
| k=1 | 极度强调排名第 1,Top-1 文档权重碾压其他 |
| k=60 | 适度平滑,前 20 名文档都有合理权重 |
| k=∞ | 退化为所有文档平均分,排名失效 |
Elasticsearch 官方在 8.9 版本将 rank_constant=60 设为默认值,
大量实验表明它在多数检索场景下都是最优或接近最优的。
理解 RRF 最好的方式是亲手实现它——20 行 Python 代码搞定核心逻辑。
"""
RRF (Reciprocal Rank Fusion) 纯 Python 实现
零外部依赖,可直接运行
"""
from typing import List, Dict
def rrf_fusion(
ranked_lists: List[List[str]],
k: int = 60
) -> List[tuple[str, float]]:
"""
ranked_lists: 多个排序列表,每个列表是文档ID的有序序列(第0位=排名第1)
k: 平滑常数,默认60
返回: [(doc_id, rrf_score), ...] 按分数降序
"""
scores: Dict[str, float] = {}
for ranked in ranked_lists:
for rank, doc_id in enumerate(ranked, start=1): # rank从1开始
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
# ──────────────────────────────────────────────
# Demo:模拟翻译记忆库混合检索
# ──────────────────────────────────────────────
def bm25_search(query: str, corpus: List[Dict]) -> List[str]:
"""简化版 BM25:按关键词命中数量排序"""
query_terms = set(query.lower().split())
scored = []
for doc in corpus:
text = (doc["source"] + " " + doc["target"]).lower()
score = sum(1 for t in query_terms if t in text)
if score > 0:
scored.append((doc["id"], score))
scored.sort(key=lambda x: x[1], reverse=True)
return [doc_id for doc_id, _ in scored]
def vector_search(query: str, corpus: List[Dict]) -> List[str]:
"""简化版向量检索:用字符级 Jaccard 相似度模拟余弦相似度"""
def jaccard(a: str, b: str) -> float:
sa, sb = set(a.lower()), set(b.lower())
return len(sa & sb) / len(sa | sb) if sa | sb else 0.0
scored = [
(doc["id"], jaccard(query, doc["source"]))
for doc in corpus
]
scored.sort(key=lambda x: x[1], reverse=True)
return [doc_id for doc_id, score in scored if score > 0]
def main():
corpus = [
{"id": "tm_001", "source": "少女祈祷中", "target": "少女正在祈祷"},
{"id": "tm_002", "source": "少女の祈り", "target": "少女的祈祷"},
{"id": "tm_003", "source": "エルザは祈りを捧げた", "target": "爱莉莎献上了祈祷"},
{"id": "tm_004", "source": "彼女は泣いていた", "target": "她在哭泣"},
{"id": "tm_005", "source": "祈りの声が響く", "target": "祈祷之声回荡"},
]
query = "少女 祈り"
bm25_results = bm25_search(query, corpus)
vector_results = vector_search(query, corpus)
rrf_results = rrf_fusion([bm25_results, vector_results], k=60)
print("=" * 50)
print(f"查询: {query}")
print("=" * 50)
print("\n[BM25 结果]")
for i, doc_id in enumerate(bm25_results[:5], 1):
doc = next(d for d in corpus if d["id"] == doc_id)
print(f" #{i} {doc_id}: {doc['source']} → {doc['target']}")
print("\n[向量检索结果]")
for i, doc_id in enumerate(vector_results[:5], 1):
doc = next(d for d in corpus if d["id"] == doc_id)
print(f" #{i} {doc_id}: {doc['source']} → {doc['target']}")
print("\n[RRF 融合结果]")
for i, (doc_id, score) in enumerate(rrf_results[:5], 1):
doc = next(d for d in corpus if d["id"] == doc_id)
print(f" #{i} {doc_id} (score={score:.4f}): {doc['source']} → {doc['target']}")
print("\n[RRF 分数计算过程]")
print(f"{'文档ID':<12} {'BM25排名':>8} {'向量排名':>8} {'RRF分数':>10}")
print("-" * 42)
for doc_id, score in rrf_results:
bm25_rank = bm25_results.index(doc_id) + 1 if doc_id in bm25_results else "N/A"
vector_rank = vector_results.index(doc_id) + 1 if doc_id in vector_results else "N/A"
br = 60 + bm25_rank if isinstance(bm25_rank, int) else "∞"
vr = 60 + vector_rank if isinstance(vector_rank, int) else "∞"
print(f"{doc_id:<12} {str(bm25_rank):>8} {str(vector_rank):>8} {score:>10.4f} (1/{br} + 1/{vr})")
if __name__ == "__main__":
main()
运行输出示例:
[RRF 分数计算过程]
文档ID BM25排名 向量排名 RRF分数
------------------------------------------
tm_002 1 1 0.0328 (1/61 + 1/61)
tm_001 2 2 0.0323 (1/62 + 1/62)
tm_005 4 3 0.0315 (1/64 + 1/63)
tm_002("少女の祈り")在 BM25 和向量检索里都排第一:BM25 方面,它是语料中唯一同时
包含"少女"和"祈り"两个词的条目;向量方面,字符集交集最大。两路都排第一,RRF 分数自然
最高。
ES 8.9+ 原生支持 rank.rrf,三个字段搞定混合检索,告别手动调权重。
Elasticsearch >= 8.9.0(免费 Basic License 可用)
PUT /translation_memory
{
"mappings": {
"properties": {
"source_text": { "type": "text", "analyzer": "standard" },
"target_text": { "type": "text", "analyzer": "standard" },
"role_name": { "type": "keyword" },
"vector": {
"type": "dense_vector",
"dims": 1536,
"index": true,
"similarity": "cosine"
}
}
}
}
这是本项目 SearchHybridCorpusPairsV2 实际使用的请求结构:
POST /translation_memory/_search
{
"query": {
"bool": {
"must": [{
"multi_match": {
"query": "少女祈祷中",
"fields": ["source_text", "target_text"],
"type": "best_fields",
"fuzziness": "AUTO"
}
}],
"filter": [
{ "terms": { "corpus_library_id": [1, 2, 3] } },
{ "term": { "role_name.keyword": "エルザ" } }
]
}
},
"knn": {
"field": "vector",
"query_vector": [0.023, -0.015, "...1536维"],
"k": 15,
"num_candidates": 30,
"filter": {
"bool": {
"filter": [
{ "terms": { "corpus_library_id": [1, 2, 3] } },
{ "term": { "role_name.keyword": "エルザ" } }
]
}
}
},
"rank": {
"rrf": {
"window_size": 30,
"rank_constant": 60
}
},
"size": 5
}
| 字段 | 作用 |
|---|---|
query |
BM25 关键词匹配,支持 fuzziness 容错 |
knn |
语义向量检索,k 决定候选池大小 |
rank.rrf |
对两路结果按排名融合,输出最终 Top-N |
本项目参数设计:
nResults = 5 // 最终返回条数
knnK = nResults * 3 = 15 // kNN 候选池
window_size = knnK * 2 = 30 // RRF 融合窗口
rank_constant = 60 // ES 默认值
截至 2026 年,主流向量数据库均已原生支持 RRF,技术选型的核心维度已从"支不支持"
转向"支持得好不好"。以下数据来自各平台 2025-2026 正式发布的版本说明。
| 平台 | RRF 原生支持版本 | 支持年份 | 特色 | 适用规模 |
|---|---|---|---|---|
| Elasticsearch | 8.9 | 2023 | 最成熟、生态最完整 | 大规模(亿级) |
| OpenSearch | 2.19 | 2025-02 | AWS 托管、与 ES 架构最近 | 大规模 |
| Qdrant | v1.16 基础 / v1.17 加权 | 2025 | 独创加权 RRF,性能极强 | 中大规模 |
| Milvus | 2.6 完整集成 / 3.0-Beta | 2025-08 / 2026-05 | BM25 速度声称 4× ES | 超大规模 |
| Weaviate | v1.24(默认 RSF,非 RRF) | 2024 | v1.30 混合搜索大幅提速 | 中小规模 |
| Vespa | 原生,reciprocal_rank() |
长期支持 | 可定制化最强,RAG 分层排序 | 超大规模 |
| Redis | 8.4 FT.HYBRID |
2025-11 | 低延迟,统一命令接口 | 中小规模 |
| PostgreSQL | ❌ 无原生,手写 SQL | — | 零额外基础设施 | 小规模 |
Qdrant 在标准 RRF 基础上引入了 weight 参数,允许对不同检索器赋予不等权重,
是目前唯一原生支持加权 RRF 的主流方案。v1.16 支持自定义 k 参数,v1.17(2025年)
发布 Weighted RRF。
# Qdrant v1.17 加权 RRF 示例
from qdrant_client import QdrantClient
from qdrant_client.models import Prefetch, FusionQuery, Fusion
client = QdrantClient("localhost", port=6333)
results = client.query_points(
collection_name="translation_memory",
prefetch=[
# 向量检索分支(语义)
Prefetch(query=query_vector, using="dense", limit=20),
# 稀疏检索分支(BM25/关键词)
Prefetch(query=sparse_vector, using="sparse", limit=20),
],
# RRF 融合,v1.17 支持 weight 参数区分两路权重
query=FusionQuery(
fusion=Fusion.RRF,
# weight=[1.0, 2.0] # 向量:BM25 = 1:2(v1.17新增)
),
limit=5,
)
适合以下情形:需要加权 RRF、追求极低延迟、数据规模在千万到亿级、不想维护 ES 集群。
OpenSearch 2.19 带来原生 RRF,3.0 引入 Z-score 归一化保护弱信号。
OpenSearch 2.19 发布于 2025-02-11,3.0 发布于 2025-05-06,2.19.x 补丁维护至 2026-03。
POST /translation_memory/_search
{
"query": {
"hybrid": {
"queries": [
{ "match": { "source_text": { "query": "少女祈祷中" } } },
{
"neural": {
"vector": {
"query_text": "少女祈祷中",
"model_id": "your-embedding-model-id",
"k": 15
}
}
}
]
}
},
"search_pipeline": {
"phase_results_processors": [{
"score-ranker-processor": {
"combination": {
"technique": "rrf",
"parameters": { "rank_constant": 60 }
}
}
}]
}
}
OpenSearch 3.0 新增 Z-score 归一化:当两路检索分数分布差异极大时,Z-score 会自动将
分数拉到同一量纲再融合,避免一路检索"淹没"另一路。
已在 AWS 生态、或团队有 ES 背景但希望避免 Elastic 商业授权限制时,OpenSearch 是
最自然的选择。
Milvus 2.6 完整集成 RRF,3.0-Beta(2026-05)引入多向量加速,官方声称 BM25 全文
检索速度是 ES 的 4 倍(基准测试场景下)。
from pymilvus import MilvusClient, AnnSearchRequest, RRFRanker
client = MilvusClient("http://localhost:19530")
bm25_req = AnnSearchRequest(
data=["少女の祈り"],
anns_field="sparse_vector",
param={"drop_ratio_search": 0.2},
limit=15,
)
dense_req = AnnSearchRequest(
data=[query_vector],
anns_field="dense_vector",
param={"metric_type": "COSINE", "params": {"ef": 100}},
limit=15,
)
results = client.hybrid_search(
collection_name="translation_memory",
reqs=[bm25_req, dense_req],
ranker=RRFRanker(k=60),
limit=5,
output_fields=["source_text", "target_text"],
)
Milvus 还支持 WeightedRanker 作为 RRF 的替代品,适合需要显式控制 BM25/向量权重
的场景。数据量超过 10 亿、追求极致吞吐、已有 Milvus 基础设施时,它是最合理的选择。
FT.HYBRID——低延迟缓存层检索Redis 8.4(2025-11)首次提供统一混合检索命令 FT.HYBRID,把 RRF 带进了内存数据库
生态。
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
results = r.execute_command(
"FT.HYBRID", "idx:tm",
"QUERY", "少女 祈り",
"VECTOR", "vector",
*query_vector_bytes,
"COMBINE", "RRF",
"WINDOW", 20,
"CONSTANT", 60,
"LIMIT", 0, 5,
)
注意: Redis 8.4 的 FT.HYBRID 目前不支持后置 filter,也不支持 FT.PROFILE
性能分析。已有 Redis 基础设施、需要亚毫秒级检索延迟、数据量不超过千万级时,这是
成本最低的方案。
PostgreSQL 生态虽无原生 RRF,但一段 SQL CTE 就能实现,适合中小规模且团队不想引入
新中间件的场景。PostgreSQL 的 BM25 能力由 ParadeDB 的 pg_bm25(现已并入
pg_search)提供,补全了混合检索的关键拼图。
-- PostgreSQL 手动 RRF:CTE 实现
WITH
bm25_results AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_vector, query) DESC) AS rank
FROM translation_memory,
to_tsquery('japanese', '少女 & 祈り') AS query
WHERE search_vector @@ query
LIMIT 30
),
vector_results AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY vector <=> $1 ASC) AS rank
FROM translation_memory
ORDER BY vector <=> $1
LIMIT 30
),
rrf AS (
SELECT
COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.rank), 0) +
COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
)
SELECT tm.source_text, tm.target_text, rrf.rrf_score
FROM rrf
JOIN translation_memory tm ON tm.id = rrf.id
ORDER BY rrf_score DESC
LIMIT 5;
数据量 < 1000 万、已用 PostgreSQL 存业务数据、团队不想维护额外搜索/向量数据库时,
这套方案的最大优势是没有额外运维负担。
Vespa 的 global-phase RRF 支持跨节点分层排序,是复杂 RAG 场景的最强方案,
但学习曲线陡峭。
<!-- Vespa rank profile 定义 RRF -->
<rank-profile name="hybrid_rrf" inherits="default">
<first-phase>
<expression>
reciprocal_rank(bm25(source_text), 60) +
reciprocal_rank(closeness(field, vector), 60)
</expression>
</first-phase>
<global-phase>
<expression>
reciprocal_rank_fusion(bm25(source_text), closeness(field, vector))
</expression>
<rerank-count>100</rerank-count>
</global-phase>
</rank-profile>
Vespa 8.530(2025-06)新增了分块级排序,可以在文档内部精选最相关的 chunk 再进行
RRF,在长文档 RAG 场景下比"拆 chunk 存独立文档"更优雅。超大规模 RAG 应用、需要
chunk 级精排、团队有能力运维复杂系统——满足这三点才值得引入 Vespa。
你的数据量是多少?
│
├─ < 1000 万,已有 PostgreSQL
│ └─ → PostgreSQL + pgvector + 手写 RRF SQL
│
├─ < 1 亿,需要低延迟(< 10ms)
│ └─ 已有 Redis 8.4+?
│ ├─ 是 → Redis FT.HYBRID + RRF
│ └─ 否 → Qdrant v1.17(加权 RRF,运维简单)
│
├─ 1 亿 ~ 10 亿,需要成熟生态
│ ├─ 已在 AWS → OpenSearch 2.19+
│ └─ 其他云/自建 → Elasticsearch 8.9+
│
└─ > 10 亿,追求极致吞吐
├─ 复杂 RAG / 需要深度定制 → Vespa
└─ 标准混合检索 → Milvus 2.6 / 3.0
纯 RRF 不是唯一选择,2025-2026 出现了几个值得关注的替代/增强方案:
| 方案 | 来源 | 核心思路 | 对比 RRF |
|---|---|---|---|
| Weighted RRF | Qdrant v1.17 | 给不同检索器分配权重 | RRF 的增强,能表达"向量更重要" |
| Relative Score Fusion (RSF) | Weaviate v1.24+ | 对原始分数归一化后融合 | 比 RRF 多保留分数幅度信息,实测提升 6% 召回率 |
| Z-score 归一化 | OpenSearch 3.0 | 先 Z-score 标准化分数再加权 | 对分数分布差异大的场景更稳健 |
| Linear Combination | ES 8.14 rank.linear |
加权融合归一化后的分数 | 需要调 weight,但对"某路更重要"场景更精准 |
RRF 是无需调参的通用基线。如果业务有明确的"BM25 比向量更重要"或反过来的需求,
可以迁移到加权 RRF 或 RSF。
window_size 越大越好window_size 决定每个检索器参与 RRF 融合的候选数量,设太大会引入低质量文档、
拖慢查询。正确做法:window_size = max(k, size) * 2。
filter 和 knn 的 filter 共用一个就行ES 中 query.bool.filter 和 knn.filter 相互独立,knn 不继承顶层 query 的
filter:
// ❌ 错误:knn 不会应用 query 里的 filter
{
"query": { "bool": { "filter": [{"term": {"role": "エルザ"}}] } },
"knn": { "field": "vector", "query_vector": [...], "k": 15 }
}
// ✅ 正确:两处都要声明 filter
{
"query": { "bool": { "filter": [{"term": {"role": "エルザ"}}] } },
"knn": {
"field": "vector", "query_vector": [...], "k": 15,
"filter": { "bool": { "filter": [{"term": {"role": "エルザ"}}] } }
}
}
这是生产中最常见的"过滤条件静默失效"问题,务必在两处分别声明。
RRF 隐含"两路检索同等重要"的假设。如果业务明确需要 BM25 更强(如法律文档精确引用),
考虑 Qdrant 加权 RRF 或 ES 8.14 的 rank.linear。
num_candidates 等于 k 就够了num_candidates 是 HNSW 内部候选池,直接影响召回率。num_candidates < k 会报错;
推荐 num_candidates = k * 2。
k 参数含义相同各平台参数名相似但默认值不同:
| 平台 | 参数名 | 默认值 | 注意 |
|---|---|---|---|
| Elasticsearch | rank_constant |
60 | — |
| OpenSearch | rank_constant |
60 | — |
| Qdrant | k |
2(!) | 默认值远小于 ES,需手动设为 60 |
| Milvus | k |
60 | 官方建议范围 10-100 |
| Redis | CONSTANT |
60 | — |
Qdrant 的默认 k=2 会导致头部文档权重极度集中,生产环境强烈建议手动指定 k=60。
| 参数 | 含义 | 推荐值 | 调大效果 | 调小效果 |
|---|---|---|---|---|
rank_constant (k) |
RRF 平滑系数 | 60 | 排名更均匀 | 头部文档碾压其他 |
window_size |
每路检索参与融合候选数 | max(k,size)*2 |
召回更全,但慢 | 快,但可能漏掉好结果 |
knn.k |
kNN 返回候选数 | size * 3 |
向量检索覆盖广 | 节省内存和计算 |
num_candidates |
HNSW 内部搜索候选数 | k * 2 |
向量召回率提升 | 速度更快,召回率下降 |
min_score |
最低分数过滤 | 0(不过滤) | 过滤低质结果 | 保留更多候选 |
| 维度 | V1 script_score | V2 RRF(当前) |
|---|---|---|
| 量纲问题 | 存在,权重系数无物理意义 | 消除,只看排名 |
| 调参复杂度 | 高,需人工调 bm25_w/vec_w | 低,rank_constant=60 开箱即用 |
| 可解释性 | 差 | 好,排名位置直观 |
| ES 版本要求 | >= 7.x | >= 8.9 |
| 生产稳定性 | 需监控权重漂移 | 稳定,无调参风险 |
2026 年的格局:RRF 已成为混合检索的事实标准,从 ES、OpenSearch 到 Qdrant、
Milvus、Redis,所有主流平台都已原生支持。选型的核心问题不再是"哪个平台支持 RRF",
而是:
Comments