跳转至

混合检索与重排序

混合检索是当前 RAG 系统中提升检索质量的关键手段。核心原因在于,向量检索和关键词检索是两种互补的检索范式,各自有明确的盲区,单独使用难以覆盖所有查询场景。

🔍 向量检索(语义检索)的盲区

  • 对专有名词、缩写、特定编号不敏感:如产品型号 "XPS-15-9520",向量模型可能将其当作普通文本处理,无法精确匹配。而用户输入 "9520" 时,向量可能检索出其他相似数字的文档,但漏掉准确的型号。

  • 对低频或新词知识缺失:如果嵌入模型在训练时没见过某个新出现的术语(如最新的病毒名称),它无法捕捉其语义,检索效果会很差。

  • 对精确短语匹配要求高的场景乏力:比如法律条款、代码函数名、特定口号,向量检索可能返回意思相近但表述不同的内容,但用户要的是精确原文。

  • 对“否定”或“排除”含义理解弱:例如“不含咖啡因的饮料”,向量可能把“含咖啡因”和“不含”的文档都当作相似,因为它们在语义上都围绕咖啡因。

📚 关键词检索(如 BM25)的盲区

  • 无法理解同义词和语义变形:搜索“廉价手机”不会匹配“性价比高的智能机”,因为没出现相同的词。这是向量检索的强项。

  • 无法处理复杂的上下文:例如“如何处理压力?”,BM25 只会寻找包含“处理”和“压力”的文档,但可能一篇讲述“放松技巧”的文章因为没有这两个词而排不上号。

  • 无法跨语言检索:输入“apple”无法匹配到“苹果”。

  • 无法理解意图:关键词匹配纯粹基于词频和逆文档频率,不关心用户在找什么类型的答案。

⚡ 为什么混合检索能取长补短?

混合检索将两者结合,可以让结果既包含语义相关的文档,又包含精确命中关键词的文档。例如在医疗文献检索中,用户搜索“COVID-19 疫苗副作用”,向量检索能找到讨论“新冠疫苗不良反应”的论文,关键词检索则能精确锁定包含“COVID-19”和“副作用”这两个词的具体段落,两者互补,极大提高召回率和精确率。

在 LangChain 中实现混合检索,可以视为一种多路召回+融合排序的策略。


🛠️ 在 LangChain 中,如何实现向量检索 + BM25 的混合检索?写出核心思路和用到的组件。

在 LangChain 中,没有现成的“HybridRetriever”类,但可以通过组合现有组件和少量自定义代码轻松实现。

核心思路:

  1. 构建两个独立的检索器:一个 VectorStoreRetriever(基于向量),一个自定义的 BM25Retriever(基于关键词)。

  2. 使用 EnsembleRetriever 将它们组合,并指定融合算法(默认 RRF)。

  3. (可选)在融合之后,加入重排序步骤,进一步提升相关性。

涉及的核心组件:

  • langchain_community.retrievers.BM25Retriever:LangChain 社区版已经提供了 BM25 的实现,可以直接使用。

  • langchain.vectorstores 中的任意 VectorStore,如 Chroma,通过 as_retriever() 获取检索器。

  • langchain.retrievers.EnsembleRetriever:用于合并多个检索器的结果。

完整代码示例:

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

# 1. 准备文档列表(原始文档对象)
documents = [ ... ]  # LangChain Document 列表

# 2. 创建 BM25 检索器
bm25_retriever = BM25Retriever.from_documents(documents)
bm25_retriever.k = 5   # 返回 top-5

# 3. 创建向量检索器
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(documents, embeddings, collection_name="my_kb")
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# 4. 组合成混合检索器
hybrid_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5]  # 权重各半(在 RRF 中体现为加权)
)

当调用 hybrid_retriever.get_relevant_documents(query) 时,它会并行调用 BM25 和向量检索器,各取 5 个结果,然后用 RRF 算法融合排序,返回最终列表。

💡 优化点:

  • 可以调整各自返回的数量 k,向量检索可以稍多(比如10),以发挥其覆盖广的优势,然后通过融合缩小。

  • 可以自定义 EnsembleRetriever 的融合逻辑,比如当用户查询包含明确数字或专有名词时,动态增加 BM25 的权重。


🤖 你自己封装过 BM25Retriever 吗?如何与现有的 VectorStoreRetriever 结合?

在 LangChain 的早期版本中,没有内置的 BM25Retriever,需要自己封装。现在的社区版已经提供,但了解其内部原理有助于自定义。

自封装 BM25Retriever 的核心实现:

from rank_bm25 import BM25Okapi
from langchain.schema import BaseRetriever, Document
from typing import List

class MyBM25Retriever(BaseRetriever):
    documents: List[Document]
    bm25_index: BM25Okapi
    k: int = 4

    def __init__(self, documents: List[Document], k=4):
        super().__init__(documents=documents, k=k)
        # 对文档内容分词(中文可用jieba)
        tokenized_corpus = [doc.page_content.split() for doc in documents]
        self.bm25_index = BM25Okapi(tokenized_corpus)

    def _get_relevant_documents(self, query: str) -> List[Document]:
        tokenized_query = query.split()
        scores = self.bm25_index.get_scores(tokenized_query)
        best_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:self.k]
        return [self.documents[i] for i in best_indices]

