深入浅出RAG——第10章:检索后处理与重排序

📖 本章学习目标

  • 区分 Bi-Encoder 和 Cross-Encoder 的架构差异及各自在检索管道中的角色
  • 使用 Cohere Rerank API 对检索结果执行精确重排序
  • 实现上下文压缩,让 LLM 提取检索结果中最相关的片段
  • 设计合理的分数阈值策略进行结果过滤

《第9章:高级检索策略》中,我们用混合检索、Multi-Query 和 MMR 提升了检索的召回和多样性。但实际上"召回到更多文档"并不等于"给 LLM 的上下文更好",因为检索结果中依然可能存在噪声、冗余和不相关的内容。这就需要用到检索后处理技术。

检索后处理工作在检索阶段之后、生成阶段之前,目标是净化即将进入 Prompt 的上下文 。这个过程通常包含三个核心步骤:重排序、过滤、压缩

通俗的理解,如果说检索环节是"广撒网",那么后处理就是"精挑选"。这两者配合,才能确保 LLM 收到的信息既全面又精准。

一、Reranking(重排序)

重排序是 RAG系统中位于初步检索与最终生成之间的关键环节,其核心作用是对初始召回的候选文档进行二次精细化评估与重新排序。它通常采用交叉编码器(Cross-Encoder)等高精度模型,将用户查询与候选文档拼接后进行深度的语义交互计算,从而精准捕捉复杂逻辑并剔除"看似相似实则无关"的噪声文档。这一"粗筛+精排"的机制能够有效纠正向量检索的偏差,确保送入大模型的上下文高度相关且精简,从而显著提升最终答案的准确性、可信度并降低幻觉风险。

1. Bi-Encoder vs Cross-Encoder

Bi-Encoder(双编码器) 和 Cross-Encoder(交叉编码器)是自然语言处理(NLP)和信息检索中两种核心的模型架构。它们的核心差异在于如何处理和比较两段文本(通常是查询 Query 和文档 Document),这直接决定了它们在速度和精度上的不同表现。

理解 Reranker 之前,需要先搞清楚两种 Encoder 架构的根本差异。这是整个检索-重排体系的理论基础。

1.1 Bi-Encoder(双编码器)

Bi-Encoder 是我们前面一直在用的向量检索背后的技术。它采用"双塔"结构,核心思想是将查询(Query)和文档(Document)分别送入两个独立的编码器(通常共享参数),各自生成一个固定维度的稠密向量(Embedding)。随后,通过计算这两个向量之间的距离(如余弦相似度)来衡量相关性。

graph LR subgraph Bi-Encoder direction LR A1[查询] --> E1[Encoder] A2[文档] --> E2[Encoder] E1 --> V1[查询向量] E2 --> V2[文档向量] V1 --> S1[余弦相似度] V2 --> S1 S1 --> R1[相似度分数] end

这个模型的工作流程我们在前面讲解向量检索时已经详细介绍,核心步骤就是分别将查询文本和文档文本转成向量,再计算两个向量的相似度(如余弦相似度)。

Bi-Encoder的核心优势是 检索极快且支持预计算。文档的向量可以提前离线算好并存入向量数据库。当用户发起查询时,系统只需对 Query 编码一次,即可通过近似最近邻搜索(ANN)在百万级数据中毫秒级匹配结果。另外其扩展性强,非常适合大规模知识库的应用的场景。

Bi-Encoder的劣势则是由于查询和文档两段文本是独立编码的,缺乏细粒度的交互,只能依赖向量的语义表示,所以它容易丢失一些细微的语义信息(例如无法很好地区分"A 收购了 B"和"B 收购了 A"),精度相对较低,对于需要逐词比对的任务表现不佳。

鉴于Bi-Encoder的优点,在RAG工程中,由于检索阶段可能需要从大规模的数据中找到匹配的文档,因此Bi-Encoder一般被用于检索阶段的初筛(召回阶段)。

1.2 Cross-Encoder(交叉编码器)

Cross-Encoder 的工作方式完全不同,它采用"单塔"联合编码,将查询和文档拼接成一个完整的序列,作为一个整体输入到 Transformer 模型(如BERT)中。模型内部的自注意力机制(Self-Attention)会让 Query 中的每个词与 Document 中的每个词进行深度的交叉交互,最终直接输出一个 0 到 1 之间的精确 相关性得分

graph LR subgraph Cross-Encoder direction LR B1[查询和文档] --> E3[Transformer Encoder] E3 --> C1[Classification Head] C1 --> S2[相关性分数 0-1] end

Cross-Encoder的关键优势是 精度极高。因为它实现了 Token 级别的深度注意力交互,能准确捕捉同义词、否定词、主被动语态以及复杂的上下文逻辑关系(比如否定、条件、指代等复杂语义),准确率通常比 Bi-Encoder 高出 5-15 个百分点。

Cross-Encoder的局限性则是 计算成本极高且速度慢 。它无法预计算文档向量,每一对 (Query, Document) 都必须实时过一遍完整的模型。如果用来检索百万级文档,耗时将是灾难性的(百万级别可能需要数小时)。因此Cross-Encoder更适合于对小规模候选集(如 Top-50 或 Top-100)进行 重排序(Reranking / 精排阶段)

单塔"和"双塔"指的是两种截然不同的神经网络模型架构。它们的核心差异在于如何处理输入数据以及数据之间的交互方式。双塔模型的核心思想是"解耦与独立计算",它包含两个独立的编码器网络(即两座"塔");单塔模型的核心思想是"联合编码与深度交互"。它只使用一个网络。

1.3 架构对比

维度 Bi-Encoder Cross-Encoder
工作方式 查询和文档分别独立编码,生成向量后计算相似度 查询和文档拼接后联合编码,直接输出相关性分数
速度 极快(文档可预编码,检索 O(log N),毫秒级响应) 极慢(每对需完整前向传播,O(N))
精度 浅层(仅在向量空间比较) 深层(Transformer 内部全量交叉注意力)
可扩展性 支持百万级文档 仅适合百级候选
在 RAG 中的位置 检索阶段(从海量候选中粗筛) 重排阶段(对少量候选精准排序)
典型模型 text-embedding-3-small, BGE-M3 Cohere Rerank, BGE-Reranker, bge-reranker-base

结合Bi-Encoder和Cross-Encoder的优缺点,实际工程中采用"两阶段检索"("粗筛 + 精排")的组合策略。

(1)第一阶段(Bi-Encoder):快速从 100 万中选出 Top-100(耗时毫秒级)

(2)第二阶段(Cross-Encoder):精准从 Top-100 中选出 Top-5(耗时秒级)

graph LR A[用户查询] --> B[Bi-Encoder 检索] B --> C[Top-100 候选] C --> D[Cross-Encoder 重排] D --> E[Top-5 最终结果] E --> F[组装 Prompt]

检索时用快速的 Bi-Encoder 从海量候选中提取 Top-N(如 20-100 个),再用精确但慢的 Cross-Encoder 从中选出真正的 Top-K(如 3-5 个),这种组合兼顾了速度与精度,是现代搜索引擎的标准做法。Google、Bing 等搜索引擎内部也采用类似的架构。

