生产RAG中的混合搜索与重排序

2026年05月15日 由 alex 发表 2903 0

稠密向量检索的固有问题


稠密检索的原理:将文本转换为高维向量,再找出向量在几何空间上与查询向量距离最近的文本块。如果两段文本上下文语义相近,它们的嵌入向量就会在向量空间中彼此靠近。


这套逻辑在概念类查询中表现很好,例如:“我们的事故升级处理流程是什么?”即便文档里用 “严重程度分级研判” 而非 “升级处理” 这类字眼,也能检索到相关文档。嵌入模型已经学习到这些概念的关联关系,余弦相似度可以精准捕捉这种语义关联。


但遇到专业技术术语时,问题就会暴露。工程师搜索「死信队列阈值配置」时,并不是在做概念提问,而是想找到包含精准专有术语的指定文档。可在嵌入过程中,“死信队列阈值” 这个精确术语,会被所在段落的其他上下文语义平均同化,最终压缩成单一稠密向量。这种均值化本身是一种取舍:稠密检索擅长概念语义匹配的特性,恰恰导致它无法可靠完成精准术语匹配。


支撑稠密检索的双编码器(Bi-encoders),会把一整个文本块的语义压缩为固定长度的单个向量。压缩过程必然会丢失信息。核心不在于要不要丢失信息,而在于可以舍弃哪些信息、必须保留哪些关键信息。


BM25:原理与作用


神经向量检索出现之前,搜索领域长期由词频统计类方法主导。BM25(Best Match 25,最佳匹配 25)是其中最经典的算法之一,广泛应用于 Elasticsearch、Solr、Weaviate 等绝大多数生产级搜索系统。


BM25 会从多个维度,计算文档与查询语句的相关度得分,核心由三部分构成:


逆文档频率(IDF)判断一个词条在整个语料库中的稀有程度。如果一个词条出现在绝大多数文档里,几乎没有区分相关性的价值;而 “死信队列阈值” 这类只在少数文档出现的词条,是极强的相关信号。IDF 会给稀有词条更高权重。


词频(TF)统计词条在当前单篇文档中出现的频次。BM25 做了优化,并非直接使用原始词频,而是引入饱和函数:得分初期随词频快速上升,后续逐渐趋于平缓,避免词条堆砌拉高得分。


文档长度归一化对长文档做扣分惩罚。篇幅越长,天然更容易命中更多词条;如果不做归一化,系统会优先返回长篇文档,不符合实际检索需求。


BM25 的局限:无法识别同义词、理解句式改写,也不能关联 “配置覆盖” 和 “自定义设置” 这类同义表述。它属于词袋模型,不关注词序与深层语义。句式「该配置会覆盖默认重试逻辑」和「可通过配置覆盖默认重试逻辑」,在 BM25 看来完全一致 —— 这既是它的优势,也是它的短板。


混合搜索:融合向量检索与 BM25


Weaviate 原生支持混合搜索,通过相对分数融合(Relative Score Fusion) 算法,将 BM25 关键词得分与稠密向量相似度得分合并,生成统一排序列表。

核心调节参数为 alpha:

alpha = 1:纯向量语义搜索

alpha = 0:纯 BM25 关键词检索

0~1 之间:两者加权融合,兼顾语义与精准术语匹配


from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.vector_stores import MetadataFilter, MetadataFilters
# Alpha of 0.5 = equal weight to keyword and semantic signals
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=10,
    vector_store_query_mode="hybrid",
    alpha=0.5,
    vector_store_kwargs={
        "filters": MetadataFilters(filters=[
            MetadataFilter(key="department", value="engineering")
        ])
    }
)


这部分还算简单,但确定该用多大的 alpha 值才是难点。


关键取决于你日常检索的精准度需求。如果大部分查询都是概念类问题(例如 “我们的事故处理流程是怎样的?”),把 alpha 设高一点,会更偏向语义匹配;如果查询多是精确术语查找(如《通用数据保护条例》第 17 条核查清单、重试策略死信队列阈值、X 服务服务等级协议),就把 alpha 调低,给关键词匹配更高权重。