对于中文,需要用 jieba 分词。

与 VectorStoreRetriever 结合的方式: 如前所述,直接使用 EnsembleRetriever,将自定义的 MyBM25Retrievervectorstore.as_retriever() 传入即可。EnsembleRetriever 会自动处理并行调用和结果融合。

更深度的结合:

不仅仅是结果层面的融合,还可以将 BM25 的分数与向量相似度分数在文档级别进行线性加权:

def _get_relevant_documents(self, query):
    vec_docs = self.vector_retriever.get_relevant_documents(query)  # 假设带分数
    bm25_docs = self.bm25_retriever.get_relevant_documents(query)  # 带分数
    combined_scores = {}
    # 对两个检索器的结果按权重累加分数
    for doc, score in vec_docs: combined_scores[doc.page_content] = 0.7 * score
    for doc, score in bm25_docs: combined_scores[doc.page_content] = combined_scores.get(doc.page_content, 0) + 0.3 * score
    sorted_docs = sorted(combined_scores.items(), key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in sorted_docs]

这需要检索器能返回原始的相似度分数(向量检索可通过 similarity_search_with_score 实现,BM25 直接有 get_scores)。这种方式比 RRF 更直接,但需要分数尺度对齐。

实践中的坑:

  • BM25 对中文的分词依赖严重,分词不好效果差。

  • 如果文档库非常大,每次重新构建 BM25 索引不现实,需要将 BM25Okapi 对象序列化或使用专门的搜索引擎(如 Elasticsearch)。

  • 动态增加文档时,需要更新索引,自实现的 BM25 更新代价高,应使用支持增量索引的库。


🔗 RRF(倒数排名融合)在混合检索中是怎么算的?它有什么优势?

RRF (Reciprocal Rank Fusion) 是一种经典的融合多个排序列表的算法,特别适合不同检索器分数尺度不一致的场景。其计算公式为:

image.png

image.png

LangChain 的 EnsembleRetriever 正是使用加权 RRF 进行融合。你可以通过 c 参数调整平滑常数。

使用方式:

EnsembleRetriever(retrievers=[retriever1, retriever2], weights=[0.6, 0.4], c=60)

📊 除了 RRF,还有哪些分数融合方式?LangChain 中如何自定义融合逻辑?

除了 RRF,常见的分数融合方式包括:

  • 线性加权(Linear Combination):归一化各检索器的分数后,按权重线性相加。需要分数具有可比性,因此必须做 Min-Max 或 Z-score 归一化。

  • CombSUM / CombMNZ:信息检索领域的经典融合算法。CombSUM 直接对各检索器的分数求和;CombMNZ 在求和的基础上乘以“命中该文档的检索器数量”,鼓励多个检索器都认可的文档。

  • 基于概率的融合:例如使用逻辑回归模型,以各检索器的分数作为特征,学习一个融合函数。

  • 基于排序的投票:如 Borda Count,按排名给分。

LangChain 中自定义融合逻辑: 可以通过子类化 EnsembleRetriever 并重写 _get_relevant_documentsweighted_reciprocal_rank 方法来实现。下面是一个自定义线性加权的例子:

from langchain.retrievers import EnsembleRetriever
from sklearn.preprocessing import MinMaxScaler

class LinearEnsembleRetriever(EnsembleRetriever):
    def _get_relevant_documents(self, query):
        # 假设每个检索器都返回带有分数的文档(需要检索器支持)
        all_docs = []
        for retriever in self.retrievers:
            docs = retriever.get_relevant_documents(query)  # 需修改为返回分数
            all_docs.append(docs)
        # 融合逻辑
        doc_scores = {}
        for docs, weight in zip(all_docs, self.weights):
            scores = [doc.metadata.get("score", 0) for doc in docs]
            # 归一化
            scaler = MinMaxScaler()
            norm_scores = scaler.fit_transform([[s] for s in scores]).flatten()
            for doc, score in zip(docs, norm_scores):
                doc_scores[doc.page_content] = doc_scores.get(doc.page_content, 0) + weight * score
        sorted_items = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
        return [self._doc_map[key] for key, _ in sorted_items]

更简单的是,在 EnsembleRetriever 基础上,封装一个 _combine_results 方法替换。但最灵活的方式是不依赖 EnsembleRetriever,自己写一个完全自定义的 HybridRetriever 类,直接实现自己的融合策略。


⚖️ 如何在混合检索中平衡向量检索和关键词检索的权重?有无动态调整策略?

平衡权重的策略分为静态和动态两类。

静态权重策略:

  • 根据经验在开发集上调试,例如向量 0.7、关键词 0.3。

  • 可以通过在评估集上搜索不同权重组合,找到最佳平衡点(自动化调参,如网格搜索)。

动态权重策略(更智能):