2. 使用 Cohere Rerank 重排序

Cohere Rerank 是由 Cohere 公司推出的一款企业级重排序模型(基于 Cross-Encoder 交叉编码器架构),专为优化搜索系统和检索增强生成应用而设计。它通过接收用户查询与候选文档并进行深度的语义联合分析,能够精准捕捉复杂逻辑并理解超过 100 种语言及半结构化数据(如表格、代码),从而将文档按语义相关性进行高精度重排。凭借其卓越的性能与易用性,Cohere Rerank 能够以极低的集成成本显著提升检索结果的相关性,并有效降低大模型生成时的幻觉风险。

2.1 为什么选择 Cohere Rerank?

Cohere 提供了目前最成熟的商业 Rerank API,在构建生产级 RAG 系统时,选择 Cohere Rerank 作为重排序方案,通常是基于其在性能、架构、工程集成以及多语言支持等方面的综合优势。以下是选择 Cohere Rerank 的核心原因:

  • 出色的多语言与半结构化数据处理:原生支持超过 100 种语言,不仅擅长纯文本,Rerank 4 等版本对代码、表格、JSON 等半结构化数据也有极强的理解能力,非常适合处理企业内部复杂的非结构化知识库
  • 高精度:在 BEIR、MTEB 等基准测试中表现优异
  • 极低的工程集成成本:简单的 API 接口,无需自己部署模型和运维,开发者只需添加几行代码即可将其无缝集成到现有的关键词检索或向量检索系统中
  • 完善的生态集成:Cohere Rerank 已经被 LangChain 等主流 RAG 编排框架原生集成,开发者可以通过简单的配置快速搭建包含重排序的完整检索链路
  • 免费额度:每月 1000 次免费调用,适合开发和测试

当然,你也可以选择开源方案(如 BGE-Reranker),但需要自己部署和维护模型。

2.2 安装与配置

bash 复制代码
npm install cohere-ai

配置 API Key(.env 文件):

bash 复制代码
COHERE_API_KEY=your-api-key-here

2.3 基础用法

typescript 复制代码
import { CohereClient } from "cohere-ai";
import { Chroma } from "@langchain/community/vectorstores/chroma";

const cohere = new CohereClient({ 
  token: process.env.COHERE_API_KEY! 
});

/**
 * 带 Rerank 的检索流程
 */
async function retrieveWithRerank(
  query: string,
  vectorStore: Chroma,
  options: {
    initialK?: number;   // 初始检索数量
    finalK?: number;     // 最终返回数量
    threshold?: number;  // 相关性阈值
  } = {}
): Promise<Array<{ doc: Document; score: number }>> {
  const {
    initialK = 20,
    finalK = 5,
    threshold = 0.3,
  } = options;

  console.log(`\n【第一阶段】Bi-Encoder 检索 Top-${initialK}`);
  
  // 第一步:Bi-Encoder 粗筛
  const rawResults = await vectorStore.similaritySearchWithScore(query, initialK);
  
  console.log(`检索到 ${rawResults.length} 个候选文档`);
  
  if (rawResults.length === 0) {
    return [];
  }

  console.log("\n【第二阶段】Cross-Encoder 重排");
  
  // 第二步:Cross-Encoder 精排
  const documents = rawResults.map(([doc]) => doc.pageContent);
  
  const rerankResult = await cohere.rerank({
    query: query,
    documents: documents,
    topN: finalK,
    model: "rerank-multilingual-v3.0",
  });

  console.log(`重排完成,返回 Top-${finalK}`);

  // 第三步:组装结果
  const rerankedDocs = rerankResult.results
    .filter(r => r.relevanceScore >= threshold)  // 过滤低分结果
    .map((r) => ({
      doc: rawResults[r.index][0],
      score: r.relevanceScore,
    }));

  console.log(`过滤后剩余 ${rerankedDocs.length} 个文档\n`);

  return rerankedDocs;
}

// 使用示例
const results = await retrieveWithRerank(
  "如何配置数据库连接池?",
  vectorStore,
  { initialK: 20, finalK: 5, threshold: 0.3 }
);

for (const [i, result] of results.entries()) {
  console.log(`[${i + 1}] 分数: ${result.score.toFixed(3)}`);
  console.log(`    内容: ${result.doc.pageContent.slice(0, 100)}...`);
  console.log(`    来源: ${result.doc.metadata.source}\n`);
}

2.4 Rerank 前后效果对比

通过对比实验,可以直观展示 Reranker 的价值。

typescript 复制代码
async function compareRerankEffect(
  query: string,
  vectorStore: Chroma
): Promise<void> {
  console.log(`查询: "${query}"\n`);

  // Rerank 前
  console.log("=== Rerank 前(Bi-Encoder Top-5)===");
  const beforeResults = await vectorStore.similaritySearchWithScore(query, 5);
  
  for (const [i, [doc, score]] of beforeResults.entries()) {
    console.log(`${i + 1}. [${score.toFixed(3)}] ${doc.pageContent.slice(0, 80)}...`);
  }

  // Rerank 后
  console.log("\n=== Rerank 后(Cross-Encoder Top-5)===");
  const afterResults = await retrieveWithRerank(query, vectorStore, {
    initialK: 20,
    finalK: 5,
  });
  
  for (const [i, result] of afterResults.entries()) {
    console.log(`${i + 1}. [${result.score.toFixed(3)}] ${result.doc.pageContent.slice(0, 80)}...`);
  }

  // 分析变化
  console.log("\n=== 变化分析 ===");
  
  const beforeIds = beforeResults.map(([doc]) => getDocId(doc));
  const afterIds = afterResults.map(r => getDocId(r.doc));
  
  const added = afterIds.filter(id => !beforeIds.includes(id));
  const removed = beforeIds.filter(id => !afterIds.includes(id));
  
  if (added.length > 0) {
    console.log(`✓ 新增 ${added.length} 个更相关的文档`);
  }
  
  if (removed.length > 0) {
    console.log(`✗ 移除 ${removed.length} 个不相关的文档`);
  }
  
  if (added.length === 0 && removed.length === 0) {
    console.log("- 结果无变化(说明 Bi-Encoder 已经很准确)");
  }
}

// 测试
await compareRerankEffect("如何配置数据库连接池?", vectorStore);
ini 复制代码
查询: "如何配置数据库连接池?"

=== Rerank 前(Bi-Encoder Top-5)===
1. [0.823] 数据库连接配置步骤 1: 安装驱动...
2. [0.815] 数据库性能优化指南...
3. [0.801] 常见数据库错误码列表...
4. [0.795] 数据库连接配置步骤 2: 设置连接字符串...
5. [0.788] MySQL 安装教程...

=== Rerank 后(Cross-Encoder Top-5)===
1. [0.912] 数据库连接池配置最佳实践:最大连接数、超时时间...
2. [0.887] PostgreSQL 连接池参数详解...
3. [0.856] 数据库连接配置步骤 2: 设置连接字符串...
4. [0.834] 数据库连接配置步骤 1: 安装驱动...
5. [0.821] Redis 连接池配置示例...

=== 变化分析 ===
✓ 新增 3 个更相关的文档
✗ 移除 2 个不相关的文档

