006、检索篇:相似度算法、混合检索与重排序(Rerank)技术详解


从一次深夜调试说起

线上问答系统的召回结果突然出现严重偏差。用户问“如何配置Redis集群”,系统返回的却是“Redis单机安装教程”和“MySQL主从配置指南”。打开监控一看,Embedding相似度分数都在0.85以上——从数值看似乎很匹配,但实际业务场景中,这种“表面相似但实质偏离”的问题正在伤害用户体验。

这个问题暴露了单纯依赖向量相似度的脆弱性。今晚我们就深入聊聊:当相似度算法遇上真实业务时,如何通过混合检索与重排序构建更健壮的召回系统。

相似度算法:不只是点积那么简单

很多人一提到向量相似度就只想到余弦相似度,其实这里有个选择陷阱。不同的相似度计算方式,在特定场景下表现差异巨大。

# 常见相似度计算实现(注释是踩坑记录)
def calculate_similarity(vec1, vec2, method='cosine'):
    """
    vec1, vec2: 归一化后的向量
    method: cosine/dot_product/euclidean
    """
    if method == 'cosine':
        # 最常用的方式,对向量长度不敏感
        # 适合文本语义匹配,但注意输入向量必须归一化!
        return np.dot(vec1, vec2)  # 已经归一化所以等价于余弦
    
    elif method == 'dot_product':
        # 点积:向量长度会影响分数
        # 在BM25+Embedding混合场景有用,可以体现“重要性权重”
        # 但别单独用,容易受embedding模长影响出问题
        return np.dot(vec1, vec2)
    
    elif method == 'euclidean':
        # 欧氏距离:越小越相似
        # 需要转换成相似度分数时:1/(1+distance)
        # 在视觉搜索里更常见,文本场景慎用
        dist = np.linalg.norm(vec1 - vec2)
        return 1 / (1 + dist)  # 转换到0-1范围
    
    # 生产环境加个兜底
    raise ValueError(f"Unknown method: {method}")

实际项目中,我习惯用余弦相似度打底,但在以下情况会调整策略:

  • 当文档长度差异大且长度信息重要时,点积可能更合适
  • 做A/B测试时发现某些query类型在特定相似度下表现更好
  • 混合检索时,不同检索路径可能需要不同的相似度计算

混合检索:让多路召回协同作战

单一检索方式总有局限。混合检索的核心思想是:让不同检索器各司其职,然后融合结果。

class HybridRetriever:
    def __init__(self):
        self.vector_retriever = VectorRetriever()  # 向量检索
        self.keyword_retriever = KeywordRetriever()  # 关键词检索(如BM25)
        self.sparse_retriever = SparseRetriever()   # 稀疏向量检索
        
    def hybrid_search(self, query, top_k=10):
        # 并行多路召回(生产环境建议用异步)
        vector_results = self.vector_retriever.search(query, top_k*2)
        keyword_results = self.keyword_retriever.search(query, top_k*2)
        
        # 分数归一化:关键步骤!
        # 不同检索器的分数尺度不同,必须归一化到同一量纲
        vector_results = self._normalize_scores(vector_results)
        keyword_results = self._normalize_scores(keyword_results)
        
        # 融合策略:加权平均
        fused_results = {}
        for doc_id, score in vector_results.items():
            fused_results[doc_id] = score * 0.7  # 向量权重
            
        for doc_id, score in keyword_results.items():
            if doc_id in fused_results:
                fused_results[doc_id] += score * 0.3
            else:
                fused_results[doc_id] = score * 0.3
        
        # 按最终分数排序
        sorted_results = sorted(fused_results.items(), 
                               key=lambda x: x[1], reverse=True)
        return sorted_results[:top_k]
    
    def _normalize_scores(self, results):
        """Min-Max归一化,简单但有效"""
        if not results:
            return {}
        scores = [s for _, s in results]
        min_s, max_s = min(scores), max(scores)
        if max_s == min_s:
            return {doc_id: 1.0 for doc_id, _ in results}
        
        return {
            doc_id: (score - min_s) / (max_s - min_s)
            for doc_id, score in results
        }