根据查询的特征自动调整权重,让系统自适应。例如:

  • 基于查询长度:短查询(2-3个词)往往缺乏足够上下文,关键词匹配更重要,可自动增加 BM25 权重;长句查询则更依赖语义,增加向量权重。

  • 基于实体检测:如果查询中包含专有名词、数字、代码片段(用正则或 NER 模型识别),则动态提高 BM25 权重,因为这些需要精确匹配。

  • 基于查询清晰度:使用一个简单分类器(或 LLM)判断查询是“事实型”(如“iPhone 15 价格”)还是“概念型”(如“什么是量子计算”)。事实型问题通常有明确关键词,提高 BM25;概念型问题提高向量。

  • 基于检索结果的置信度反馈:先分别执行两种检索,如果 BM25 返回的文档与查询的语义相似度普遍较低(用 embedding 计算平均相似度),说明关键词检索质量不高,可以降低其权重。反之亦然。

  • 强化学习:通过用户点击反馈,在线调整权重,但实现复杂。

LangChain 中的实现思路: 自定义 HybridRetriever,在 _get_relevant_documents 方法中,根据上述规则动态设置 self.weights 或调用不同的融合函数。

class AdaptiveHybridRetriever(BaseRetriever):
    def __init__(self, bm25_retriever, vector_retriever):
        self.bm25 = bm25_retriever
        self.vector = vector_retriever
    def _get_relevant_documents(self, query):
        # 规则示例:如果查询包含数字,增加BM25权重
        has_number = any(char.isdigit() for char in query)
        w_bm25 = 0.6 if has_number else 0.3
        w_vector = 0.4 if has_number else 0.7
        # 构造 EnsembleRetriever 动态执行
        ensemble = EnsembleRetriever(
            retrievers=[self.bm25, self.vector],
            weights=[w_bm25, w_vector]
        )
        return ensemble.get_relevant_documents(query)

动态调整权重的核心在于理解查询意图,不一定需要重型模型,简单的规则就能显著提升效果。


🎯 CohereRerank 在 LangChain 中怎么用?它和普通重排序模型相比,强在哪?

Cohere 的 Rerank 服务是一个专门为重排序设计的 API。LangChain 通过 CohereRerank 压缩器封装了它,可以作为 ContextualCompressionRetriever 的压缩器使用。

使用方法:

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CohereRerank
from langchain.vectorstores import Chroma

# 假设已有 base_retriever
retriever = Chroma(...).as_retriever(search_kwargs={"k": 20})  # 多捞一些

# 创建 Cohere Rerank 压缩器
compressor = CohereRerank(cohere_api_key="your_key", model="rerank-english-v2.0", top_n=5)

# 构建压缩检索器
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=retriever
)

docs = compression_retriever.get_relevant_documents("what is quantum computing?")
# 返回的 docs 已经被重排序,只保留 top_n=5

Cohere Rerank 内部会调用 Cohere 的 API,将每个文档与查询进行匹配打分,然后按分数排序并截断。

与普通重排序模型相比,Cohere Rerank 强在哪?

  • 高性能的交叉编码器:Cohere 的 rerank 模型基于大规模预训练和微调,其交叉编码器结构能够同时处理查询和文档,捕捉细粒度语义关系,重排序质量通常优于基于 embedding 的双塔模型。

  • 多语言支持:rerank-english-v2.0 支持英语,而 rerank-multilingual-v2.0 支持多语言。

  • 服务端托管,无需自部署:省去了模型部署和运维的麻烦,延迟稳定。

  • 专为长文档设计:它可以处理较长的文档(如 4k token),并输出精确的相关性分数。

不过,它是付费 API,且有数据隐私风险(数据离开你的服务器)。但它提供了极佳的开箱即用体验。


🔓 如果你没有预算使用 Cohere,如何用开源模型(如 bge-reranker)在 LangChain 中实现重排序?

你可以通过自定义压缩器,将任何开源交叉编码器模型封装进 LangChain。这里以 bge-reranker-base(BAAI 发布)为例。

步骤:

  1. 安装 sentence-transformers 或其他推理库。

  2. 创建自定义的 BaseDocumentCompressor 子类,在 compress_documents 中使用模型对文档打分,排序,返回 top-k。

代码实现:

from langchain.retrievers.document_compressors import BaseDocumentCompressor
from langchain.schema import Document
from sentence_transformers import CrossEncoder
from typing import Sequence

class BgeRerankCompressor(BaseDocumentCompressor):
    model_name: str = "BAAI/bge-reranker-base"
    top_n: int = 5
    _model: CrossEncoder = None

    def __init__(self, model_name=None, top_n=5):
        super().__init__(model_name=model_name or self.model_name, top_n=top_n)
        self._model = CrossEncoder(self.model_name, max_length=512)

    def compress_documents(self, documents: Sequence[Document], query: str) -> Sequence[Document]:
        if len(documents) == 0:
            return []
        # 构建 [query, doc] 对
        pairs = [[query, doc.page_content] for doc in documents]
        scores = self._model.predict(pairs)
        # 将分数附加到 metadata 中,并排序
        for i, doc in enumerate(documents):
            doc.metadata["rerank_score"] = float(scores[i])
        sorted_docs = sorted(documents, key=lambda d: d.metadata["rerank_score"], reverse=True)
        return sorted_docs[:self.top_n]

使用:

# 假设已经有一个基础检索器 base_retriever(例如从向量库拿到的 20 个文档)
compressor = BgeRerankCompressor(top_n=5)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=base_retriever
)
docs = compression_retriever.get_relevant_documents(query)