实际落地中,不要靠猜,要用数据实测调优。我当时就是从公司 IT 服务工单历史里,抽取 150 组标注好的「查询 - 文档」样本 作为评估集,在自有语料上完成了 alpha 参数的调优。


import ragas
from ragas.metrics import ContextPrecision, ContextRecall
from datasets import Dataset
import numpy as np
def evaluate_alpha(alpha_value, eval_queries, ground_truth_docs):
    results = []
    for query, expected_doc_ids in zip(eval_queries, ground_truth_docs):
        # Update retriever with new alpha
        retriever.alpha = alpha_value
        retrieved_nodes = retriever.retrieve(query)
        retrieved_ids = [n.node.metadata.get("doc_id") for n in retrieved_nodes]
        hit = any(doc_id in retrieved_ids for doc_id in expected_doc_ids)
        rank = next(
            (i + 1 for i, doc_id in enumerate(retrieved_ids) if doc_id in expected_doc_ids),
            None
        )
        results.append({"hit": hit, "rank": rank})
 
    hit_rate = np.mean([r["hit"] for r in results])
    mrr = np.mean([1 / r["rank"] if r["rank"] else 0 for r in results])
    return {"alpha": alpha_value, "hit_rate": hit_rate, "mrr": mrr}
# Test across the range
alphas = [0.0, 0.25, 0.5, 0.75, 1.0]
results = [evaluate_alpha(a, eval_queries, ground_truth_docs) for a in alphas]
for r in results:
    print(f"Alpha: {r['alpha']:.2f} | Hit Rate: {r['hit_rate']:.3f} | MRR: {r['mrr']:.3f}")


在我们的工程知识库语料库上,测试结果如下(你实际得出的数据会有所不同):


Alpha: 0.00 | Hit Rate: 0.71 | MRR: 0.58    # Pure BM25
Alpha: 0.25 | Hit Rate: 0.80 | MRR: 0.66
Alpha: 0.50 | Hit Rate: 0.83 | MRR: 0.69    -> our sweet spot
Alpha: 0.75 | Hit Rate: 0.81 | MRR: 0.67
Alpha: 1.00 | Hit Rate: 0.73 | MRR: 0.61    # Pure dense


纯向量检索、纯 BM25 关键词检索这两种单一模式,表现明显不如任意融合配比的混合搜索。之前检索失败的死信队列相关查询,在 alpha=0.5 时,结果排名从第 11 位提升到了第 4 位;即便稠密向量检索仍对该文档语义判定模糊,BM25 关键词信号依然把它的排位拉了上来。


关于测试数据有个重要注意点:如果你的知识库大多是长篇叙述式文档,alpha 取值 0.65–0.75 效果通常更好;如果文档包含大量精确技术标识、错误码、产品名称,alpha 设为 0.35–0.5 会更合适。不存在通用万能的固定值,必须基于自己的业务数据实测调优。


补充判断技巧:若 alpha=0.0(纯 BM25) 的命中率远低于 alpha=1.0(纯稠密向量),说明你的语料词汇丰富、多句式同义改写,这种情况应偏向调高 alpha;反之,如果纯 BM25 命中率更高,说明用户习惯用精准专业术语检索,应调低 alpha;若两者命中率相近,可从 0.5 起步再逐步微调。


混合搜索无法解决的遗留问题


当你把检索到的 10 条文本块传给大模型时,模型会通读全部内容,但上下文窗口的注意力分配并不均匀。类似「中间信息丢失(lost in the middle)」等多项研究表明:大模型更关注输入开头和结尾的上下文,对藏在中间位置的信息识别可靠性很低。如果最相关的文本块排在 10 条里的第 8 位,就只能寄希望于模型从注意力薄弱的中段区域自行抓取关键信息,风险很高。


交叉编码器:原理与优势


双编码器(Bi-encoder) 会完全独立处理查询语句和文档文本,计算相似度时,双方的向量彼此没有交互感知。