这个对比通常能直观展示 Reranker 的价值,Bi-Encoder Top-5 中混入的不相关文档(如"MySQL 安装教程"、"常见数据库错误码列表"),在 Cross-Encoder 重排后被挤出了 Top-5,取而代之的是真正关于"连接池配置"的文档。

2.5 封装为可复用的 Reranked Retriever

将 Rerank 逻辑封装为一个标准的 Retriever 接口,可以在工程中复用。这样做的好处是可以无缝替换原有的 Retriever,其他代码无需修改。

typescript 复制代码
import { BaseRetriever } from "@langchain/core/retrievers";
import { CallbackManagerForRetrieverRun } from "@langchain/core/callbacks/manager";

class RerankedRetriever extends BaseRetriever {
  private vectorStore: Chroma;
  private cohere: CohereClient;
  private initialK: number;
  private finalK: number;
  private threshold: number;

  constructor(options: {
    vectorStore: Chroma;
    cohereApiKey: string;
    initialK?: number;
    finalK?: number;
    threshold?: number;
  }) {
    super();
    this.vectorStore = options.vectorStore;
    this.cohere = new CohereClient({ token: options.cohereApiKey });
    this.initialK = options.initialK || 20;
    this.finalK = options.finalK || 5;
    this.threshold = options.threshold || 0.3;
  }

  async _getRelevantDocuments(
    query: string,
    runManager?: CallbackManagerForRetrieverRun
  ): Promise<Document[]> {
    const results = await retrieveWithRerank(query, this.vectorStore, {
      initialK: this.initialK,
      finalK: this.finalK,
      threshold: this.threshold,
    });

    return results.map(r => r.doc);
  }
}

// 使用
const rerankedRetriever = new RerankedRetriever({
  vectorStore,
  cohereApiKey: process.env.COHERE_API_KEY!,
  initialK: 20,
  finalK: 5,
  threshold: 0.3,
});

const docs = await rerankedRetriever.invoke("如何配置数据库连接池?");

二、分数阈值过滤

1. 为什么需要阈值过滤

在检索过程中,机械式的固定 Top-K 检索策略存在致命隐患:即使知识库中完全没有相关信息,系统也会强行返回得分最高的几个片段。这些低相似度的伪相关内容被喂给大语言模型后,极易诱发严重的模型幻觉与答非所问。这就需要引入分数阈值过滤机制,核心逻辑是设定一个绝对阈值(Hard Thresholding),仅保留相似度分数大于或等于该阈值的切块。如果所有结果都低于阈值则返回空,告诉用户找不到相关信息。这样能够带来三大核心收益:

(1)筑起拒答防线:当所有检索结果的相似度均低于设定阈值时,系统能够果断判定"知识库无相关记录",直接触发标准拒答模板,而非让模型强行编造。

(2)剔除长尾噪声:阈值截断能动态保留真正有效的片段,剔除强行拉来凑数的低质内容,提升 Prompt 的信息纯度。

(3)节省 Token 与降低延迟:动态返回高置信度切块,大幅压缩上下文长度,降低 API 成本与首字响应时间(TTFT)。

2. 基础实现

分数阈值过滤实现时需要明确度量方式与基准,不同相似度度量方式的取值范围不同。对于最常见的余弦相似度(Cosine Similarity),分数越接近 1 表示越相似,通常仅保留 Score≥τ\text{Score} \ge \tau Score≥τ 的结果;对于欧氏距离(L2 Distance),数值越小表示越相似,则保留 Distance≤τ\text{Distance} \le \tau Distance≤τ 的结果。在使用 BGE 或 OpenAI 等主流 Embedding 模型时,余弦相似度的绝对硬阈值通常设置在 0.70∼0.820.70 \sim 0.82 0.70∼0.82 之间。这种策略逻辑简单,适用于规范明确、语料风格高度一致的垂直专业知识库(如医疗指南、API 文档)。

typescript 复制代码
interface ScoredDocument {
  doc: Document;
  score: number;
}

function filterByThreshold(
  results: ScoredDocument[],
  threshold: number
): ScoredDocument[] {
  const filtered = results.filter(result => result.score >= threshold);
  
  console.log(`过滤前: ${results.length} 个文档`);
  console.log(`过滤后: ${filtered.length} 个文档`);
  console.log(`阈值: ${threshold}`);
  
  return filtered;
}

// 使用
const results = await vectorStore.similaritySearchWithScore(query, 10);
const validDocs = filterByThreshold(
  results.map(([doc, score]) => ({ doc, score })),
  0.75
);

if (validDocs.length === 0) {
  return "抱歉,知识库中没有找到相关信息。请尝试换一种提问方式。";
}

3. 如何标定阈值

不同 Embedding 模型、不同的需求场景都需要不同的阈值,比如text-embedding-3-small 的 0.75 和 BGE-M3 的 0.75 含义是不同的,需要根据你自己的数据标定,切忌凭直觉拍脑袋确定阈值,科学的标定流程应基于数据驱动。可以参考如下方式:

(1)构建基准黄金测试集:准备 100~200 条包含标准答案的问答对,并刻意加入 20% 左右的负样本(即知识库无法回答的 Query)。

(2)遍历计算指标 :在 0.50∼0.950.50 \sim 0.95 0.50∼0.95 的区间内,以 0.020.02 0.02 为步长遍历阈值,计算各阈值下的准确率(Precision)、召回率(Recall)以及拒答正确率。

(3)寻找 F1 最大值拐点 :绘制 Precision-Recall 曲线,寻找 F1= 2⋅P⋅RP+R F1 = \frac{2 \cdot P \cdot R}{P + R} F1=P+R2⋅P⋅R 的最大值拐点。该拐点对应的阈值即为当前数据集下的最佳平衡点。

(4)基于数据分布计算:也可通过统计分析,计算测试集相似度分数的平均值与标准差,将阈值设定为"平均值 + 0.5倍标准差"。

以下是一个使用基于 F1 度量最大化的网格搜索(Grid Search) 方法来确定最优阈值的示例,基本的原理就是阈值太低时召回率高,但精确率低(很多不相关的也被保留);阈值太高(0.9)精确率高,但召回率低(很多相关的被过滤掉),而最终的F1 最高点(比如0.75)是平衡点,这个平衡点就是最优解。

typescript 复制代码
/**
 * 通过网格搜索和 F1 分数最大化,自动标定最佳的检索阈值
 * @param testCases - 包含问题和标准答案文档ID的测试数据集(黄金测试集)
 * @param vectorStore - 向量数据库实例
 * @param thresholds - 待测试的候选阈值列表,默认为 [0.5, 0.6, 0.7, 0.75, 0.8, 0.85, 0.9]
 * @returns 返回一个对象,包含计算出的最优阈值以及各阈值对应的评估指标
 */