注意事项:

  • 交叉编码器每次只能处理一对,对大量文档(如50个)进行重排序会比较慢,但比调用在线 API 有延迟优势。

  • 可以通过微调 bge-reranker 更好地适配你的领域。

  • 需要 GPU 加速批量推理,否则会很慢。

  • 确保 max_length 适应你的文档长度,超出部分会被截断。


📌 重排序模型一般放在检索管道的什么位置?是在混合检索之后吗?

标准位置:在初步检索(和混合检索融合)之后,最终生成答案之前。

更具体地说,典型的多阶段检索管道是:

用户查询
→ 多路检索(向量 + 关键词等)
→ 结果融合(RRF 等)获得一个较大的候选文档集合(例如50-100个文档)
→ 重排序模型(Cross-Encoder)对这些候选文档进行精细打分排序
→ 截断为 top-k(例如 5 个)作为最终上下文
→ 送入 LLM 生成答案

为什么放在融合之后?

  • 重排序模型通常计算量较大(如交叉编码器),直接对全量文档(百万级)排序不现实。前面的检索阶段用快速但粗略的方法(embedding 点积、BM25)大幅缩小候选集。

  • 混合检索融合后的列表已经综合了多路信号,但融合算法(如 RRF)是无监督的,可能不够精准。重排序模型可以学习复杂的相关性模式,给出最准确的排序。

  • 放在最后能保证送入 LLM 的上下文信息密度最高,减少 token 浪费。

能否放在混合检索之前? 可以,但没必要。如果在每路检索后都加重排序,成本太高,而最终融合时又可能打乱顺序。通常是一次重排序,放在融合之后。

一个反例:如果只使用单一路向量检索,重排序可直接放在向量检索之后,无需融合步骤。


🚀 当需要重排序的文档很多时(比如 50 个),如何减少重排序的计算开销?

50 个文档对于交叉编码器来说可能耗时较长(例如,bge-reranker-base 在 CPU 上可能需要几秒钟)。优化策略如下:

  1. 分阶段缩减(Cascade) 先使用快速、轻量的过滤方法将 50 个减少到 15 个,再用昂贵的交叉编码器重排序。比如:
  2. 基于向量相似度阈值:只保留相似度 > 某阈值的文档。
  3. 基于 BM25 分数:如果混合检索之前有 BM25 分数,可以过滤掉 BM25 分数极低的文档。
  4. 基于简单的句子长度或关键词命中过滤:快速剔除明显无关的。

  5. 使用更轻量级的重排序模型作为初排 例如,先用一个基于嵌入向量的 Cosine 相似度排序(双塔模型),取 top-15,再用交叉编码器精细排序。这其实是一个三阶段管道:检索 → 融合 → 轻量级排序 → 重量级排序。

  6. 批量推理与并行化 交叉编码器可以接收一个 batch 的 pairs 进行预测,这比逐对预测快得多。确保你的实现使用了 model.predict(pairs, batch_size=32),并利用 GPU 加速。

  7. 使用模型量化或更小的模型 将交叉编码器转换为 ONNX 并使用 INT8 量化推理,可以显著提升速度。或者选择更小版本的模型(如 bge-reranker-small)。

  8. 缓存 对于重复的 (query, doc) 组合,缓存重排序分数。如果文档库不常变,可以预先离线计算热门查询的分数。

  9. 文档摘要预过滤 不是对全文重排序,而是先让轻量模型对长文档生成摘要或标题,然后只对摘要进行重排序。如果摘要得分高,再使用全文。这进一步减少输入长度。

  10. 启发式截断 如果 50 个文档中,前面的若干个已经在之前阶段有很高的分数,可以直接取前 10 个,不需要全部重排,但可能牺牲召回。

在实践中,通常结合分阶段缩减和批量推理,可以在保持检索质量的同时将延迟控制在百毫秒以内。

📊 你怎样评估重排序的效果?用什么指标,如何做 A/B 测试?

重排序(Reranking)作为检索管道中的关键一环,其效果直接影响最终答案的质量。评估重排序需要从排序质量、端到端任务性能和用户体验三个层面进行。

📈 评估指标

  1. 排序质量指标(离线评估)
  2. NDCG(Normalized Discounted Cumulative Gain):最核心的排序指标。它对排名靠前的结果给予更高权重,非常适合评估重排序是否将最相关的文档放在了前面。需要人工标注每个文档与查询的相关度分数(例如0-3分)。
  3. MRR(Mean Reciprocal Rank):第一个相关文档排名的倒数平均值。适合评估“找第一个正确答案”的能力。
  4. MAP(Mean Average Precision):在不同召回水平上的精确率平均值,综合考虑排序和召回。
  5. Precision@K / Recall@K:例如 Precision@5,衡量重排序后前5个文档的精确率。计算时,需要知道文档真实的相关性标签。

  6. 端到端任务指标

  7. 答案正确率:如果重排序服务于 RAG 问答,可以直接评测最终答案的准确性(如 Exact Match、F1)。
  8. 引用准确性:检查 LLM 生成的答案是否引用了正确的文档(Citation Accuracy)。

  9. 效率指标

  10. 重排序延迟(Latency):P50、P95 延迟。
  11. 吞吐量(Throughput):每秒能处理多少查询。

🧪 A/B 测试方法

