检索不止余弦相似度:高级检索策略大揭秘

周一早上的事故复盘会,空气比平常凝重。老李坐在会议室前面,保温杯重重地顿在桌上。

“昨天系统被人投诉了,”老李环顾一圈,“用户问‘PostgreSQL 连接池在 Kubernetes 里怎么配’,系统回答的是通用 Postgres 配置,完全没有 K8s 相关内容。我查了日志,正确答案其实在知识库里,排在检索结果的第四位——但我们只取了 top-3。”

小王举手:“老李,top_k=3 是你定的。”

“是我定的,怎么了?三条最相似的文档还不够?我以前做全文检索,取前三条精准命中,哪有这么多事儿!”

“老李你想啊——你去相亲角给儿子找对象,只看资料最像的三个姑娘,万一第四个才是真爱呢?而且‘像’是什么意思?身高体重学历匹配就叫像?性格和三观就不重要了?”

老李的眉头拧成一团:“检索跟相亲能一样吗?”

“太一样了。你用的余弦相似度,只看语义向量之间的角度,这叫‘稠密检索’。它擅长理解‘意思相近’,但有时候最对的答案不是靠‘意思相近’就能找到的。”


稠密检索 vs 稀疏检索:两种世界观

小王走到白板前,画了两个圈。

“稠密检索,就是把问题和文档都压成 1536 维的向量,算余弦相似度。它擅长模糊匹配、跨语言匹配、同义改写。你问‘K8s 里 PG 连接池怎么配’,它能把‘K8s’和‘Kubernetes’对应上,把‘配’和‘配置’对应上。”

老李点头:“这不挺好?”

“问题在于,它对每个词一视同仁。‘PostgreSQL’和‘Kubernetes’在向量空间里被平均化了,万一某篇文档大量提到‘连接池’但只是顺嘴提了一句‘Kubernetes’,稠密检索也可能把它排到前面——因为向量被‘连接池’这个高频词带偏了。”

小王在另一边画了另一个圈:“稀疏检索,尤其是 BM25,跟稠密检索相反。它不看语义,只看词频和逆文档频率。‘Kubernetes’这个词如果在整个知识库里很少见,那它在查询里的权重就极高。BM25 能精准揪出那些明确提到‘Kubernetes’的文档,而不会被‘连接池’分散注意力。”

python
# 稀疏检索示意:用 BM25 给文档打分
from rank_bm25 import BM25Okapi

corpus = [
    "PostgreSQL 连接池配置指南 适用于 Kubernetes 环境".split(),
    "连接池参数优化 max_connections 设置".split(),
    "Kubernetes 部署 PostgreSQL 最佳实践 包括连接池".split(),
]
bm25 = BM25Okapi(corpus)
query = "Kubernetes 里怎么配 PostgreSQL 连接池".split()
scores = bm25.get_scores(query)
for i, s in enumerate(scores):
    print(f"文档{i}: BM25={s:.2f}")
# 文档2(同时有 K8s 和连接池)得分会远高于文档1(只有连接池无 K8s)

“所以你昨天那个事故,”小王放下笔,“很有可能是稠密检索把一篇‘连接池通用配置’排到了前三,而真正讲‘K8s 里怎么配’的文档被挤到了第四。不是知识库没有答案,是你的检索策略太依赖语义相似度了。”

老李沉默了一会儿:“那你说怎么办?全用 BM25?那语义模糊的问题不就又回来了?”


混合检索:让两种世界观合作

“不用非此即彼,”小王在白板上画了一个漏斗,“混合检索就是同时跑稠密检索和稀疏检索,然后把两个结果融合。你可以把两份排名加权合并,或者取两份结果的交集——反正最后交给人看的,是两套系统的共识。”

python
def hybrid_search(query, dense_index, sparse_index, alpha=0.5):
    # 稠密检索:向量相似度
    dense_results = dense_index.search(query, top_k=20)
    # 稀疏检索:BM25 分数
    sparse_results = sparse_index.search(query, top_k=20)
    
    # 融合分数:加权求和
    scores = {}
    for doc_id, dense_score in dense_results.items():
        scores[doc_id] = scores.get(doc_id, 0) + alpha * dense_score
    for doc_id, sparse_score in sparse_results.items():
        scores[doc_id] = scores.get(doc_id, 0) + (1-alpha) * sparse_score
    
    # 按融合分数重排
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:10]