async function calibrateThreshold(
  testCases: Array<{ query: string; relevantDocIds: string[] }>,
  vectorStore: Chroma,
  thresholds: number[] = [0.5, 0.6, 0.7, 0.75, 0.8, 0.85, 0.9]
): Promise<{ optimalThreshold: number; metrics: any }> {
  
  // 初始化一个字典,用于存储每个候选阈值对应的评估指标(精确率、召回率、F1)
  const results: Record<number, { precision: number; recall: number; f1: number }> = {};

  // 【网格搜索】遍历每一个候选阈值
  for (const threshold of thresholds) {
    let totalPrecision = 0; // 累加器:记录当前阈值下所有测试用例的精确率总和
    let totalRecall = 0;    // 累加器:记录当前阈值下所有测试用例的召回率总和
    let count = 0;          // 计数器:记录实际参与计算的测试用例数量

    // 遍历测试集中的每一个问答对
    for (const testCase of testCases) {
      // 1. 执行向量检索:针对当前 query,从向量库中检索最相似的 Top-10 个文档及分数
      const searchResults = await vectorStore.similaritySearchWithScore(testCase.query, 10);
      
      // 2. 阈值过滤:使用当前的候选阈值,剔除掉相似度不达标的文档
      const filtered = filterByThreshold(
        searchResults.map(([doc, score]) => ({
          doc,
          score,
          id: doc.metadata.id, // 提取文档的唯一标识,用于后续比对
        })),
        threshold
      );

      // 3. 提取过滤后保留下来的文档 ID 列表
      const retrievedIds = filtered.map(r => r.id);
      // 获取当前测试用例的标准答案文档 ID 列表
      const relevantIds = testCase.relevantDocIds;

      // 4. 计算 True Positives (TP):即检索出的文档中,真正属于标准答案的数量(交集)
      const truePositives = retrievedIds.filter(id => relevantIds.includes(id)).length;
      
      // 5. 计算当前用例的 Precision(精确率):检索出的结果中有多少是相关的
      // 如果检索结果为空,精确率设为 0,防止除以 0 报错
      const precision = retrievedIds.length > 0 ? truePositives / retrievedIds.length : 0;
      
      // 6. 计算当前用例的 Recall(召回率):标准答案中有多少被成功检索出来了
      // 如果标准答案为空,召回率设为 0
      const recall = relevantIds.length > 0 ? truePositives / relevantIds.length : 0;

      // 将当前用例的指标累加到总和中
      totalPrecision += precision;
      totalRecall += recall;
      count++;
    }

    // 7. 计算当前阈值在整体测试集上的平均精确率和平均召回率
    const avgPrecision = totalPrecision / count;
    const avgRecall = totalRecall / count;
    
    // 8. 计算 F1 分数(精确率和召回率的调和平均数)
    // 加上 1e-8 是为了防止当 avgPrecision 和 avgRecall 都为 0 时发生除以 0 的异常
    const f1 = 2 * (avgPrecision * avgRecall) / (avgPrecision + avgRecall + 1e-8);

    // 将当前阈值的评估结果存入 results 字典
    results[threshold] = {
      precision: avgPrecision,
      recall: avgRecall,
      f1,
    };

    // 在控制台实时打印当前阈值的评估进度,方便调试和观察
    console.log(`阈值 ${threshold.toFixed(2)}: P=${avgPrecision.toFixed(3)}, R=${avgRecall.toFixed(3)}, F1=${f1.toFixed(3)}`);
  }

  // 【寻找最优解】将 results 字典转换为数组,并按照 F1 分数进行降序排序
  // 排序后取第一个元素(即 F1 最高的结果),提取其对应的阈值作为最优阈值
  const optimalThreshold = Object.entries(results)
    .sort((a, b) => b[1].f1 - a[1].f1)[0][0];

  // 返回最终结果:包含找到的最优阈值,以及所有候选阈值的详细评估指标
  return {
    optimalThreshold: parseFloat(optimalThreshold), // 将字符串类型的 key 转回数字类型
    metrics: results,
  };
}

// ================= 测试数据准备 =================

// 准备带有标准答案的测试数据集(黄金测试集)
// 在实际使用中,建议至少准备 20~100 条以上的测试用例,且包含一定比例的负样本(即无法回答的问题)
const testCases = [
  {
    query: "如何配置数据库连接池?",
    relevantDocIds: ["doc_123", "doc_456"], // 这个问题对应的正确文档 ID
  },
  // ... 其他测试用例
];

// ================= 执行阈值标定 =================

// 调用标定函数,传入测试集和向量库
const { optimalThreshold, metrics } = await calibrateThreshold(testCases, vectorStore);

// 在控制台输出最终计算出的最优阈值
console.log(`\n最优阈值: ${optimalThreshold}`);

输出示例:

ini 复制代码
阈值 0.50: P=0.650, R=0.920, F1=0.762
阈值 0.60: P=0.720, R=0.880, F1=0.792
阈值 0.70: P=0.810, R=0.820, F1=0.815
阈值 0.75: P=0.870, R=0.780, F1=0.822
阈值 0.80: P=0.920, R=0.680, F1=0.782
阈值 0.85: P=0.960, R=0.520, F1=0.675
阈值 0.90: P=0.980, R=0.350, F1=0.515

最优阈值: 0.75

4. 动态阈值策略

在复杂业务场景中,单一的固定静态阈值往往难以兼顾所有类型的 Query,业界通常采用以下三种进阶截断策略:

  1. 相对动态落差截断(Relative Drop) :以最高分 Smax⁡ S_{\max} Smax 为基准,保留满足 Scorei≥Smax⁡−Δ \text{Score}i \ge S{\max} - \Delta Scorei≥Smax−Δ 且 Scorei≥τbase \text{Score}i \ge \tau{\text{base}} Scorei≥τbase 的切块。该策略能避免当整体命中度极高时因硬阈值误杀次要切块,或在整体偏低时因微弱差距拉入无关切块。
  2. 软硬双阈值分流(Dual-Threshold Routing) :将结果划分为三个区间。高置信区(如 ≥0.82\ge 0.82 ≥0.82)直接注入 Prompt;灰度重排区(如 0.65∼0.820.65 \sim 0.82 0.65∼0.82)送入 Cross-Encoder Reranker 进一步精排;低置信区(如 <0.65< 0.65 <0.65)直接丢弃或触发拒答。
  3. 自适应动态寻优(Adaptive Thresholding) :系统根据当前检索结果的整体质量自动调节过滤标准。例如,计算当前召回集合分数的均值 μ\mu μ 和标准差 σ\sigma σ,将阈值动态设定为 τ=μ−nσ\tau = \mu - n\sigma τ=μ−nσ( nn n 为可调超参数)。当检索质量高时,阈值自动收紧;当检索质量平庸时,阈值自动放宽以保全召回率。

以下是一段基于统计分布的自适应动态阈值策略的示例代码,该策略接收一个参数 scores(即当前检索返回的文档相似度分数列表),通过计算这批分数的统计特征(均值、中位数或百分位数)来寻找当前的基准线,只保留那些分数与基准线差距不超过相对落差(margin)的文档。

