面试场景
面试官问:"混合检索比纯向量好吗?"
如果你回答"好",他紧接着就会问:"什么情况下混合反而更差?"
这就是 RAG 面试中最常见的条件变更陷阱 ------面试官不是在考你知道多少,而是在考你是否理解每个结论的适用边界。
底层知识:三种检索策略的原理与互补性
向量检索(Dense Retrieval)
将查询和文档都编码为高维向量,通过余弦相似度或内积计算相关性。
优势 :语义模糊匹配、同义改写、跨语言表达
劣势:精确术语匹配弱、对 embedding 模型质量敏感
BM25 检索(Sparse Retrieval)
基于词频-逆文档频率的统计检索,本质是 TF-IDF 的改进版。
BM25(q,d)=∑t∈qIDF(t)⋅f(t,d)(k1+1)f(t,d)+k1(1−b+b∣d∣avgdl)\text{BM25}(q,d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t,d)(k_1+1)}{f(t,d)+k_1(1-b+b\frac{|d|}{\text{avgdl}})}BM25(q,d)=t∈q∑IDF(t)⋅f(t,d)+k1(1−b+bavgdl∣d∣)f(t,d)(k1+1)
优势 :精确术语匹配、实体名、代码片段、错误码
劣势:语义改写能力弱,无法处理同义词
混合检索(Hybrid Retrieval)
通过 RRF(Reciprocal Rank Fusion)等融合算法合并两路结果:
RRF(d)=∑r∈R1K+rankr(d)\text{RRF}(d) = \sum_{r \in R} \frac{1}{K + \text{rank}_r(d)}RRF(d)=r∈R∑K+rankr(d)1
其中 K 是平滑参数,控制排名靠前结果的权重。
核心假设:向量和 BM25 的互补性。但这个互补性有明确的场景边界。
Python 实验:条件反转验证
实验设计
- 构建 30 篇技术文档库,每篇有模拟 embedding
- 构造两类查询集各 10 条:
- 语义型查询:口语化、模糊表达(如"怎么让模型学得更快更准")
- 精确术语型查询:包含专有名词(如"BM25算法中TF和IDF的计算公式")
- 对比三种策略在两类查询上的 Recall@5、NDCG@5、MRR
- 测试 RRF 参数 K 的影响
核心代码
python
import random
import numpy as np
from collections import defaultdict
random.seed(42)
np.random.seed(42)
def cosine_sim(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
def bm25_score(query_terms, doc, all_docs, k1=1.5, b=0.75):
"""简化的 BM25 评分"""
score = 0.0
N = len(all_docs)
avg_dl = np.mean([len(d.terms) for d in all_docs])
dl = len(doc.terms)
for qt in query_terms:
tf = sum(1 for t in doc.terms if t == qt or qt in t or t in qt)
df = sum(1 for d in all_docs if any(qt in t or t in qt for t in d.terms))
if df == 0:
continue
idf = np.log((N - df + 0.5) / (df + 0.5) + 1)
tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avg_dl))
score += idf * tf_norm
return score
def vector_search(query, documents, top_k=5):
"""向量检索:用查询的模拟 embedding 做余弦相似度"""
# 语义查询噪声小,精确查询噪声大(模拟真实场景)
if query.query_type == "semantic":
q_emb += np.random.normal(0, 0.15, len(q_emb))
else:
q_emb += np.random.normal(0, 0.35, len(q_emb)) # 精确术语在向量空间偏移更大
# ... 计算余弦相似度排序
return scored[:top_k]
def rrf_fusion(vec_results, bm25_results, k=60, top_k=5):
"""RRF 融合两路检索结果"""
scores = defaultdict(float)
for rank, (doc_id, _) in enumerate(vec_results):
scores[doc_id] += 1.0 / (k + rank + 1)
for rank, (doc_id, _) in enumerate(bm25_results):
scores[doc_id] += 1.0 / (k + rank + 1)
fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return fused[:top_k]
完整可运行代码见
experiment_hybrid_retrieval.py
实验结果
核心对比:三种策略 × 两类查询
| 查询类型 | 策略 | Recall@5 | NDCG@5 | MRR |
|---|---|---|---|---|
| 语义型 | 向量检索 | 0.850 | 0.671 | 0.658 |
| 语义型 | BM25 | 0.750 | 0.769 | 1.000 |
| 语义型 | 混合(K=60) | 0.950 | 0.911 | 1.000 |
| 精确术语 | 向量检索 | 0.300 | 0.202 | 0.170 |
| 精确术语 | BM25 | 1.000 | 1.000 | 1.000 |
| 精确术语 | 混合(K=60) | 0.800 | 0.626 | 0.567 |
关键发现 :在精确术语查询上,BM25 的 NDCG@5 是完美的 1.000,而混合检索反而降至 0.626------下降了 37.4%。向量检索引入的噪声把 BM25 的精确匹配稀释了。
条件反转的核心数据
语义型查询:
混合 vs 向量: NDCG +0.164(混合更优)
混合 vs BM25: NDCG +0.085(混合更优)
精确术语型查询:
混合 vs 向量: NDCG +0.360(混合优于纯向量)
混合 vs BM25: NDCG -0.287(混合不如纯 BM25!)
RRF 参数 K 的影响
| K 值 | 语义型 NDCG@5 | 精确型 NDCG@5 |
|---|---|---|
| 1 | 0.898 | 0.839 |
| 10 | 0.748 | 0.749 |
| 60 | 0.794 | 0.613 |
| 200 | 0.869 | 0.765 |
K 值不是"越大越好"或"越小越好"------最优值取决于查询分布。
逐查询翻车分析
以下查询中,混合检索的 NDCG 低于两路中最好的那一路:
| 查询 | 类型 | 向量 NDCG | BM25 NDCG | 混合 NDCG | 混合-最优 |
|---|---|---|---|---|---|
| 如何理解机器翻译的核心技术 | 语义 | 0.624 | 0.850 | 0.571 | -0.280 |
| BM25算法中TF和IDF的计算公式 | 精确 | 0.000 | 1.000 | 0.000 | -1.000 |
| BERT的Masked LM训练目标 | 精确 | 0.000 | 1.000 | 0.500 | -0.500 |
| Redis缓存穿透的解决方案 | 精确 | 0.000 | 1.000 | 0.631 | -0.369 |
这些"翻车"case 揭示了一个关键事实:当一路检索提供了完美的排序时,引入另一路噪声只会把完美结果搞乱。
数据分析:条件反转的根因
为什么精确术语查询上混合反而更差?
根本原因是信息粒度的不对称:
- 精确术语查询的 BM25 可以精确命中包含该术语的文档,排名第一的文档就是正确答案
- 向量检索把查询编码为稠密向量后,"BM25"这个词的精确匹配信号被稀释在语义空间中
- RRF 融合时,向量路引入的不相关文档获得了额外的排名分数,把 BM25 的完美排序搞乱
什么条件下混合检索真正有效?
混合检索的优势场景是长尾混合分布:
- 一部分查询是口语化的("怎么提升推荐效果")
- 一部分查询包含精确术语("协同过滤的冷启动问题")
- 大部分查询介于两者之间
在这种分布下,没有任何单一策略能覆盖所有查询类型,混合的价值来自于互补。
面试回答模板
低分回答:"混合检索比纯向量好,因为结合了语义和关键词匹配。"
高分回答:
"混合检索的价值取决于查询分布。我们在项目上做了一个条件反转实验:
- 在语义型查询切片上,混合比纯 BM25 的 NDCG@5 高 0.085
- 但在精确术语查询切片上,混合比纯 BM25 低 0.287------向量路把 BM25 的精确命中稀释了
- RRF 的 K 参数我们调了 8 个值,最优 K=1 在语义查询上 NDCG 0.898,但在精确查询上只有 0.839
所以我们最终的方案是按查询类型动态选择策略:先用一个轻量分类器判断查询类型,语义型走向量,精确型走 BM25,混合型走融合。"
实践建议
- 构造条件反转实验:分别构造纯语义和纯精确查询集,测量三种策略的差异
- 遍历 RRF 的 K 参数:不要凭感觉选 K,画出 K-NDCG 曲线找拐点
- 按查询类型切片分析:整体指标掩盖了局部差异
- 面试中带数字回答:不是"混合有时更好",而是"在 XX 切片上高 N 个点,在 YY 切片上低 M 个点"
本系列共 6 篇,下一篇:《Agent 上下文漂移与 Reflexion 陷阱------"加反思就好了"什么时候不成立?》
完整Python代码
python
#!/usr/bin/env python3
"""
混合检索条件反转实验
对比向量检索、BM25 检索和混合检索在不同查询分布下的表现,
验证「混合检索并非无条件优越」的论点。
实验设计:
1. 构造两类查询集:语义型查询 vs 精确术语型查询
2. 模拟三种检索策略在各查询上的表现
3. 分析 RRF 融合参数 K 的影响
4. 得出条件依赖的结论
"""
import random
import numpy as np
from collections import defaultdict
random.seed(42)
np.random.seed(42)
# ============================================================
# 1. 构建文档库(含 embedding 模拟)
# ============================================================
class Document:
def __init__(self, doc_id, title, terms, embedding=None):
self.doc_id = doc_id
self.title = title
self.terms = terms # 关键词列表
self.embedding = embedding or []
# 文档库:30 篇技术文档
DOCS_DATA = [
("深度学习中的梯度下降优化", ["梯度下降", "学习率", "Adam", "SGD", "收敛"]),
("反向传播算法详解", ["反向传播", "链式法则", "梯度", "计算图", "自动微分"]),
("Transformer 架构原理", ["Transformer", "self-attention", "多头注意力", "位置编码", "编码器"]),
("BERT 预训练模型", ["BERT", "Masked LM", "Next Sentence", "fine-tune", "预训练"]),
("GPT 系列模型演进", ["GPT", "自回归", "语言模型", "few-shot", "prompt"]),
("卷积神经网络基础", ["CNN", "卷积核", "池化", "特征图", "感受野"]),
("目标检测 YOLO 系列", ["YOLO", "目标检测", "anchor", "NMS", "bounding box"]),
("图像分割技术综述", ["语义分割", "实例分割", "U-Net", "Mask R-CNN", "像素级"]),
("ResNet 残差网络", ["ResNet", "残差连接", "skip connection", "深度网络", "退化问题"]),
("强化学习 Q-learning", ["Q-learning", "Q-table", "Bellman", "离散动作", "值函数"]),
("策略梯度方法 PPO", ["PPO", "策略梯度", "actor-critic", "clip", "on-policy"]),
("推荐系统协同过滤", ["协同过滤", "用户-物品矩阵", "相似度", "冷启动", "KNN"]),
("矩阵分解推荐", ["矩阵分解", "SVD", "隐因子", "ALS", "评分预测"]),
("知识图谱嵌入 TransE", ["TransE", "图嵌入", "三元组", "关系建模", "距离度量"]),
("BM25 检索算法", ["BM25", "TF-IDF", "倒排索引", "词频", "文档频率"]),
("向量数据库 Faiss", ["Faiss", "ANN", "HNSW", "向量索引", "近似最近邻"]),
("Elasticsearch 搜索引擎", ["Elasticsearch", "倒排索引", "分词", "Lucene", "DSL查询"]),
("分布式一致性 Raft", ["Raft", "leader选举", "日志复制", "一致性", "任期"]),
("CAP 定理详解", ["CAP", "一致性", "可用性", "分区容忍", "BASE"]),
("B+树索引原理", ["B+树", "索引", "平衡树", "磁盘IO", "范围查询"]),
("微服务熔断模式", ["熔断", "Circuit Breaker", "降级", "Hystrix", "超时"]),
("API 网关设计", ["API网关", "路由", "限流", "认证", "协议转换"]),
("Docker 容器化", ["Docker", "容器", "镜像", "Dockerfile", "namespace"]),
("Kubernetes 编排", ["K8s", "Pod", "Service", "Deployment", "调度"]),
("Redis 缓存策略", ["Redis", "缓存穿透", "缓存雪崩", "LRU", "持久化"]),
("消息队列 Kafka", ["Kafka", "分区", "消费者组", "offset", "高吞吐"]),
("图数据库 Neo4j", ["Neo4j", "Cypher", "属性图", "图遍历", "关系查询"]),
("GAN 生成对抗网络", ["GAN", "生成器", "判别器", "对抗训练", "mode collapse"]),
("注意力机制详解", ["attention", "query-key-value", "缩放点积", "交叉注意力", "多头"]),
("词向量 Word2Vec", ["Word2Vec", "CBOW", "Skip-gram", "负采样", "词嵌入"]),
]
documents = [Document(i, title, terms) for i, (title, terms) in enumerate(DOCS_DATA)]
# 为每个文档生成模拟 embedding(8维,用主题聚类)
topic_centers = {
"ML": np.array([0.8, 0.7, 0.1, 0.2, 0.3, 0.1, 0.5, 0.4]),
"NLP": np.array([0.7, 0.3, 0.8, 0.6, 0.2, 0.3, 0.4, 0.7]),
"CV": np.array([0.6, 0.8, 0.2, 0.1, 0.7, 0.3, 0.5, 0.2]),
"RL": np.array([0.5, 0.5, 0.3, 0.8, 0.4, 0.6, 0.3, 0.5]),
"RecSys": np.array([0.3, 0.4, 0.5, 0.5, 0.6, 0.7, 0.4, 0.3]),
"IR": np.array([0.4, 0.3, 0.7, 0.3, 0.5, 0.8, 0.6, 0.4]),
"DistSys": np.array([0.2, 0.6, 0.4, 0.4, 0.3, 0.5, 0.8, 0.6]),
"Infra": np.array([0.3, 0.7, 0.3, 0.3, 0.5, 0.4, 0.7, 0.8]),
}
# 给每个文档分配主题和 embedding
doc_topics = [
"ML", "ML", "NLP", "NLP", "NLP", # 0-4
"CV", "CV", "CV", "CV", # 5-8
"RL", "RL", # 9-10
"RecSys", "RecSys", # 11-12
"IR", "IR", "IR", # 13-15 (含知识图谱)
"IR", "DistSys", "DistSys", # 16-18
"DistSys", "Infra", "Infra", # 19-21
"Infra", "Infra", "Infra", # 22-24
"Infra", "IR", "ML", "NLP", "NLP", # 25-29
]
for doc, topic in zip(documents, doc_topics):
center = topic_centers[topic]
noise = np.random.normal(0, 0.1, len(center))
doc.embedding = center + noise
doc.embedding = doc.embedding / np.linalg.norm(doc.embedding)
# ============================================================
# 2. 查询集:两类分布
# ============================================================
class Query:
def __init__(self, qid, text, query_type, relevant_doc_ids, key_terms):
self.qid = qid
self.text = text
self.query_type = query_type # "semantic" or "precise"
self.relevant_doc_ids = relevant_doc_ids
self.key_terms = key_terms
SEMANTIC_QUERIES = [
Query(0, "怎么让模型学得更快更准", "semantic", [0, 1], ["梯度下降", "学习率"]),
Query(1, "如何理解机器翻译的核心技术", "semantic", [2, 3], ["Transformer", "注意力"]),
Query(2, "识别图片中物体的方法有哪些", "semantic", [6, 7], ["目标检测", "图像分割"]),
Query(3, "怎么让AI自己学会玩游戏", "semantic", [9, 10], ["强化学习", "Q-learning"]),
Query(4, "怎么给用户推荐他们可能喜欢的东西", "semantic", [11, 12], ["协同过滤", "推荐"]),
Query(5, "如何让计算机理解词语的含义", "semantic", [29, 3], ["词向量", "Word2Vec"]),
Query(6, "图片风格迁移和生成是怎么做到的", "semantic", [27, 8], ["GAN", "生成"]),
Query(7, "大规模数据怎么存储和查询", "semantic", [15, 16], ["索引", "向量"]),
Query(8, "怎么保证多个服务器数据一致", "semantic", [17, 18], ["一致性", "分布式"]),
Query(9, "系统响应慢怎么排查和优化", "semantic", [24, 20], ["缓存", "性能"]),
]
PRECISE_QUERIES = [
Query(10, "BM25算法中TF和IDF的计算公式", "precise", [14], ["BM25", "TF-IDF"]),
Query(11, "BERT的Masked LM训练目标", "precise", [3], ["BERT", "Masked LM"]),
Query(12, "YOLO的anchor box机制", "precise", [6], ["YOLO", "anchor"]),
Query(13, "PPO算法的clip ratio参数", "precise", [10], ["PPO", "clip"]),
Query(14, "Faiss中HNSW索引的构建参数", "precise", [15], ["Faiss", "HNSW"]),
Query(15, "Elasticsearch的DSL查询语法", "precise", [16], ["Elasticsearch", "DSL"]),
Query(16, "Raft协议的leader选举流程", "precise", [17], ["Raft", "leader选举"]),
Query(17, "Redis缓存穿透的解决方案", "precise", [24], ["Redis", "缓存穿透"]),
Query(18, "Kafka消费者组的rebalance机制", "precise", [25], ["Kafka", "消费者组"]),
Query(19, "Docker的namespace隔离原理", "precise", [22], ["Docker", "namespace"]),
]
ALL_QUERIES = SEMANTIC_QUERIES + PRECISE_QUERIES
# ============================================================
# 3. 三种检索策略
# ============================================================
def cosine_sim(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
def bm25_score(query_terms, doc, all_docs, k1=1.5, b=0.75):
"""简化的 BM25 评分"""
score = 0.0
N = len(all_docs)
avg_dl = np.mean([len(d.terms) for d in all_docs])
dl = len(doc.terms)
for qt in query_terms:
# TF
tf = sum(1 for t in doc.terms if t == qt or qt in t or t in qt)
# DF
df = sum(1 for d in all_docs if any(qt in t or t in qt for t in d.terms))
if df == 0:
continue
idf = np.log((N - df + 0.5) / (df + 0.5) + 1)
tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avg_dl))
score += idf * tf_norm
return score
def vector_search(query, documents, top_k=5):
"""向量检索:用查询的模拟 embedding 做余弦相似度"""
# 模拟查询 embedding:取相关文档 embedding 的平均 + 噪声
if query.relevant_doc_ids:
rel_embs = [documents[d].embedding for d in query.relevant_doc_ids if d < len(documents)]
if rel_embs:
q_emb = np.mean(rel_embs, axis=0)
else:
q_emb = np.random.rand(8)
else:
q_emb = np.random.rand(8)
# 语义查询:噪声小(模拟模糊但方向正确)
# 精确查询:噪声大(模拟精确术语在向量空间中不一定靠近目标文档)
if query.query_type == "semantic":
q_emb += np.random.normal(0, 0.15, len(q_emb))
else:
q_emb += np.random.normal(0, 0.35, len(q_emb)) # 精确查询向量偏移更大
q_emb = q_emb / np.linalg.norm(q_emb)
scored = [(doc.doc_id, cosine_sim(q_emb, doc.embedding)) for doc in documents]
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
def bm25_search(query, documents, top_k=5):
"""BM25 检索"""
query_terms = query.text.split() + query.key_terms
scored = [(doc.doc_id, bm25_score(query_terms, doc, documents))
for doc in documents]
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
def rrf_fusion(vec_results, bm25_results, k=60, top_k=5):
"""RRF 融合两路检索结果"""
scores = defaultdict(float)
for rank, (doc_id, _) in enumerate(vec_results):
scores[doc_id] += 1.0 / (k + rank + 1)
for rank, (doc_id, _) in enumerate(bm25_results):
scores[doc_id] += 1.0 / (k + rank + 1)
fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return fused[:top_k]
def evaluate(results, relevant_ids, top_k=5):
"""计算 Recall@K 和 NDCG@K"""
result_ids = [r[0] for r in results[:top_k]]
relevant_set = set(relevant_ids)
# Recall@K
hits = sum(1 for rid in result_ids if rid in relevant_set)
recall = hits / max(len(relevant_set), 1)
# NDCG@K
dcg = 0
for i, rid in enumerate(result_ids):
rel = 1.0 if rid in relevant_set else 0.0
dcg += rel / np.log2(i + 2)
idcg = sum(1.0 / np.log2(i + 2) for i in range(min(len(relevant_set), top_k)))
ndcg = dcg / max(idcg, 1e-8)
# MRR
mrr = 0
for i, rid in enumerate(result_ids):
if rid in relevant_set:
mrr = 1.0 / (i + 1)
break
return {"recall": recall, "ndcg": ndcg, "mrr": mrr}
# ============================================================
# 4. 主实验
# ============================================================
def run_experiments():
print("=" * 72)
print(" 混合检索条件反转实验")
print(" 目标:验证混合检索在不同查询分布下的条件依赖性")
print("=" * 72)
top_k = 5
# --- 实验1:三种策略在两类查询上的表现 ---
print("\n" + "=" * 72)
print("【实验 1】三种检索策略 × 两类查询分布")
print("=" * 72)
strategies = {
"向量检索": lambda q: vector_search(q, documents, top_k),
"BM25检索": lambda q: bm25_search(q, documents, top_k),
"混合检索(K=60)": lambda q: rrf_fusion(
vector_search(q, documents, top_k * 2),
bm25_search(q, documents, top_k * 2),
k=60, top_k=top_k
),
}
for qtype, qtype_label in [("semantic", "语义型查询"), ("precise", "精确术语型查询")]:
queries = [q for q in ALL_QUERIES if q.query_type == qtype]
print(f"\n--- {qtype_label}({len(queries)} 条) ---")
print(f"{'策略':<20} {'Recall@5':>10} {'NDCG@5':>10} {'MRR':>10}")
print("-" * 55)
for strat_name, strat_fn in strategies.items():
metrics = defaultdict(list)
for q in queries:
results = strat_fn(q)
m = evaluate(results, q.relevant_doc_ids, top_k)
for k, v in m.items():
metrics[k].append(v)
avg_recall = np.mean(metrics["recall"])
avg_ndcg = np.mean(metrics["ndcg"])
avg_mrr = np.mean(metrics["mrr"])
print(f"{strat_name:<20} {avg_recall:>10.3f} {avg_ndcg:>10.3f} {avg_mrr:>10.3f}")
# --- 实验2:RRF K 参数的影响 ---
print("\n" + "=" * 72)
print("【实验 2】RRF 融合参数 K 的影响")
print("=" * 72)
k_values = [1, 5, 10, 20, 40, 60, 100, 200]
for qtype, qtype_label in [("semantic", "语义型查询"), ("precise", "精确术语型查询")]:
queries = [q for q in ALL_QUERIES if q.query_type == qtype]
print(f"\n--- {qtype_label} ---")
print(f"{'K值':<8} {'Recall@5':>10} {'NDCG@5':>10} {'MRR':>10}")
print("-" * 42)
for k in k_values:
metrics = defaultdict(list)
for q in queries:
fused = rrf_fusion(
vector_search(q, documents, top_k * 2),
bm25_search(q, documents, top_k * 2),
k=k, top_k=top_k
)
m = evaluate(fused, q.relevant_doc_ids, top_k)
for mk, v in m.items():
metrics[mk].append(v)
print(f"{k:<8} {np.mean(metrics['recall']):>10.3f} "
f"{np.mean(metrics['ndcg']):>10.3f} {np.mean(metrics['mrr']):>10.3f}")
# --- 实验3:条件反转的核心对比 ---
print("\n" + "=" * 72)
print("【实验 3】条件反转核心对比:混合 vs 纯策略")
print("=" * 72)
for qtype, qtype_label in [("semantic", "语义型查询"), ("precise", "精确术语型查询")]:
queries = [q for q in ALL_QUERIES if q.query_type == qtype]
vec_metrics = defaultdict(list)
bm25_metrics = defaultdict(list)
hybrid_metrics = defaultdict(list)
for q in queries:
vr = vector_search(q, documents, top_k * 2)
br = bm25_search(q, documents, top_k * 2)
vm = evaluate(vr[:top_k], q.relevant_doc_ids, top_k)
bm = evaluate(br[:top_k], q.relevant_doc_ids, top_k)
hr = rrf_fusion(vr, br, k=60, top_k=top_k)
hm = evaluate(hr, q.relevant_doc_ids, top_k)
for k in ["recall", "ndcg", "mrr"]:
vec_metrics[k].append(vm[k])
bm25_metrics[k].append(bm[k])
hybrid_metrics[k].append(hm[k])
print(f"\n--- {qtype_label} ---")
print(f"{'对比项':<24} {'NDCG@5':>10} {'差值':>10}")
print("-" * 48)
for metric in ["ndcg"]:
v = np.mean(vec_metrics[metric])
b = np.mean(bm25_metrics[metric])
h = np.mean(hybrid_metrics[metric])
diff_vs_vec = h - v
diff_vs_bm25 = h - b
print(f"{'混合 vs 向量':<24} {h:>10.3f} {diff_vs_vec:>+10.3f}")
print(f"{'混合 vs BM25':<24} {h:>10.3f} {diff_vs_bm25:>+10.3f}")
# --- 实验4:逐查询对比 ---
print("\n" + "=" * 72)
print("【实验 4】逐查询对比:混合检索在哪些查询上翻车")
print("=" * 72)
print(f"\n{'查询':<30} {'类型':<8} {'向量NDCG':>10} {'BM25':>10} {'混合':>10} {'混合-最优':>10}")
print("-" * 82)
for q in ALL_QUERIES:
vr = vector_search(q, documents, top_k * 2)
br = bm25_search(q, documents, top_k * 2)
hr = rrf_fusion(vr, br, k=60, top_k=top_k)
vm = evaluate(vr[:top_k], q.relevant_doc_ids, top_k)
bm = evaluate(br[:top_k], q.relevant_doc_ids, top_k)
hm = evaluate(hr, q.relevant_doc_ids, top_k)
best_single = max(vm["ndcg"], bm["ndcg"])
gap = hm["ndcg"] - best_single
qtype_cn = "语义" if q.query_type == "semantic" else "精确"
text_short = q.text[:28]
print(f"{text_short:<30} {qtype_cn:<8} {vm['ndcg']:>10.3f} {bm['ndcg']:>10.3f} "
f"{hm['ndcg']:>10.3f} {gap:>+10.3f}")
# --- 总结 ---
print("\n" + "=" * 72)
print("实验结论")
print("=" * 72)
print("""
1. 混合检索的价值来自向量检索与 BM25 的互补性,但互补有明确的场景边界:
- 语义型查询:向量检索提供主要信号,BM25 补充有限
- 精确术语查询:BM25 提供主要信号,向量检索可能引入噪声
2. RRF 的 K 参数是关键调节点:
- K 值小 → 更信任排名靠前的结果(偏向某一路)
- K 值大 → 两路权重趋于平均(可能叠加噪声)
- 最优 K 值取决于查询分布,需要实验确定
3. 混合检索并非无条件优越:
- 在纯语义查询分布下,混合可能不如纯向量
- 在纯精确查询分布下,混合可能不如纯 BM25
- 真正的优势出现在混合分布(长尾口语 + 精确术语混合)场景
4. 面试中的关键回答:
- 不要说「混合检索比纯向量好」
- 要说「在我们的查询分布下,混合检索在 XX 切片上比纯策略高 N 个点,
但在 YY 切片上反而低 M 个点,原因是 ZZ」
""")
if __name__ == "__main__":
run_experiments()