A/B 测试用于在线环境,比较两个重排序策略(例如 Cohere Rerank vs. bge-reranker)的真实效果。

  • 流量分割:将生产流量随机分成两组(A组和B组),确保样本同分布。

  • 控制变量:两组除了重排序模型不同,其他组件(检索器、LLM、Prompt)完全相同。

  • 收集指标:

  • 用户行为指标:点击率(CTR)、停留时长、点赞/点踩比例。
  • 业务指标:转化率、任务完成率。
  • 性能指标:延迟、错误率。

  • 统计显著性检验:收集足够样本后,使用 t 检验或卡方检验判断差异是否显著。

  • 灰度发布:通过配置中心动态切换重排序模型,无需重启服务。

💡 实操建议

  • 离线评估阶段,使用已有标注数据集(如 MS MARCO、自己标注的数据)计算 NDCG 进行粗筛。

  • 在线 A/B 测试时,重点关注 CTR 和用户满意度,因为它们是最终商业价值的体现。

  • 务必监控重排序带来的额外延迟,避免为了提升精度而牺牲用户体验。


🔄 解释一下“多阶段检索”:粗排(向量)→ 精排(重排序)→ 压缩(LLM)的完整链路。

多阶段检索是现代 RAG 系统中平衡速度、成本和精度的经典架构。它像漏斗一样,逐步筛选和提炼信息,最终只把最相关、最紧凑的上下文交给 LLM。

用户查询
[第一阶段:粗排(Retrieval)]
  目的:从海量文档中快速召回候选集。
  方法:向量检索(ANN)和/或关键词检索(BM25)。
  特点:速度快(毫秒级),覆盖面广,但精度较低。
  输出:候选文档集合(例如 top-100)。
[第二阶段:精排(Reranking)]
  目的:对候选集进行更精细的相关性排序。
  方法:使用计算量更大但更精确的模型(如交叉编码器 Cohere Rerank、bge-reranker)。
  特点:速度中等(几十毫秒),能捕捉细粒度语义,排序质量高。
  输出:重排后的 top-K 文档(例如 top-10)。
[第三阶段:压缩(Compression)]
  目的:从每个文档中提取与查询直接相关的核心片段,去除冗余。
  方法:基于 LLM 的提取器(LLMChainExtractor)或较小的模型。
  特点:计算量大(秒级),但能极大减少 token 消耗,提升 LLM 生成质量。
  输出:压缩后的文档片段(例如 3-5 个关键句)。
最终上下文 → LLM 生成答案

各阶段的设计权衡

  • 粗排:通过增加召回量(如 top-200)为后续阶段提供更多“原料”,但会增加后续阶段的计算负担。通常使用近似最近邻搜索(ANN)和倒排索引,支持百万级文档库。

  • 精排:通常是一个列表级(Listwise)或对级(Pairwise)的重排序模型。输入是查询和文档列表,输出重新排序的文档列表。选择重排序模型时要在延迟和精度之间平衡。

  • 压缩:视情况而定,如果文档已经较短(如 100-200 tokens),可跳过压缩。对于长文档(如 2000+ tokens),压缩非常必要。除了 LLM 提取,也可采用基于规则的关键句抽取(如 TextRank)。

示例架构:

FAISS (ANN) 召回 top-100 → bge-reranker-large 精排 top-10 → LLMChainExtractor 压缩 → GPT-4 生成

链式串联:

在 LangChain 中,可以这样组装:

retriever = vectorstore.as_retriever(search_kwargs={"k": 100})
compressor = BgeRerankCompressor(top_n=10)  # 自定义的精排压缩器
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor, base_retriever=retriever
)
# 如果再需要 LLM 压缩,可以再包一层 LLMChainExtractor
extractor = LLMChainExtractor.from_llm(llm)
final_retriever = ContextualCompressionRetriever(
    base_compressor=extractor, base_retriever=compression_retriever
)

监控与调试:记录每个阶段的输出文档数量和内容,以便当最终答案质量不佳时,能迅速定位是哪个阶段遗漏了关键文档。


🔍 在多阶段检索中,如果用户反馈不好,你如何快速定位是哪个阶段的问题?

当用户反馈“答案不对”或“搜不到相关内容”时,定位问题需要自上而下逐层排查,从最终答案回溯到原始检索。

快速定位方法

  1. 检查最终 LLM 生成(第三阶段压缩之后)
  2. 查看 LLM 的输入 Prompt,确认是否包含了与用户问题相关的上下文片段。
  3. 如果 LLM 看到了相关片段但回答错误,问题在 LLM 本身(幻觉、理解偏差、Prompt 设计不佳)。
  4. 如果 LLM 的输入中压根没有相关片段,问题在上游。

  5. 检查压缩阶段的输出

  6. 取出压缩后的文档片段,与原始文档对比:压缩是否过度删除了关键信息?是否保留了源文档的核心事实?
  7. 如果压缩后丢失了信息,问题在压缩器(提取 Prompt 不精确,或提取模型能力不足)。
  8. 如果压缩后片段无关,问题可能在精排或粗排。

  9. 检查精排阶段的输出

  10. 获取重排序后的 top-K 文档(未压缩),人工判断这些文档是否与问题相关。
  11. 如果多数文档无关,但前面的候选集中存在相关文档,则重排序模型出了问题(排序不准,可能过拟合或对领域适应性差)。
  12. 如果精排后的文档大多相关,但压缩后变差,问题在压缩。
  13. 如果重排序的输入候选集中相关文档很少,问题在粗排(召回不足)。

  14. 检查粗排阶段的输出

  15. 查看粗排返回的 top-100 文档,统计有多少是真正相关的。
  16. 如果相关文档数量极少(例如只有1-2条),则粗排的召回率不足,需要优化嵌入模型、检索参数或加入关键词检索增强。
  17. 如果相关文档有一定数量但排名靠后,说明粗排的排序能力弱,可能需要调整嵌入模型或相似度算法。

  18. 检查检索查询的构建

  19. 如果用户问题有歧义,或 ConversationalRetrievalChain 中生成的独立查询不准确,导致粗排从一开始就偏了。这是查询重写的问题,不是检索器本身的错。