“alpha 控制权重。偏语义的任务调大 alpha,偏精确关键词的任务调小。比如法律条文检索,少一个词意思全变,那 BM25 的权重就得高;客服对话检索,用户用词千奇百怪,稠密检索的权重就要高。”

老李用笔敲着本子:“那每个查询都要调这个 alpha?”

“不用手动调。更高级的做法是多阶段检索。”


多阶段检索:先海选,再精排

“多阶段检索就像相亲里的‘海选加面谈’。”小王话锋一转,把相亲的比喻延续下来。

“第一个阶段,海选。你用极快的速度从几万份档案里挑出几十个候选人。这个阶段不要求精准,只要求快。BM25 和向量粗筛都可以,top_k 设大一点,比如 100。”

“第二个阶段,精排。你把这 100 个候选人拉来面谈。用更重的模型——比如交叉编码器,把问题和候选文档拼在一起,让模型直接打分。这个阶段慢,但精准,因为它不是靠向量近似,而是逐字逐句对比。”

python
# 多阶段检索示意:粗排 + 精排
from sentence_transformers import CrossEncoder

def two_stage_retrieval(query, vec_db, bm25_index):
    # 阶段一:粗排,召回 100 篇
    dense_candidates = vec_db.search(query, top_k=50)
    sparse_candidates = bm25_index.search(query, top_k=50)
    candidate_ids = list(set(dense_candidates) | set(sparse_candidates))
    
    # 阶段二:精排,用交叉编码器重打分
    model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
    pairs = [(query, get_doc_text(did)) for did in candidate_ids]
    scores = model.predict(pairs)
    
    # 取精排 top-3
    ranked = sorted(zip(candidate_ids, scores), key=lambda x: x[1], reverse=True)
    return [doc_id for doc_id, _ in ranked[:3]]

老李看着代码,像是第一次看到了检索的完整流水线。

图:多阶段检索——先用粗排快速筛出候选,再用精排选出最佳


不止是 top-k:那些容易忽略的调参细节

“算法我懂了,”老李把保温杯拧开喝了一口,“但你刚才说,我昨天事故的问题是只取 top-3。那如果我设成 top-10 呢?”

“然后大模型一次读十篇文档?Token 消耗飙涨,而且无关信息一多,模型更容易被干扰。所以除了 top-k,还有几个参数要一起调。”

小王掰着手指头数:“score threshold——设置一个最低相似度阈值,低于这个分直接丢弃,避免低分噪音混入。多样性控制——如果前三篇都是同一份文档的不同版本,取一篇就够了,剩两篇位置留给不同来源的内容。元数据过滤——用户问‘2025 年的年会’,你就应该在检索前先过滤掉 2024 年和 2026 年的文档,而不是靠相似度。”

老李表情严肃:“这听起来不像调个参数那么简单了,这得写一套检索策略框架。”

“对。所以做 RAG 项目,花时间最多的往往不是模型本身,而是检索策略的打磨。模型再聪明,检索给错材料,巧妇也难为无米之炊。”

老李靠着椅背,手里的保温杯转了好几圈。

“行吧。这次事故报告你来写,把混合检索和多阶段检索的方案一并列进去。”他站起来,走了两步又停下,转过身来,“你那相亲角的比喻……你真有去相亲角?”

“没有,我是网上看的。不过老李你要是有兴趣——”

“滚。”老李摆摆手,但嘴角分明有点翘,“下不为例啊。还有,你刚才说的那个交叉编码器,它跟向量检索在内部有什么区别?你下午到我办公室,给我画清楚。”

小王比了个 OK 的手势,看着老李走出会议室。保温杯里的枸杞晃晃悠悠,像是在点头。事故复盘会结束时已近中午,阳光透过百叶窗打在白板上的那几个圈上——稠密、稀疏、混合——仿佛三个齿轮,终于咬合在了一起。

混合搜索:当关键字和向量手拉手成为好朋友
分块的艺术:如何把文档切成 AI 爱吃的尺寸