而交叉编码器(Cross-encoder) 做法完全不同:它将一条查询 + 单篇文档拼接成同一个输入序列,送入 Transformer 模型联合编码。模型每一层注意力机制都能实现查询词条与文档词条相互交叉注意力匹配,在输出相关性得分前,完整理解查询与文档之间的语义关联。


两者的识别能力差距非常明显。举个例子:查询:支付服务的重试次数上限是多少?候选文本块 A:重试上限因服务类型而异。大多数内部服务默认重试 3 次,并采用指数退避策略。候选文本块 B:支付服务消费者配置为最多重试 5 次,超出后消息将被路由至死信队列。


双编码器可能会把文本块 A 排在前面,只因 “重试次数上限” 和 “重试上限随服务变化” 语义相近。而交叉编码器会同时比对两段内容,立刻识别出文本块 B 包含查询指定服务的具体数值;查询里的「支付服务」与文档里的「支付服务消费者」形成交叉注意力匹配,直接判定 B 才是正确结果。


这也是交叉编码器在排序任务上精度远高于双编码器的核心原因。但它有明显局限:无法预计算。双编码器可以在索引阶段提前把所有文档生成向量,检索时只需生成查询向量,仅两步操作;而交叉编码器必须在检索时逐组处理「查询 - 文档」配对。如果面对百万级文档,就要执行百万次模型前向推理,不可能直接对全量语料使用。


行业标准解法是两阶段漏斗架构:先用双编码器大范围初筛,快速召回 Top-N 候选结果;再用交叉编码器,仅对这 N 条候选做精准重打分、重排序。


实际落地表现:在 CPU 环境使用轻量版交叉编码器,对 20 条结果做重排序,仅增加约 80–120 毫秒查询延迟,完全可接受。


重排序功能落地实现


我们采用 sentence-transformers 库中的 ms-marco-MiniLM-L-6-v2 模型。该模型基于大规模问答数据集 MS MARCO 训练,是通用检索场景中使用最广泛的开源交叉编码器。如果是垂直领域专属业务内容,可以用自己标注的「查询 - 文档」样本做微调;即便直接使用通用预训练模型,也能满足初期落地需求。


from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank_nodes(query: str, retrieved_nodes: list, top_n: int = 5) -> list:
    """
    Takes query and a list of LlamaIndex NodeWithScore objects,
    returns top_n nodes reranked by cross-encoder score.
    """
    # Build (query, chunk_text) pairs for the cross-encoder
    pairs = [(query, node.node.get_content()) for node in retrieved_nodes]
 
    # Score all pairs and returns a list of floats
    scores = reranker.predict(pairs)
 
    # Attach scores to nodes and sort
    for node, score in zip(retrieved_nodes, scores):
        node.score = float(score)
 
    reranked = sorted(retrieved_nodes, key=lambda n: n.score, reverse=True)
    return reranked[:top_n]

# Full retrieval + re-ranking pipeline
query = "What is the retry limit for the payment service dead-letter queue?"
# Stage 1: retrieve more than you need (20 candidates)
retrieved = retriever.retrieve(query)   # top_k=20 in retriever config
# Stage 2: re-rank down to 5
reranked = rerank_nodes(query, retrieved, top_n=5)
# Inspect what happened to document ranks
print("After re-ranking:")
for i, node in enumerate(reranked):
    source = node.node.metadata.get("source", "unknown")
    print(f"  Rank {i+1} | Score: {node.score:.4f} | Source: {source}")


LlamaIndex 同样提供原生的 SentenceTransformerRerank 后处理器,可以无缝集成到它的查询链路流水线中。


from llama_index.postprocessor.sbert_rerank import SentenceTransformerRerank
from llama_index.core import QueryBundle
from llama_index.core.query_engine import RetrieverQueryEngine
reranker_postprocessor = SentenceTransformerRerank(
    model="cross-encoder/ms-marco-MiniLM-L-6-v2",
    top_n=5
)
query_engine = RetrieverQueryEngine.from_args(
    retriever=retriever,
    node_postprocessors=[reranker_postprocessor]
)
response = query_engine.query(
    "What is the retry limit for the payment service dead-letter queue?"
)