辅助工具

  • 日志系统:记录每个阶段的输入/输出、耗时、文档 ID。

  • 可视化工具:LangSmith 或自建平台,可以清晰展示链的每一步。

  • 人工抽样:定期随机抽查,建立反馈循环。

快速决策表

查看内嵌表格


⚙️ 你有什么经验来调优混合检索中超参数(如 k 值、BM25 参数、Reranker 阈值)?

调优混合检索超参数是一个经验科学和系统搜索相结合的过程。

  1. k 值(各检索器返回数量)

  2. 向量检索 k1:通常设得稍大(如 20-50),因为向量检索覆盖面广,能够捞出“看起来相似”的文档,但其中很多可能不精确。更大的 k1 能提高召回率,但增加精排负担。

  3. 关键词检索 k2:可设小一些(如 10-20),因为 BM25 更倾向于精确匹配,返回的文档要么高度相关,要么完全无关。

  4. 混合后送给重排序的 k:一般是 k1 + k2 去重后的数量,再取 30-50 给重排序。

  5. 调优方法:以召回率(Recall@K)为目标,逐步增大 k1 和 k2,直到召回率趋于稳定。比如,先固定 k2=20,测试 k1=10,20,30...,找到转折点。

  6. BM25 参数(k1 和 b)

  7. BM25 有两个关键参数:k1(词频饱和度控制,默认1.2)和 b(文档长度归一化,默认0.75)。

  8. 对于长文档集合,可以稍微降低 b(如0.5),减少长文档被过度惩罚;对于短文档,b 设为0.75 即可。

  9. k1 一般不做大幅调整。如果发现 BM25 对高频词过于敏感(如“the”),可以尝试降低 k1(如0.5),但通常默认值够用。

  10. 调优可用网格搜索,在验证集上使用 NDCG 评估。

  11. 重排序阈值

  12. 重排序后需要截断,决定最终送入 LLM 的文档数量 top_n

  13. 如果 LLM 窗口够大,top_n 可以设为 5-10。窗口小则 3-5。

  14. 如果重排序模型输出分数,可以设置一个分数阈值(如 >0.5),低于阈值的文档即使在前 top_n 也丢弃,避免引入噪音。但交叉编码器的分数分布因模型而异,阈值需要通过验证集校准。

  15. 融合权重

  16. 静态权重:通过网格搜索 (w_vector 从 0.1 到 0.9) 在验证集上测试,选择 NDCG 最高的组合。

  17. 动态权重:根据查询特征(长度、是否含数字等)制定规则,然后通过 A/B 测试验证。

  18. 系统性优化流程

  19. 准备一个有标注的评估集(查询-相关文档对)。

  20. 固定精排模型,先优化粗排的 k 值,以 Recall@100 为目标。

  21. 引入混合检索,调融合权重,以 NDCG@10 为目标。

  22. 加入重排序,调 top_n 和可能的分数阈值,以 NDCG@5 为目标。

  23. 在线 A/B 测试微调,关注真实用户指标。

💡 经验之谈:不要一次性调所有参数。像剥洋葱一样层层优化,并始终用同一个评估集衡量进步。另外,记录每次实验的参数和结果,形成“参数-效果”矩阵。


📰 如果业务要求检索结果的时间新鲜度很高(如新闻),你如何在检索时融入时间权重?

在新闻、社交媒体监控等场景中,文档的时间距离(新鲜度)是至关重要的排序因子。只靠语义相似度会导致旧闻排在前面。融入时间权重有几种策略,从简单到复杂:

策略一:后处理时间衰减(最简单)

在检索(或重排序)完成后,根据文档的发布时间对相似度分数进行时间衰减加权。例如:

image.png

策略二:向量检索前置过滤(高效)

在向量检索时,直接通过元数据过滤掉过旧的文档。例如,只检索最近 7 天内的文档。这样从根本上减少了候选集,提高了检索效率。

  • 在 Chroma、Weaviate 等向量库中,可以在 search_kwargs 中传入 filter 参数:
retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 10,
        "filter": {"timestamp": {"$gte": "2024-01-01"}}  # 示例,语法依数据库而定
    }
)
  • 如果需要更灵活的时间范围,可以利用 SelfQueryRetriever(见后续问题)将自然语言转为过滤条件。

策略三:在混合检索中融入时间分数 将时间新鲜度作为一个独立的检索器分数,与向量、关键词分数一起融合。例如,为每个文档计算一个时间分数 time_score = max(0, 1 - (current_time - doc_time) / time_window),然后通过线性加权将其加入最终分数。 在自定义的 HybridRetriever 中,可以这样做:

def _get_relevant_documents(self, query):
    # 获取向量和关键词的分数
    ...
    # 计算时间分数
    current_ts = datetime.now().timestamp()
    for doc in all_docs:
        doc_ts = doc.metadata.get("timestamp")
        delta = (current_ts - doc_ts) / (3600*24)  # 天
        time_score = max(0, 1 - delta / 30)  # 30天内线性衰减
        doc.metadata["final_score"] = 0.5 * similarity_score + 0.3 * keyword_score + 0.2 * time_score
    # 按 final_score 降序排序

这种方式最灵活,但也需要谨慎设计权重,避免时间分数主导结果导致丢失高度相关但略旧的文档。

策略四:使用支持时间衰减的向量索引

一些专门的向量数据库(如 Elasticsearch、Vespa)支持在相似度函数中内嵌时间衰减,无需应用层处理。如果业务极度依赖时间,可以考虑迁移到这些更强大的搜索引擎。

注意:时间权重的引入可能会降低纯粹的语义相关性,因此需要针对业务场景调参(如 λλ 或权重),并在评估时加入时间维度的指标(如“最新相关文档的排名”)。


🏷️ 在 LangChain 中,如何实现按元数据过滤的检索(例如只检索某个年份的文档)?

在 LangChain 中,实现元数据过滤检索主要依赖向量数据库自身的能力。不同向量数据库的过滤语法不同,但通过 search_kwargsfilter 参数传递给检索器即可。

以 Chroma 为例:

retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 5,
        "filter": {"year": 2023}
    }
)
# 检索时只会考虑 year=2023 的文档

Chroma 支持嵌套字段和操作符(如 {"year": {"\(gte": 2020, "\)lte": 2023}})。

以 Weaviate 为例:

retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 5,
        "where_filter": {
            "operator": "Equal",
            "path": ["year"],
            "valueInt": 2023
        }
    }
)

Weaviate 使用 GraphQL 风格的 where 过滤。

以 FAISS 为例:FAISS 本身不支持元数据过滤,因为它只存向量。要实现过滤,必须在检索后手动筛选,或者使用 LangChain 的 FAISS 封装提供的 similarity_search 方法,传入 filter 函数(但会降低效率)。通常建议换用支持过滤的向量库。

通用方法:如果你不确定底层向量库,可以自定义一个带过滤的 Retriever 包装器:

class FilteredRetriever(BaseRetriever):
    def __init__(self, base_retriever, filter_dict):
        self.base = base_retriever
        self.filter_dict = filter_dict
    def _get_relevant_documents(self, query):
        docs = self.base.get_relevant_documents(query)
        return [doc for doc in docs if all(doc.metadata.get(k) == v for k,v in self.filter_dict.items())]

但这种方式效率较低,最好依赖数据库原生过滤。


🧠 什么是 SelfQueryRetriever?它将自然语言转化为什么形式来过滤?

SelfQueryRetriever 是 LangChain 中的一个高级检索器,它能够从用户的自然语言查询中自动提取出结构化的元数据过滤条件,并用这些条件对向量数据库进行过滤,同时仍进行语义检索。

它将自然语言转化为两部分:

  1. 查询字符串(query):用于语义检索的纯文本,例如“气候变化报告”。

  2. 过滤条件(filter):一个结构化的元数据过滤字典,例如 {"year": {"$eq": 2023}}

工作原理: 它依赖一个 LLM(通常是 OpenAI 的 GPT-3.5/4)解析用户输入,并按照指定的元数据字段定义,生成结构化输出。然后,调用底层向量存储的检索方法,同时传入 queryfilter

优势:

  • 用户无需知道数据库中有哪些元数据字段,就能通过自然语言实现复杂过滤,例如“找出去年张三写的关于机器学习的文章”。

  • 支持多种比较操作符:等于、大于、小于、包含、日期范围等。

核心组件:

  • SelfQueryRetriever 本身。

  • 一个定义了元数据字段及其类型的结构(AttributeInfo)。

  • 一个支持过滤的 VectorStore。

  • 一个 LLM。

生成的过滤条件示例:

用户输入:“show me documents about climate change from 2020 to 2023”

LLM 可能会生成:

{
  "query": "climate change",
  "filter": {
    "year": {"$gte": 2020, "$lte": 2023}
  }
}

📝 写一个 SelfQueryRetriever 的使用示例:从“找出去年关于气候变化的报告”中提取过滤条件。

首先,定义你的文档元数据结构,然后创建 SelfQueryRetriever。

from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
from langchain.llms import OpenAI
from langchain.vectorstores import Chroma

# 1. 定义元数据字段信息
metadata_field_info = [
    AttributeInfo(
        name="year",
        description="文档的发布年份",
        type="integer",
    ),
    AttributeInfo(
        name="author",
        description="文档的作者",
        type="string",
    ),
    AttributeInfo(
        name="category",
        description="文档的类别,如'报告'、'论文'、'新闻'",
        type="string",
    ),
]

# 2. 描述文档内容(帮助 LLM 理解)
document_content_description = "文档集合包含关于气候变化、环境政策等研究报告和新闻"