typescript 复制代码
function dynamicThreshold(
  scores: number[],
  strategy: "mean" | "median" | "percentile" = "mean",
  margin: number = 0.1
): number {
  if (scores.length === 0) {
    return 0.7;  // 默认阈值
  }

  let threshold: number;

  switch (strategy) {
    case "mean":
      // 基于平均分数的阈值
      const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
      threshold = mean - margin;
      break;

    case "median":
      // 基于中位数的阈值
      const sorted = [...scores].sort((a, b) => a - b);
      const median = sorted[Math.floor(sorted.length / 2)];
      threshold = median - margin;
      break;

    case "percentile":
      // 基于百分位数的阈值(如 75 分位)
      const sortedScores = [...scores].sort((a, b) => a - b);
      const percentileIndex = Math.floor(scores.length * 0.75);
      threshold = sortedScores[percentileIndex] - margin;
      break;
  }

  // 限制在合理范围内
  return Math.max(0.5, Math.min(0.9, threshold));
}

// 使用
const scores = results.map(([_, score]) => score);
const threshold = dynamicThreshold(scores, "mean", 0.1);
console.log(`动态阈值: ${threshold.toFixed(3)}`);

代码的最后一行 Math.max(0.5, Math.min(0.9, threshold)) 起到了安全兜底的作用。无论动态计算的结果如何,最终的阈值都会被强制限制在 0.5 到 0.9 之间。这防止了因为极端情况(例如所有分数都很低,导致计算出的阈值低于 0.5)而把大量垃圾文档放进 Prompt 中。

这是一种非常轻量级且实用的自适应过滤策略,能适应不同的查询难度。它不需要复杂的模型训练或离线测试,仅通过简单的统计学方法,就能让 RAG 系统在面对各种难易程度不同的 Query 时,自动调节过滤的松紧度,实现因地制宜的检索。即对于简单查询,分数普遍较高,阈值也会相应提高;对于困难查询,分数普遍较低,阈值会降低以保证召回。

5. 分数阈值过滤在检索流水线的位置

通过前面章节我们知道,RAG检索初召回以及重排这两个阶段都会输出一个具有分数值的文档结果列表,那分数阈值过滤应该放到这两个阶段的哪个位置合适呢?其实,在检RAG系统的标准架构中,阈值过滤通常既会放在检索以后,也会放在重排以后。这两个阶段的过滤构成了层层过滤的机制,分别承担着不同的职责。

具体来说,它们的区别如下:

(1)检索后的阈值过滤(初检过滤)

  • 位置:在初步检索(如向量检索或关键词检索)完成之后,重排(Rerank)之前。
  • 作用:作为第一道质量门槛,快速过滤掉与查询相关性较低的候选内容,减少后续重排阶段的计算压力。

(2) 重排后的阈值过滤(精排过滤)

  • 位置:在重排模型对候选文档重新打分并排序之后。
  • 作用:作为第二道更严格的质量防线。由于重排模型能更精准地理解语义,此时的阈值过滤可以剔除那些虽然字面相似但实际不符合具体需求的低质量结果,确保最终输入给大模型(LLM)的上下文高度相关。

这种逐级收紧的策略能够有效降低无关信息带来的干扰,提升最终回答的准确度。

三、上下文压缩

在前面的章节中,我们探讨了如何通过重排序和分数阈值过滤,为大模型筛选出高置信度的参考资料。然而,在实际工程落地中,检索后处理的优化并未就此止步,仅靠重排、两道过滤这三个环节往往还不够。为了进一步提供更高质量的资料、突破大模型上下文窗口的物理限制并提升推理效率,我们需要在检索与生成之间引入一道更为精细的提纯工序:上下文压缩(Context Compression)

1. 为什么需要上下文压缩?

为什么在已经具备严格过滤机制的前提下,为什么我们依然迫切需要对检索结果进行二次压缩呢?

因为重排与过滤虽然保证了入选文档的宏观相关性,却无法消除文档内部微观层面的信息冗余。其本质上都是在"文档块(Chunk)"级别做文章,它们能确保送入 Prompt 的每一个 Chunk 都是高度相关的(高置信度),但无法保证 Chunk 内部的每一句话都切中要害(非高质量)。比如一个 500 字的 Chunk 中,可能只有两三句话是真正的核心答案,其余全是背景铺垫或冗余信息。如果直接将这些长文本拼接后喂给大模型,不仅会白白消耗宝贵的上下文窗口和 Token 成本,还会稀释模型的注意力,甚至触发"迷失在中间(Lost in the Middle)"效应,导致关键信息被忽略。因此,为了进一步提纯上下文,我们需要在检索后处理链路的末端引入上下文压缩,主要有以下四个核心目的:

(1)解决"文档相关,但局部无关"的问题(提升信噪比)

一个500字有400字是铺垫的相关Chunk,如果不做压缩,这 400 字的噪声会稀释大模型的注意力,甚至诱发幻觉。压缩技术(如关键句提取)能精准剔除这些相关但冗余的信息,让大模型 100% 聚焦于核心信息。

(2)对抗大模型的"迷失在中间"效应

研究表明,大语言模型在处理长上下文时,对开头和结尾的信息记忆最深,而对中间部分的信息容易"失忆"或忽略。 重排虽然把最相关的文档排到了前面,但如果多个长文档拼接在一起,上下文依然很长,关键信息很容易被挤到中间位置。通过压缩把冗长的文本浓缩成精简的摘要或关键句,不仅缩短了长度,还能让核心证据更加突出,极大提升模型对关键信息的利用率。

(3) 突破上下文窗口限制与降低推理成本

大模型的上下文窗口是有限的,且处理长文本的计算复杂度极高。当召回的 Top-K 文档较多时,即使过滤后,总 Token 数也可能超出模型限制。压缩是防止上下文爆满溢出的最有效手段。另外,大模型的 API 调用是按 Token 计费的,将 10 个 500 Token 的文档压缩成 10 个 100 Token 的精华,能直接节省 80% 的 Token 消耗,大幅降低每次查询的成本。除了防溢出和降成本外,降延迟也是压缩上下文的好处,输入的 Token 越少,大模型生成首字的时间(TTFT)就越快,用户体验越好。

(4) 允许检索阶段更宽松的召回

上下文压缩的存在,实际上反向赋能了检索阶段。有了压缩兜底,我们在初步检索时可以放宽阈值,多召回一些候选文档(比如从 Top-5 扩大到 Top-20),以确保不遗漏任何潜在答案。然后,通过重排和压缩的组合拳,把这些粗糙但全面的材料精炼成高纯度的上下文。这既保证了高召回率,又保证了最终的生成质量。

简单来说,重排和阈值过滤解决的是"文档级别(Chunk-level)"的筛选问题,而上下文压缩(Compression)解决的是"内容级别(Token-level)"的提纯问题。

2. 压缩算法与工程实践

在明确了上下文压缩的必要性之后,简单介绍下常见的压缩算法。在工程实践中,上下文压缩并非只有一种解法,而是根据对精度、速度与成本的不同侧重,演化出了三大主流技术流派。我们可以将它们形象地理解为给文档"做减法"、"做翻译"以及"做整理"。

2.1 抽取式压缩:给文档"做减法"

这是目前工业界应用最广泛、性价比最高的压缩方式。它的核心逻辑是保留原文,剔除噪音。它不会改变原始文本的措辞,而是通过算法识别并保留高价值片段,直接丢弃低价值内容。