这里的 top_n=5 用于告诉重排序模块,选取多少条文档送入生成环节。调大该数值会给大模型提供更多上下文,但同时会增加检索延迟,也会提升冗余干扰信息混入的风险。在我们的系统中,5 是最佳平衡点:既能满足多环节复杂问题的上下文需求,又不会让提示词被无关内容挤占干扰。


注意:抽样一批查询,记录原始检索排序与重排序后排序之间的秩相关系数。如果交叉编码器几乎不改变原有排序,说明两种情况之一:要么你的双编码器检索效果已经足够优秀,重排序带来的收益微乎其微;要么你选用的交叉编码器模型过于通用,并不适配自身业务领域。


效果量化评估


我们将三组方案放在一起对比:纯稠密向量检索(作为基准线)、混合搜索、混合搜索 + 重排序。我使用包含 150 条查询的评估集,分别在这三种配置下完成了全量测试。


from ragas import evaluate
from ragas.metrics import (
    ContextPrecision,
    ContextRecall,
    AnswerRelevancy,
    Faithfulness
)
from datasets import Dataset
def build_ragas_dataset(queries, retrieved_contexts, ground_truths, generated_answers):
    return Dataset.from_dict({
        "question": queries,
        "contexts": retrieved_contexts,    # list of lists of strings
        "answer": generated_answers,
        "ground_truth": ground_truths
    })
# Build datasets for each configuration, then evaluate
baseline_dataset = build_ragas_dataset(
    queries, baseline_contexts, ground_truths, baseline_answers
)
hybrid_dataset = build_ragas_dataset(
    queries, hybrid_contexts, ground_truths, hybrid_answers
)
hybrid_rerank_dataset = build_ragas_dataset(
    queries, hybrid_rerank_contexts, ground_truths, hybrid_rerank_answers
)
metrics = [ContextPrecision(), ContextRecall(), AnswerRelevancy(), Faithfulness()]
baseline_result = evaluate(baseline_dataset, metrics=metrics)
hybrid_result = evaluate(hybrid_dataset, metrics=metrics)
hybrid_rerank_result = evaluate(hybrid_rerank_dataset, metrics=metrics)


我们在工程知识库语料库上的测试结果如下(均为内部评估得出的真实数据):


屏幕截图2026-05-15094031


有几点值得注意:


从稠密检索升级到混合搜索后,上下文召回率大幅提升(从 0.74 升至 0.83),而再叠加重排序后几乎没有变化(0.84)。原因在于:召回率只衡量是否检索到了正确文档。而重排序是在已召回的结果集合内部做调整,无法提升召回率。混合搜索之所以能拉高召回率,是依靠 BM25 组件命中了那些被稠密模型排得过于靠后的精准专业术语文档。


叠加重排序后,上下文精确率显著跃升(从 0.71 升至 0.79)。精确率衡量检索出来的文本块中,真正相关的占比有多大。重排序恰好起到了应有作用:把无关内容从送入生成环节的Top 5结果里剔除出去。


答案相关性与事实忠实度在每一轮优化阶段都稳步提升。这两项属于端到端评估指标,体现了检索质量持续优化,最终层层传导、让生成效果也同步变好。


元数据过滤


元数据过滤可以在执行向量搜索之前先缩小检索范围。例如工程部门员工询问部署流程时,可以在开始嵌入向量比对之前,就把检索范围限定在工程类文档。这样不仅检索速度更快,候选结果池也更精简、相关性更高,同时让 BM25 打分和稠密向量相似度计算都变得更加精准。


from llama_index.core.vector_stores import (
    MetadataFilter,
    MetadataFilters,
    FilterOperator,
    FilterCondition
)
# Apply filters based on user context
def build_retriever_with_filters(
    index,
    user_department: str,
    max_doc_age_days: int = 365,
    classification_level: str = "internal"
):
    from datetime import datetime, timedelta
 
    cutoff_date = (datetime.now() - timedelta(days=max_doc_age_days)).isoformat()
 
    filters = MetadataFilters(
        filters=[
            MetadataFilter(
                key="department",
                value=user_department,
                operator=FilterOperator.EQ
            ),
            MetadataFilter(
                key="updated_at",
                value=cutoff_date,
                operator=FilterOperator.GT
            ),
            MetadataFilter(
                key="classification",
                value="confidential",
                operator=FilterOperator.NE  # Exclude confidential unless authorised
            ),
        ],
        condition=FilterCondition.AND
    )
 
    return VectorIndexRetriever(
        index=index,
        similarity_top_k=20,
        vector_store_query_mode="hybrid",
        alpha=0.5,
        vector_store_kwargs={"filters": filters}
    )