混合比例需要根据业务调整。我们的经验是:技术文档场景,向量权重高些(0.6-0.8);FAQ问答场景,关键词权重可以适当提高。

重排序(Rerank):最后的精调关口

召回阶段追求“全面”,重排序阶段追求“精准”。这是两个不同的优化目标。

class Reranker:
    def __init__(self):
        # 小型交叉编码器,比双塔模型更精准但更慢
        self.cross_encoder = load_cross_encoder()
        
        # 业务规则处理器(硬约束)
        self.rule_engine = RuleEngine()
        
    def rerank(self, query, candidates):
        """
        candidates: [(doc_id, text, raw_score), ...]
        返回重排序后的列表
        """
        reranked = []
        
        for doc_id, text, raw_score in candidates:
            # 1. 交叉编码器计算精细相关度
            ce_score = self.cross_encoder.predict([[query, text]])
            
            # 2. 业务规则调整(比如时效性、权威性)
            rule_adjust = self.rule_engine.apply_rules(query, text)
            
            # 3. 综合打分(这个公式调了两个月...)
            final_score = (
                ce_score * 0.6 + 
                raw_score * 0.2 + 
                rule_adjust * 0.2
            )
            
            reranked.append({
                'doc_id': doc_id,
                'text': text,
                'score': final_score,
                'components': {  # 保留各维度分数,调试用
                    'ce': ce_score,
                    'raw': raw_score,
                    'rule': rule_adjust
                }
            })
        
        # 按最终分数排序
        reranked.sort(key=lambda x: x['score'], reverse=True)
        return reranked

重排序模型的选择很关键。我们的实践路径:

  1. 初期用简单规则(如关键词匹配度加分)
  2. 中期上轻量级交叉编码器(如MiniLM)
  3. 后期针对业务微调专属rerank模型

工程落地时的几个深坑

坑1:分数分布不一致
多路召回的分数分布可能完全不同。BM25分数可能几百上千,余弦相似度在0-1之间。直接加权平均会出大问题。必须先归一化,或者用排名而非绝对分数。

坑2:召回多样性丢失
过度追求精度可能导致结果同质化。我们加了个“多样性惩罚”因子:对内容过于相似的文档降权,确保结果覆盖不同角度。

坑3:延迟累积
混合检索+重排序的延迟是各阶段之和。我们的优化方案:

  • 向量检索用FAISS量化索引
  • 关键词检索用倒排索引+缓存
  • 重排序只处理前50个候选(而不是全部召回结果)
  • 用户无感知时可以做预检索

坑4:评估指标错配
离线评估用MRR@10,线上看点击率,两者可能不一致。我们建立了A/B测试框架,任何策略上线必须通过线上实验。

个人经验建议

  1. 从简单开始:先做好单一检索,再加混合,最后上重排序。别想一步到位。

  2. 监控分数分布:每天监控各阶段分数分布变化。突然的分布偏移往往意味着问题。

  3. 保留可解释性:重排序时保留各维度分数,线上问题排查时能救命。

  4. 业务规则谨慎用:规则容易固化思维。我们曾规定“包含用户query中所有关键词的文档优先”,结果发现很多优质文档因为同义词替换被降权。

  5. 用户行为反哺:点击、停留、负反馈都是天然标注数据。我们构建了持续学习闭环:用户行为→模型微调→线上更新。

  6. 资源分配权衡:把更多计算资源放在头部候选。我们现在的比例是:召回1000→粗排100→精排10→返回3。

那个凌晨的问题,最终是通过调整混合权重+增加“配置”关键词权重解决的。但更根本的解决方案是:在重排序阶段加入“意图一致性检测”,用小型分类器判断文档是否真的在回答用户意图。

检索系统就像老中医开方,需要多种药材(算法)配伍,而且得根据病人(业务场景)随时调整。没有银弹,只有持续观察、实验和调整。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