在工程落地中,最典型的代表是基于 LLM 的文档内容提取(LLMChainExtractor)(如LangChain)。这种方法不再把整个 Chunk 喂给大模型,而是利用大语言模型的深度理解能力,遍历检索到的文档,只保留能回答用户 Query 的具体句子。它就像是用荧光笔在书上划线,精准剔除修饰性、过渡性的废话。这种策略的信息保真度极高,因为保留了原文原句,完全规避了模型重写带来的幻觉风险,非常适合法律、医疗等对严谨性要求极高的场景。

另一种轻量级的抽取式方法是嵌入过滤(EmbeddingsFilter)。这是一种基于向量相似度的二次清洗。即便一个文档块通过了重排序,其内部仍可能包含不相关的段落。我们可以将文档切分为更细粒度的句子,计算它们与用户 Query 的向量相似度,设定一个较高的阈值,只保留相似度极高的片段。由于它纯粹依靠数学计算,无需调用大模型,因此速度极快,适合对延迟敏感或资源受限的场景。

2.2 抽象式压缩:给文档"做翻译"

抽象式压缩的核心是利用大语言模型强大的理解与生成能力,对检索到的长文本进行重述。

在工程实践中,这通常表现为LLM 总结与重写。系统会将检索到的文档块发送给 LLM,并配合特定的 Prompt(例如:"请阅读以下文档,并根据用户问题提取相关信息,用简练的语言重写")。抽象式压缩的优势在于能极大提升信息的密度,将 500 字的背景铺垫压缩成 50 字的精华,且生成的语言流畅,大模型更易理解。然而,这种方法的劣势也同样明显,它不仅需要消耗额外的 LLM 调用费用,增加系统延迟,还存在幻觉风险,即模型可能在重写时曲解原意。因此,它更适合客服摘要、快速问答等对绝对严谨性要求稍低的场景。

2.3 上下文过滤与重组:给文档"做整理"

这一类方法介于上述两者之间,或者作为上述方法的补充,主要解决的是上下文窗口管理与冗余消除的问题。最基础的是文档级截断与滑动窗口。当检索到的相关文档总长度超过 LLM 窗口限制时,工程上常采用"滑动窗口"或"重叠块"策略,确保关键信息的完整性,同时优先保留重排序后排名靠前的文档。

此外,在处理复杂检索时,我们还会用到冗余过滤(EmbeddingsRedundantFilter)。它通过对比检索结果中文档间的向量相似性,剔除高度重复的内容,避免相同的信息在上下文中反复出现,从而进一步节约 Token。

2.4 工程进阶:组合流水线(DocumentCompressorPipeline)

在实际的高阶 RAG 系统中,我们往往不会单选一种策略,而是将多种方法串联起来,构建一条漏斗式的压缩流水线。

一个典型的工程链路如下:首先,使用嵌入过滤(EmbeddingsFilter)作为第一道防线,以极低的成本快速剔除明显无关的句子;接着,将剩下的候选内容送入 LLMChainExtractor 进行精修,精准定位答案所在的句子;最后,如果拼接后的 Token 依然超限,再触发摘要压缩作为兜底。这种混合策略兼顾了速度、成本与信息保真度,是目前生产环境中最推荐的上下文压缩范式。

3. LangChain 实现上下文压缩

LangChain 采用抽取式压缩的算法,它提供了 ContextualCompressionRetrieverLLMChainExtractor,可以自动完成压缩。

typescript 复制代码
import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression";
import { LLMChainExtractor } from "langchain/retrievers/document_compressors/chain_extract";
import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({ modelName: "gpt-4o", temperature: 0 });

// 创建压缩器
const compressor = LLMChainExtractor.fromLLM(llm);

// 创建压缩检索器,它会自动执行"先检索,后压缩"的流水线
const compressionRetriever = new ContextualCompressionRetriever({
  baseCompressor: compressor,
  baseRetriever: vectorStore.asRetriever({ k: 10 }),
});

// 使用
const compressedDocs = await compressionRetriever.invoke(query);

console.log("压缩前总长度:", docs.reduce((sum, d) => sum + d.pageContent.length, 0));
console.log("压缩后总长度:", compressedDocs.reduce((sum, d) => sum + d.pageContent.length, 0));

4. 自定义压缩实现

如果你想更精细地控制压缩过程,可以自己实现压缩算法。

typescript 复制代码
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";

class ContextCompressor {
  private llm: ChatOpenAI;

  constructor(llm: ChatOpenAI) {
    this.llm = llm;
  }

  /**
   * 压缩单个文档,提取与查询相关的部分
   */
  async compressDocument(
    query: string,
    doc: Document
  ): Promise<Document | null> {
    const systemPrompt = `你是一个信息提取专家。请从给定的文档中提取与用户查询最相关的句子。

要求:
1. 只保留与查询直接相关的句子
2. 保持原文表述,不要改写
3. 如果没有相关信息,返回空字符串
4. 最多保留 3 个句子`;

    const userPrompt = `查询: ${query}

文档:
${doc.pageContent}

请提取相关句子:`;

    const messages = [
      new SystemMessage(systemPrompt),
      new HumanMessage(userPrompt),
    ];

    const response = await this.llm.invoke(messages);
    const extracted = (response.content as string).trim();

    // 如果提取结果为空,返回 null
    if (!extracted || extracted.length < 10) {
      return null;
    }

    // 返回压缩后的文档
    return new Document({
      pageContent: extracted,
      metadata: doc.metadata,
    });
  }

  /**
   * 批量压缩文档
   */
  async compressDocuments(
    query: string,
    docs: Document[]
  ): Promise<Document[]> {
    const compressedDocs: Document[] = [];

    for (const doc of docs) {
      const compressed = await this.compressDocument(query, doc);
      
      if (compressed) {
        compressedDocs.push(compressed);
      }
    }

    return compressedDocs;
  }
}

// 使用
const compressor = new ContextCompressor(
  new ChatOpenAI({ modelName: "gpt-4o", temperature: 0 })
);

const docs = await retriever.invoke(query);
const compressedDocs = await compressor.compressDocuments(query, docs);

console.log(`压缩前: ${docs.length} 个文档`);
console.log(`压缩后: ${compressedDocs.length} 个文档`);

5. 上下文压缩的成本与收益

引入上下文压缩本质上是一场关于计算资源与模型注意力的博弈。从成本维度来看,是以前置的计算成本置换后端的推理红利。

首先是延迟增加 ,这是最显著的痛点。由于压缩过程需要对每个检索到的文档单独调用 LLM,假设单次处理耗时 500ms,处理 10 个文档将直接带来 5 秒的额外延迟。其次是API 费用,以 gpt-4o 为例,压缩 10 个文档(假设每个文档包含 200 输入 Token 和 50 输出 Token)的额外成本约为:

10×(200+50)× 51,000,000 =0.012510 \times (200 + 50) \times \frac{\5}{1,000,000} = \\0.0125 10×(200+50)×1,000,0005=0.0125

然而,这种投入能带来显著的收益。最直接的是减少噪声 ,通过剔除无关信息,大幅提升了答案的质量与可解释性 ,让用户看到的内容更加聚焦。同时,由于压缩后的 Prompt 显著变短,也有效节省了 Token,降低了后续主任务中 LLM 的调用成本。