# 3. 初始化 LLM 和 VectorStore
llm = OpenAI(temperature=0)
vectorstore = Chroma(...)  # 你的向量数据库

# 4. 创建 SelfQueryRetriever
retriever = SelfQueryRetriever.from_llm(
    llm=llm,
    vectorstore=vectorstore,
    document_contents=document_content_description,
    metadata_field_info=metadata_field_info,
    verbose=True,
)

# 5. 使用
query = "找出去年关于气候变化的报告"
docs = retriever.get_relevant_documents(query)
# LLM 内部会解析:
# query -> "气候变化 报告"
# filter -> {"year": 2023, "category": "报告"}   # 假设今年是2024

说明:SelfQueryRetriever 会使用 LLM 解析自然语言,结合元数据字段信息,生成一个结构化的查询。它首先会确定 year 应该是“去年”(假设当前年份减一),category 应该是“报告”。然后,将这些过滤条件连同语义查询一起发送给向量数据库。

局限性:

  • 依赖 LLM 对日期和字段的理解,对于模糊查询可能出错。

  • 必须预先定义好元数据结构,且字段描述要足够清晰。


🔍 SelfQueryRetriever 的底层是如何把自然语言转为结构化查询的?依赖 LLM 吗?

是的,完全依赖 LLM。 SelfQueryRetriever 底层使用了一个精心设计的提示词(Prompt)和输出解析器(Output Parser),让 LLM 充当“自然语言转结构化查询”的翻译器。

工作流程:

  1. 构造提示词:将用户问题、所有可用元数据字段的定义(名称、类型、描述)、以及文档内容描述,填充到一个模板中。

  2. LLM 推理:LLM 根据提示词,生成一个结构化的 JSON 或特定格式的字符串,包含两个部分:

  3. query:用于语义检索的字符串。
  4. filter:一个表示过滤条件的对象,例如 {"field": "year", "operator": "gte", "value": 2020}

  5. 解析输出:LangChain 使用自定义的解析器(如 StructuredQueryOutputParser)将 LLM 的输出转换为一个内部的 StructuredQuery 对象。

  6. 翻译为数据库特定过滤:不同向量数据库的过滤语法不同。LangChain 提供了 VectorStoreQueryTranslator,将通用的 StructuredQuery 翻译成特定数据库(如 Chroma、Weaviate、Pinecone)的过滤格式。

  7. 执行检索:最终,使用解析出的 query 和翻译后的 filter 调用 VectorStore 的 searchsimilarity_search 方法。

关键源码位置:

  • langchain/chains/query_constructor/base.py:构造提示词和解析。

  • langchain/chains/query_constructor/ir.py:定义中间表示(StructuredQuery)。

  • langchain/retrievers/self_query/:针对不同数据库的翻译器。

依赖 LLM 的强与弱:

  • 强:能够理解复杂的自然语言和隐式约束(如“去年”、“最新的”)。

  • 弱:可能产生幻觉或错误解析,对模糊查询不鲁棒。因此,通常建议使用功能更强的 LLM(如 GPT-4),并提供详细的字段描述和示例。

自定义提示词:SelfQueryRetriever 允许传入自定义的 prompt 参数,可以优化解析准确性。


🎯 如果用户的查询很模糊,向量检索和关键词检索结果都差,你怎么办?

当查询模糊(如“那个东西”、“上次说的那个”)或信息量极低时,检索系统普遍失效。此时需要采取查询增强和交互式检索策略。

  1. 查询重写与扩展(离线优化)

  2. 使用 MultiQueryRetriever:让 LLM 从不同角度生成多个更具体的查询,然后合并结果。这可以在检索前自动扩写模糊查询。

  3. 历史上下文消歧:在对话场景中,通过 condense_question 将历史对话融入,把“那个东西”替换为之前提到的具体实体。

  4. 同义词扩展:使用词典或大模型生成同义词、相关词,扩大检索范围。

  5. 引导用户澄清(在线交互)

  6. 如果置信度很低,系统可以主动反问:“您是指哪方面的气候变化?是政策、科学还是经济影响?” 这需要设计一个澄清对话流程。

  7. 让 LLM 判断查询的明确性,若模糊,则生成一个澄清问题,等待用户回答后再检索。

  8. 利用元数据和过滤

  9. 如果系统记录了用户画像(偏好、领域),可以在检索时自动添加过滤条件,缩小范围。例如,默认检索“用户最近关注的领域”。

  10. 降级策略

  11. 如果实在检索不到相关文档,可以返回通用知识或直接承认不知道,避免胡编乱造。

  12. 使用一个外部搜索引擎 API 作为后备,在私有知识库无结果时,搜索公网知识。

  13. 迭代优化

  14. 收集模糊查询的日志,分析其模式,有针对性地补充文档,或者训练更好的嵌入模型。

  15. 对于反复出现的模糊查询,可以建立别名或 QA 对,直接匹配。

  16. 混合检索的兜底

  17. 虽然向量和关键词都差,但两者的结合(Ensemble)有时能通过互补稍微提升。如果还不够,考虑加入更多路的检索(如基于知识图谱、基于实体链接)。

总之,模糊查询的解决不在检索器本身,而在于整个系统的交互设计和查询预处理。一个智能的 RAG 系统应当能够优雅地处理不确定性。