一份18 个月前就已下线废弃的服务运维手册,不仅毫无参考价值,一旦系统把它当作当前基础设施问题的标准答案给出,还会带来潜在风险。这种场景下,通过更新时间(updated_at) 做过滤,可以有效屏蔽老旧过期信息。


需要留意一种失效风险:如果过滤条件设置过于严苛,把真正包含答案的文档也排除在外,模型就只会基于剩余文档,一本正经地给出错误回答。正确做法是:先采用合理的默认过滤规则(如按部门过滤、适当时间范围过滤);只有经过真实查询验证、确认确实有效后,再针对特定业务场景增设更严格的过滤条件。


完整链路流程


以下三大组件在检索流程中的完整配合逻辑(演示版):


from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.postprocessor.sbert_rerank import SentenceTransformerRerank
from llama_index.core.response_synthesizers import get_response_synthesizer
from llama_index.llms.ollama import Ollama
# LLM (local, via Ollama)
llm = Ollama(model="llama3", request_timeout=120.0)
# Stage 1: Hybrid retriever with metadata filters
retriever = build_retriever_with_filters(
    index=index,
    user_department="engineering",
    max_doc_age_days=365
)
# Stage 2: Cross-encoder re-ranker
reranker = SentenceTransformerRerank(
    model="cross-encoder/ms-marco-MiniLM-L-6-v2",
    top_n=5
)
# Stage 3: Response synthesizer
synthesizer = get_response_synthesizer(
    llm=llm,
    response_mode="compact",  # Merges multiple chunks into one prompt
    use_async=True
)
# Assemble the query engine
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    node_postprocessors=[reranker],
    response_synthesizer=synthesizer
)
# Query it
response = query_engine.query(
    "What is the retry limit for the payment service dead-letter queue?"
)
print(response.response)
# Source attribution: important for enterprise use cases
for node in response.source_nodes:
    print(f"  Source: {node.node.metadata.get('source')} | Score: {node.score:.4f}")


有一处需要注意配置项:response_mode="compact"。该模式会把多条检索到的文本块合并为一次提示词调用,而不是每条文本块都单独调用一次大模型。对于 5 条文本块的场景,能大幅降低响应延迟,同时合理控制上下文窗口占用。如果使用上下文长度偏小的模型,或是文本块篇幅较长,可以改用 response_mode="tree_summarise" 树形摘要模式,采用分阶段递进处理。


完成以上全部优化并上线生产环境后,我们的内部知识助手终于能够准确回答关于消息队列消费者重试策略的问题。


关于 RAGAS 的最后补充


RAGAS 评分不能等同于产品质量指标,它只是一种诊断分析工具。上下文精确率 0.79,只能说明:平均而言,传给大模型的检索内容里有 79% 是相关的;并不代表 79% 的用户都能得到正确答案。


RAGAS 的真正价值,在于衡量版本优化带来的实际效果。引入混合搜索后,上下文召回率有没有上升?新增重排序后,上下文精确率是否提升、同时没有拖累召回率?这类对比问题,RAGAS 可以给出可靠结论。每次调整检索链路配置时,都用 RAGAS 做优化前后对照;并随着知识库语料迭代,长期跟踪指标变化。




文章来源:https://towardsdatascience.com/hybrid-search-and-re-ranking-in-production-rag/
欢迎关注ATYUN官方公众号
商务合作及内容投稿请联系邮箱:bd@atyun.com
评论 登录
热门职位
Maluuba
20000~40000/月
Cisco
25000~30000/月 深圳市
PilotAILabs
30000~60000/年 深圳市
写评论取消
回复取消