基于上述权衡,该策略的适用场景非常明确:它非常适合对回答质量要求极高(如法律、医疗、金融)、检索结果冗余度高,且对延迟不敏感(如离线分析、邮件回复)的场景。反之,对于延迟要求在 2 秒以内的实时对话系统或高频查询场景,由于其高昂的时间与资金成本,并不推荐使用。

6. 轻量级压缩替代方案

如果不想承担 LLM 压缩的成本,可以用以下轻量级方案。

(1)基于关键词的句子提取

这是一种"点状"的提取策略。它将文档拆解为独立的句子,然后统计每个句子中包含用户 Query 关键词的数量。得分最高的 Top-N 个句子被保留并拼接

typescript 复制代码
function extractRelevantSentences(
  query: string,
  docContent: string,
  maxSentences: number = 3
): string {
  // 提取查询中的关键词
  const keywords = query
    .toLowerCase()
    .split(/\s+/)
    .filter(word => word.length > 2);  // 过滤短词

  // 按句子分割文档
  const sentences = docContent.split(/[。!?.!?\n]+/).filter(s => s.trim().length > 0);

  // 计算每个句子的相关性得分
  const scoredSentences = sentences.map(sentence => {
    const lowerSentence = sentence.toLowerCase();
    const score = keywords.filter(kw => lowerSentence.includes(kw)).length;
    return { sentence, score };
  });

  // 按得分排序,取前 N 个
  const topSentences = scoredSentences
    .sort((a, b) => b.score - a.score)
    .slice(0, maxSentences)
    .map(s => s.sentence.trim());

  return topSentences.join("。");
}

// 使用
const compressed = extractRelevantSentences(query, doc.pageContent);

这种方法虽然不如 LLM 智能,但实现极简,执行速度极快,能精准定位到包含答案的具体句子,最大程度剔除无关句子。当然,由于是按单句打分,可能会破坏上下文的连贯性,导致提取出的句子之间缺乏逻辑过渡;同时,它高度依赖关键词匹配,无法处理同义词或语义相关的复杂表达。

(2)滑动窗口截取

这是一种"面状"的提取策略。它不破坏句子的完整性,而是设定一个固定大小的窗口(如 200 个字符),以一定的步长(如 50 个字符)在文档上滑动。它会计算每个窗口内包含的关键词总数,最终只保留"关键词最密集"的那一个窗口。

typescript 复制代码
function slidingWindowExtract(
  query: string,
  docContent: string,
  windowSize: number = 200
): string {
  // 找到查询关键词在文档中的位置
  const keywords = query.split(/\s+/).filter(w => w.length > 2);
  
  let bestPosition = 0;
  let maxKeywordCount = 0;

  for (let i = 0; i < docContent.length - windowSize; i += 50) {
    const window = docContent.slice(i, i + windowSize).toLowerCase();
    const keywordCount = keywords.filter(kw => window.includes(kw.toLowerCase())).length;
    
    if (keywordCount > maxKeywordCount) {
      maxKeywordCount = keywordCount;
      bestPosition = i;
    }
  }

  // 返回关键词最密集的窗口
  return docContent.slice(bestPosition, bestPosition + windowSize);
}

滑动窗口保留了局部的上下文连贯性,避免了断章取义;非常适合处理"答案集中在某一段落"的场景。缺点则是窗口大小和步长是固定的,如果答案跨越了窗口的边界,可能会被截断;同样受限于关键词匹配的局限性。

四、完整的后处理流水线

到此,我们已经把RAG检索和检索后处理的相关环节都介绍完了。将检索、重排序、压缩、过滤整合为一个完整的处理流水线,大致如下:

flowchart TD %% 定义节点样式 classDef process fill:#e1f5fe,stroke:#01579b,stroke-width:2px; classDef decision fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; classDef terminator fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px; %% 流程节点 A([用户提问 Query]) --> B[初召回 Retrieval<br/>向量检索 / BM25 / 混合检索] B --> C{分数阈值过滤<br/>第一次过滤} C -- 全部低于阈值 --> D([触发拒答机制<br/>返回标准兜底回复]) C -- 存在高于阈值结果 --> E[重排序 Rerank<br/>Cross-Encoder 二次精排] E --> F{分数阈值过滤<br/>第二次过滤} F -- 全部低于阈值 --> D F -- 存在高于阈值结果 --> G[上下文压缩 Compression<br/>抽取式提取 / 抽象式摘要 / 嵌入过滤] G --> H[拼接 Prompt 注入 LLM] H --> I([生成最终回答与引用]) %% 应用样式 class B,E,G,H process; class C,F decision; class A,D,I terminator;

我们可以将整个流水线线的代码封装成一个通用的检索后处理工具:

typescript 复制代码
class PostProcessingPipeline {
  private vectorStore: Chroma;
  private cohere: CohereClient;
  private compressor: ContextCompressor;
  private preRerankThreshold: number; // 重排前的阈值
  private postRerankThreshold: number; // 重排后的二次过滤阈值

  constructor(options: {
    vectorStore: Chroma;
    cohereApiKey: string;
    llm: ChatOpenAI;
    preRerankThreshold?: number;
    postRerankThreshold?: number;
  }) {
    this.vectorStore = options.vectorStore;
    this.cohere = new CohereClient({ token: options.cohereApiKey });
    this.compressor = new ContextCompressor(options.llm);
    this.preRerankThreshold = options.preRerankThreshold || 0.75;
    this.postRerankThreshold = options.postRerankThreshold || 0.65; // 重排后的分数分布通常更合理,阈值可适当调整
  }

  async process(query: string): Promise<Document[]> {
    console.log("\n【后处理流水线】开始\n");

    // 第一步:初始检索
    console.log("Step 1: 初始检索 Top-20");
    const initialResults = await this.vectorStore.similaritySearchWithScore(query, 20);
    console.log(`  检索到 ${initialResults.length} 个文档\n`);

    // 第二步:第一次阈值过滤(初检过滤)
    console.log("Step 2: 第一次阈值过滤");
    const filtered = initialResults
      .filter(([_, score]) => score >= this.preRerankThreshold)
      .map(([doc, score]) => ({ doc, score }));
    
    console.log(`  过滤后剩余 ${filtered.length} 个文档\n`);

    if (filtered.length === 0) {
      console.log("  ⚠️ 所有文档都被过滤,返回空结果");
      return [];
    }

    // 第三步:Rerank 重排序
    console.log("Step 3: Rerank 重排序");
    const reranked = await this.rerank(query, filtered);
    console.log(`  重排后返回 ${reranked.length} 个文档\n`);

    // 第四步:第二次阈值过滤(重排后精排过滤)
    console.log("Step 4: 第二次阈值过滤(重排后)");
    const postFiltered = reranked.filter(r => r.score >= this.postRerankThreshold);
    console.log(`  二次过滤后剩余 ${postFiltered.length} 个文档\n`);

    if (postFiltered.length === 0) {
      console.log("  ⚠️ 重排后所有文档均低于阈值,返回空结果");
      return [];
    }

    // 第五步:上下文压缩
    console.log("Step 5: 上下文压缩");
    const compressed = await this.compressor.compressDocuments(
      query,
      postFiltered.map(r => r.doc)
    );
    console.log(`  压缩后剩余 ${compressed.length} 个文档\n`);

    console.log("【后处理流水线】完成\n");

    return compressed;
  }

