向量检索调参经验——相似度阈值怎么定
向量检索调参经验——相似度阈值怎么定
适读人群:做RAG应用的AI工程师 | 阅读时长:约14分钟 | 核心价值:掌握基于测试数据的相似度阈值系统化调参方法
我第一次在 RAG 系统里设相似度阈值,是拍了个 0.7 的数字。
为什么是 0.7?说实话,没有理由,就是感觉「七成相似」应该够了,不太高不太低,听起来挺合理的。
上线跑了两周,用户反馈越来越多抱怨「问一个问题,助手说找不到相关信息」。我一查日志,发现大量用户问题被系统判断为「无相关文档」,直接走了降级回答流程。把这批 Query 拿出来手动查,发现知识库里其实有对应的内容,只是余弦相似度分数在 0.62-0.69 之间,被阈值挡在了外面。
于是我把阈值改成 0.5。
然后又出问题了——某些问题开始召回一堆不相关的文档,模型拿着这些低质量的上下文生成了乱七八糟的答案,比之前还糟。
这就是相似度阈值调参的经典困境:阈值高了召回不足,阈值低了引入噪音。
这篇文章把我系统化解决这个问题的方法完整写出来。
为什么这是个被低估的问题
向量检索在 RAG 系统里扮演的角色是「找到相关文档」,相似度阈值决定了「多相似才算相关」。
问题的本质是:余弦相似度(或其他距离度量)是一个没有绝对含义的数字。0.8 的余弦相似度不代表「八成相关」,它的含义完全取决于你用的 Embedding 模型、你的文档内容特征、以及你的检索场景。
同样的 0.7 阈值,在一个法律文档知识库里可能过于严格,在一个闲聊 FAQ 知识库里可能太宽松。
而且,不同 Embedding 模型输出的相似度分数分布完全不同。用 text-embedding-ada-002 调好的 0.75 阈值,换成 bge-large-zh 可能就完全不适用了。
基础概念:Precision 和 Recall 的 tradeoff
在讲怎么调参之前,先对齐两个概念。
Precision(精确率):在你召回的文档里,有多少是真正相关的。
Recall(召回率):在所有真正相关的文档里,你召回了多少。
阈值和这两个指标的关系是:
- 阈值越高 → Precision 越高(召回的都是高相似的,准确) → Recall 越低(很多相关文档被过滤掉了)
- 阈值越低 → Recall 越高(捞了更多相关文档) → Precision 越低(也捞了很多不相关的)
这是一个固有的 tradeoff,没有办法同时最大化两者。你需要根据业务场景,判断这个 tradeoff 应该偏向哪边。
什么时候宁可 Precision 低也要 Recall 高? 比如医疗建议场景,漏掉一条重要的文档(比如药物禁忌)可能造成严重后果,宁可多召回一些、让模型自己判断相关性,也不能漏。
什么时候宁可 Recall 低也要 Precision 高? 比如对话系统,上下文窗口有限,召回太多噪音文档会挤掉真正有用的内容,而且会干扰模型判断。这时候宁可少召回,保证质量。
调参方法:基于测试集的 Precision-Recall 曲线
理论讲完了,说具体怎么做。
第一步:构建带标注的测试集
这是最关键也最耗时的一步,没有它后面的事情都是空话。
测试集需要包含:
- 一批真实的用户 Query(从日志里抽,或者手工构造)
- 每个 Query 对应的「应该被召回的文档 ID 列表」(人工标注)
标注方法:把 Query 和知识库里的文档都列出来,让熟悉业务的人判断「这个文档和这个 Query 是否相关」。不需要标注所有文档,只需要对每个 Query 找出真正相关的那几个。
测试集规模:100-300 个 Query 通常够用。关键是要覆盖业务的主要场景——如果有某类特殊场景,要刻意多采样几个。
# 测试集格式示例
test_set = [
{
"query_id": "q001",
"query": "如何申请年假?",
"relevant_doc_ids": ["doc_045", "doc_123"] # 人工标注的相关文档
},
{
"query_id": "q002",
"query": "报销发票需要哪些材料?",
"relevant_doc_ids": ["doc_088", "doc_091", "doc_210"]
},
# ...更多样本
]第二步:在不同阈值下计算 Precision 和 Recall
import numpy as np
from typing import List, Dict
def evaluate_retrieval(
test_set: List[Dict],
vectorstore,
threshold: float,
top_k: int = 10
) -> Dict[str, float]:
"""
在给定阈值下,计算整体 Precision 和 Recall
"""
precisions = []
recalls = []
for item in test_set:
query = item["query"]
relevant_ids = set(item["relevant_doc_ids"])
# 检索(先不过阈值,拿到所有带分数的结果)
results = vectorstore.similarity_search_with_score(
query, k=top_k
)
# 按阈值过滤
# 注意:不同库的相似度方向不同
# 有些是越大越相似(余弦相似度),有些是越小越相似(L2距离)
# 这里假设越大越相似
retrieved_ids = set(
doc.metadata["doc_id"]
for doc, score in results
if score >= threshold
)
if len(retrieved_ids) == 0:
precision = 1.0 # 没召回任何东西,精确率定义为1(无假阳性)
recall = 0.0
else:
# 交集:既被召回、又真的相关的文档
true_positives = len(retrieved_ids & relevant_ids)
precision = true_positives / len(retrieved_ids)
recall = true_positives / len(relevant_ids)
precisions.append(precision)
recalls.append(recall)
return {
"threshold": threshold,
"mean_precision": np.mean(precisions),
"mean_recall": np.mean(recalls),
"f1": 2 * np.mean(precisions) * np.mean(recalls) / (np.mean(precisions) + np.mean(recalls) + 1e-10)
}
def sweep_thresholds(test_set, vectorstore, thresholds=None):
"""扫描多个阈值,得到完整的 P-R 曲线数据"""
if thresholds is None:
thresholds = np.arange(0.3, 0.95, 0.05)
results = []
for threshold in thresholds:
result = evaluate_retrieval(test_set, vectorstore, threshold)
results.append(result)
print(f"阈值 {threshold:.2f}: Precision={result['mean_precision']:.3f}, "
f"Recall={result['mean_recall']:.3f}, F1={result['f1']:.3f}")
return results第三步:画出 P-R 曲线,找决策点
import matplotlib.pyplot as plt
def plot_pr_curve(results: List[Dict]):
precisions = [r["mean_precision"] for r in results]
recalls = [r["mean_recall"] for r in results]
thresholds = [r["threshold"] for r in results]
f1_scores = [r["f1"] for r in results]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
# 左图:P-R 曲线
ax1.plot(recalls, precisions, 'b-o', markersize=4)
for i, t in enumerate(thresholds):
ax1.annotate(f'{t:.2f}', (recalls[i], precisions[i]),
textcoords="offset points", xytext=(5, 5), fontsize=8)
ax1.set_xlabel('Recall')
ax1.set_ylabel('Precision')
ax1.set_title('Precision-Recall Curve')
ax1.grid(True)
# 右图:F1 随阈值的变化
ax2.plot(thresholds, f1_scores, 'r-o', markersize=4)
best_idx = np.argmax(f1_scores)
ax2.axvline(x=thresholds[best_idx], color='green', linestyle='--',
label=f'Best F1={f1_scores[best_idx]:.3f} at threshold={thresholds[best_idx]:.2f}')
ax2.set_xlabel('Threshold')
ax2.set_ylabel('F1 Score')
ax2.set_title('F1 Score vs Threshold')
ax2.legend()
ax2.grid(True)
plt.tight_layout()
plt.savefig('pr_curve.png', dpi=150)
plt.show()第四步:根据业务场景选阈值
有了 P-R 曲线,不要简单地选 F1 最高的点,要根据业务场景做判断:
- 如果业务对「召回不足」的容忍度低(比如知识库问答,漏了答案用户会直接不满意):选 Recall >= 0.85 的最高 Precision 点
- 如果业务对「噪音」的容忍度低(比如上下文长度非常受限的场景):选 Precision >= 0.85 的最高 Recall 点
- 如果两者均衡:选 F1 最高点
我的一次真实调参记录
项目:企业内部政策 FAQ 系统,知识库约 1200 个文档分块。 Embedding 模型:text-embedding-3-small
测试集:150 个 Query,手工标注了相关文档。
扫描结果(部分):
阈值 0.30: Precision=0.412, Recall=0.941, F1=0.573
阈值 0.40: Precision=0.521, Recall=0.912, F1=0.664
阈值 0.50: Precision=0.634, Recall=0.873, F1=0.735
阈值 0.55: Precision=0.691, Recall=0.842, F1=0.759
阈值 0.60: Precision=0.748, Recall=0.798, F1=0.772 ← F1最高
阈值 0.65: Precision=0.812, Recall=0.734, F1=0.771
阈值 0.70: Precision=0.861, Recall=0.651, F1=0.742
阈值 0.75: Precision=0.903, Recall=0.532, F1=0.669
阈值 0.80: Precision=0.941, Recall=0.391, F1=0.553F1 最高点在 0.60,但我们的业务场景(政策 FAQ)偏向于不能漏,用户问一个政策问题,漏了回答比回答不准更糟糕。
所以我选了 0.55 而不是 0.60——Recall 提升 4.4 个点(0.842 vs 0.798),代价是 Precision 降低 5.7 个点(0.691 vs 0.748),这个 tradeoff 对我们的场景值得。
上线后,「找不到相关信息」的投诉减少了约 60%,偶尔召回不太相关内容导致的答案偏差投诉上升了少量,综合满意度提升了。
进阶:动态阈值
固定阈值是最简单的做法,但有个问题:不同 Query 的「难度」不一样。有些 Query 非常专业,知识库里的最相关文档相似度可能也只有 0.62;有些 Query 很通用,轻松能召回 0.85 以上的文档。
固定阈值对前者太严,对后者可能太松。
一个改进方案是动态阈值:不用全局固定阈值,而是基于检索结果的分数分布来动态判断。
def dynamic_threshold_filter(
results_with_scores: List[tuple],
min_results: int = 1,
max_results: int = 5,
score_gap_threshold: float = 0.15
) -> List:
"""
动态阈值过滤策略:
1. 如果最高分和次高分之间有明显gap,只取最高分那批
2. 保证至少 min_results 个结果(除非实在没有)
3. 最多返回 max_results 个结果
"""
if not results_with_scores:
return []
# 按分数降序排列
sorted_results = sorted(results_with_scores, key=lambda x: x[1], reverse=True)
selected = [sorted_results[0]]
for i in range(1, len(sorted_results)):
current_score = sorted_results[i][1]
prev_score = sorted_results[i-1][1]
# 如果和上一个的分数差距大,停止
if prev_score - current_score > score_gap_threshold:
break
selected.append(sorted_results[i])
if len(selected) >= max_results:
break
# 确保至少有 min_results 个
if len(selected) < min_results and len(sorted_results) >= min_results:
selected = sorted_results[:min_results]
return [doc for doc, score in selected]这个动态策略的逻辑是:看分数的「悬崖」。如果第一个结果分数 0.85,第二个 0.83,第三个 0.81,第四个突然跌到 0.62——那 0.62 这个很可能是噪音,不需要。
相似度阈值看起来是个小参数,但它实际上是 RAG 系统 Precision 和 Recall 的总控。把它建立在测试数据而不是直觉上,你的 RAG 系统才有可靠的行为保证。