  private async rerank(
    query: string,
    docs: Array<{ doc: Document; score: number }>
  ): Promise<Array<{ doc: Document; score: number }>> {
    const documents = docs.map(d => d.doc.pageContent);
    
    const rerankResult = await this.cohere.rerank({
      query,
      documents,
      topN: 5,
      model: "rerank-multilingual-v3.0",
    });

    return rerankResult.results.map(r => ({
      doc: docs[r.index].doc,
      score: r.relevanceScore,
    }));
  }
}

// 使用
const pipeline = new PostProcessingPipeline({
  vectorStore,
  cohereApiKey: process.env.COHERE_API_KEY!,
  llm: new ChatOpenAI({ modelName: "gpt-4o", temperature: 0 }),
  preRerankThreshold: 0.75,
  postRerankThreshold: 0.65,
});

const finalDocs = await pipeline.process("如何配置数据库连接池?");

FAQ

Q1:Reranker 每次多消耗多少成本?

Cohere Rerank 的定价按搜索次数计算,通常在 $0.001-0.002 每次查询。相比 LLM 调用的成本,Reranker 的开销通常可以忽略。

以每月 10,000 次查询为例:

ini 复制代码
Rerank 成本: 10,000 × $0.002 = $20
LLM 成本: 10,000 × $0.01 = $100
总成本: $120

Rerank 只占总成本的 17%,性价比很高。

Q2:可以跳过 Rerank 直接用 Cross-Encoder 做全量检索吗?

技术上可以,但实际不可行。对 100 万条文档分别做 Cross-Encoder 推理需要数小时。Bi-Encoder 就是为了解决全量检索的效率问题而存在的。

唯一的例外是你的知识库非常小(< 1000 条),这种情况下可以直接用 Cross-Encoder。但即使如此,两阶段检索仍然是更好的选择,因为未来知识库可能会扩大。

Q3:上下文压缩和 Reranker 能一起用吗?

可以,而且是推荐的做法。流程为:

  1. 检索 Top-20
  2. 阈值过滤
  3. Reranker 排序选 Top-5
  4. 上下文压缩去掉无关句子
  5. 组装 Prompt

每一步都在提升最终进入 LLM 的信息质量。

但要注意延迟累积:

makefile 复制代码
检索: 10ms
过滤: 1ms
Rerank: 2s
压缩: 5s
总计: ~7s

对于实时应用,可能需要舍弃压缩环节,或者使用轻量级压缩方案。

Q4:如何选择合适的阈值?

最好的方法是用你自己的数据标定(见上文"如何标定阈值"一节)。如果没有标注数据,可以从以下经验值开始:

  • text-embedding-3-small:0.75
  • BGE-M3:0.7
  • text-embedding-ada-002:0.8

然后根据用户反馈调整。如果发现太多不相关结果,提高阈值;如果发现漏掉了很多相关结果,降低阈值。

Q5:后处理会增加多少延迟?

典型的延迟分布:

makefile 复制代码
初始检索: 10ms
阈值过滤: 1ms
Rerank (20→5): 2-3s
上下文压缩 (5 个文档): 3-5s
总计: 5-8s

相比之下,无后处理的检索只需 10-20ms。后处理的延迟主要来自 Rerank 和压缩两个 LLM 调用环节。

优化策略:

  • 并行执行多个 LLM 调用
  • 使用更快的模型(如 gpt-3.5-turbo 代替 gpt-4o)
  • 只对高分候选做压缩
  • 缓存常用查询的结果

练习

练习 1:实现 Reranked Retrieval

在你《第7章:基础篇实战:文档问答机器人》的项目中接入 Cohere Rerank API(提供免费额度),实现"检索 20 → Rerank 5"的流水线。对 5 个查询对比 Rerank 前后的 Top-3 变化。

验证标准

  • 对于至少 2 个查询,Rerank 后 Top-3 中重新排序或被替换的文档更相关
  • 能够量化展示 Rerank 带来的改进(如 MRR 提升)
  • 记录 Rerank 的延迟和成本

提示

typescript 复制代码
// 注册 Cohere 账号获取免费 API Key
// https://dashboard.cohere.com/api-keys

练习 2:标定你的分数阈值

对 100 个查询记录检索 Top-5 的相似度分数,手动标注哪些文档是真正相关的。根据标注数据标定一个合适的阈值。

验证标准

  • 阈值能将 > 90% 的真正相关文档保留
  • 同时过滤掉 > 50% 的不相关文档
  • 绘制 Precision-Recall 曲线,展示不同阈值下的表现

提示

typescript 复制代码
// 准备标注数据
const labeledData = [
  {
    query: "如何配置数据库连接池?",
    results: [
      { docId: "doc_123", score: 0.85, relevant: true },
      { docId: "doc_456", score: 0.82, relevant: true },
      { docId: "doc_789", score: 0.78, relevant: false },
      // ...
    ],
  },
  // ... 至少 20 个查询
];

练习 3:实现轻量级上下文压缩

不使用 LLM,而是基于关键词匹配或滑动窗口实现一个轻量级的上下文压缩器。对比与 LLM 压缩的效果和性能差异。

验证标准

  • 压缩率至少达到 50%
  • 压缩后的内容仍然包含关键信息
  • 延迟比 LLM 压缩低 10 倍以上

提示

参考上文"轻量级压缩替代方案"一节中的代码示例。

练习 4:构建完整的后处理流水线

将阈值过滤、Rerank、压缩整合为一个完整的流水线。对比启用和禁用各个环节的效果差异。

验证标准

  • 能够单独启用/禁用每个环节
  • 量化每个环节带来的改进(精确率、召回率、延迟)
  • 给出推荐的配置方案

📚 延伸阅读

相关推荐
阿里云大数据AI技术1 小时前
使用 PAI,一键拉起云端 AI “投资智囊团”
人工智能·agent
四六的六1 小时前
让 AI 自己去点后台页面,它把我们的库存点没了
前端·人工智能·agent·个人开发·ai编程·ai产品·ai前端
月华路1 小时前
《模型不玄学》第30章 完整跑一遍 Cheat Sheet
人工智能·深度学习·机器学习
workflower1 小时前
智能体-企业行政问答助手
人工智能·机器学习·机器人·云计算·无人机
武子康1 小时前
Agent 的工具没变,SGLang 缓存为什么没命中?
人工智能·llm·agent
hyunbar7771 小时前
LangChain 实战:Agent 4类结构化输出方式
人工智能
咖啡星人k2 小时前
2026 可观测性实战:把观测契约写进SPEC,MonkeyCode 云端跑通
人工智能·机器学习
阿里云大数据AI技术2 小时前
基于阿里云Milvus 知识库服务,快速搭建智能客服问答系统实践
人工智能·agent
RAOY的AI笔记2 小时前
GPT-6 Astra开发指南:API调用、工具使用与AI Agent应用思路
大数据·人工智能·gpt