Skip to content
RAG 检索优化概述:目标与评价维度
检索增强生成(Retrieval-Augmented Generation,RAG)通过外部知识库为语言模型提供上下文。当用户提出一个问题时,RAG 先从文档集合中找出相关片段,再把片段拼接到提示词中交给生成模型。这个流程中,检索链路负责决定“生成模型能看到哪些信息”。检索结果缺少关键文档时,生成模型无法凭空补全;检索结果携带大量无关片段时,生成模型的注意力也会被分散。
检索优化的目标可以拆成三点:
- 提高召回率:相关内容首先要出现在候选列表中。相关文档没有被召回,后续的重排和生成都无法补救。
- 提高排序精度:相关内容要尽量排在前面。最终传给生成模型的上下文通常只保留前 k 条,排在后面的文档会被截断。
- 降低噪声:减少与查询无关的片段数量,避免生成模型从噪声中推断出错误信息。
评价检索质量时,需要把它与生成质量分开。检索指标只评价“检索结果与查询的相关性”,不评价“答案是否正确”。如果用端到端答案质量来反推检索质量,生成模型的偏好和缺陷会混入评价结果。例如,一个生成模型可能在没有相关上下文的情况下仍然输出看起来合理的答案,这会让一个检索质量很差的系统在端到端评估中得分偏高。
RAG 检索阶段常用的离线指标有:
- Recall@k:前 k 条检索结果对相关文档的覆盖率,只关心相关文档是否出现在前 k 条中,不关心排序[1]。比如正确文档是 Doc B,系统返回 Doc X、Doc A、Doc B、Doc Y、Doc Z 时,
Recall@1=0、Recall@2=0、Recall@3=1、Recall@5=1。 - Precision@k:前 k 条检索结果中相关文档所占的比例,衡量检索结果中的噪声比例。与 Recall@k 配合使用,可以同时观察“有没有漏掉相关文档”和“检索结果中有多少无关内容”。
- MRR(Mean Reciprocal Rank):第一个相关结果排名的倒数。适合只需要一个正确答案的问答系统。
- MAP(Mean Average Precision):对单个查询,计算每个相关文档被召回到的位置上的 Precision,再取平均;对所有查询再平均。只支持二元相关性,适合多文档摘要等需要考察全部相关文档排序的场景。
- NDCG(Normalized Discounted Cumulative Gain):支持多级相关性的排序质量指标,相关度高的文档排在前面时得分更高。
生成阶段则使用另一类指标。RAGAS 中的 Faithfulness 衡量生成答案中有多少陈述可以被检索到的上下文支持,用于度量幻觉程度[2]。但需要注意,Faithfulness 高只代表“没有编造”,不代表“回答了问题”。一个只复述上下文原文却没有回答提问的答案,Faithfulness 可能接近 1.0,实用价值却很低[2]。
RAG 检索链路全景:召回、重排与上下文组装
一条完整的 RAG 检索链路通常由以下环节组成:
用户查询
→ 查询处理(改写、扩展)
→ 召回(BM25 / 稠密向量 / 混合检索)
→ 分数融合(RRF 或归一化加权)
→ 重排序(Cross-Encoder 等模型)
→ 上下文组装(去重、截断、排序)
→ 生成各环节的职责如下:
- 召回:在完整文档集合中快速获取候选集。这个阶段追求高召回率,允许候选集中存在部分噪声。候选数量通常为 20~200 条。
- 分数融合:当存在多路召回(例如 BM25 和向量检索各返回一批结果)时,把不同方法的得分或排名合并为统一的排序。
- 重排序:对召回阶段的候选集做精细化调整。排序模型通常比召回模型更复杂、更准确,但计算代价也更高。
- 上下文组装:把重排后的文档去重、截断,按相关性顺序组织成提示词片段。这个环节决定了最终进入生成模型的上下文内容。
召回和重排采用不同模型的原因在于效率与精度的权衡。召回阶段面对全量文档,必须使用可以预先索引的高效方法;重排阶段只面对少量候选,可以使用更复杂的交互式模型。因此,两阶段检索引擎在 RAG 中是一种常见结构:召回阶段负责“找全”,重排阶段负责“找准”。
召回准备:分块策略与 Embedding 模型选择
分块策略
文档不能整体作为一条记录送入向量检索。嵌入模型有输入长度限制,且过长的文本段落在向量空间中往往语义混杂。分块的目标是把文档切分成语义相对完整的片段,使每个片段尽可能独立地表达一个主题。
常见的分块方式:
- 固定大小分块:按字符数或 token 数切分。实现简单,但会切断句子和段落。
- 递归字符分割:按分隔符优先级逐层切分,先按段落、再按句子、最后按字符,尽量保持语义边界完整。LangChain 的
RecursiveCharacterTextSplitter属于此类。 - 语义分块:通过嵌入向量判断相邻句子的语义连贯性,在语义断裂处切分。计算成本更高,但切分边界更贴近内容结构。
分块重叠(overlap)的作用是缓解边界截断问题。相邻块之间保留一小段重叠文本,可以避免一个完整句子或概念恰好被切成两段。重叠量过小则补偿不足,过大则会产生大量重复内容,占用向量存储和上下文空间。
示例:使用 RecursiveCharacterTextSplitter 按块大小和重叠量分割长文本:
typescript
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const chunks = await splitter.splitText(longText);
console.log(chunks.length);chunkSize 与 chunkOverlap 的单位是字符数,具体数值应根据文档类型和嵌入模型上下文长度调整。这里不需要为所有场景固定一个推荐值;分块参数应当作为评测变量,在数据集上比较不同配置的效果。
Embedding 模型选择
嵌入模型负责把文本映射为向量。模型的选择直接影响检索效果的语义边界。
选择时需要考虑:
- 通用模型与领域模型:通用嵌入模型在开放域问题上表现稳定;领域数据分布特殊时,微调过的领域模型可能表现更好。
- 模型上下文长度:嵌入模型能接受的最大输入长度决定了分块大小的上限。
- 向量维度:维度影响向量存储的占用和检索延迟,但维度与效果并不成正比。
- 参考基准:MTEB、BEIR 等公开基准可以用于初步筛选模型,最终应以业务评测集上的结果为准。
召回策略:BM25、稠密向量与混合检索
BM25 稀疏检索
BM25 是一种基于词频和逆文档频率的传统检索算法。它把查询和文档都拆成词项,按词项匹配程度打分,属于稀疏检索。
BM25 的优点在于精确匹配:专业术语、产品名、编号这类词语,BM25 可以准确命中。缺点则是词汇鸿沟:查询中使用的词语与文档中的词语不同时,即使语义相同,BM25 也无法匹配。例如查询“如何卸载软件”,文档中写“移除应用程序”,两者措辞不同,BM25 的得分会偏低。
稠密向量检索
稠密向量检索先用嵌入模型把查询和文档编码成向量,再通过余弦相似度或内积计算相关性。它能把“卸载”与“移除”这类语义相近但词面不同的文本映射到接近的向量区域,弥补词汇鸿沟问题。
向量检索的短板在于:嵌入模型的训练语料决定了它的语义理解边界,遇到冷门术语或专有名词时,向量检索可能给出并不相关的结果;同时,向量检索对精确词匹配的支持较弱,检索一个不常见的编号时往往不如 BM25。
混合检索
混合检索同时执行稀疏检索和稠密检索,再把结果合并。它试图结合两种检索方式的互补性:
- 精确匹配场景由 BM25 兜底;
- 语义匹配场景由向量检索兜底;
- 多路结果合并后,整体召回率通常高于任意单一路。
下面是一个在 LangChain.js 中组合 BM25 与向量检索的示例。BM25Retriever 来自 @langchain/community,向量检索器由向量存储的 asRetriever() 方法生成:
typescript
import { BM25Retriever } from "@langchain/community/retrievers/bm25";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
const docs = [
"RAG 是检索增强生成的缩写",
"重排序模型对候选文档重新打分",
"BM25 适用于精确词匹配",
];
// 稀疏检索
const bm25Retriever = BM25Retriever.fromDocuments(docs, { k: 3 });
// 稠密向量检索
const vectorStore = await MemoryVectorStore.fromTexts(
docs,
{},
new OpenAIEmbeddings(),
);
const vectorRetriever = vectorStore.asRetriever(3);
const bm25Result = await bm25Retriever.invoke("BM25 有什么特点");
const vectorResult = await vectorRetriever.invoke("BM25 有什么特点");得到两路结果后,还需要把候选文档按照统一的规则合并,这一步在下一节介绍。
多路召回分数融合:RRF 与归一化方法
多路召回产生多个排序列表后,需要融合成一份最终候选列表。两路检索器的分数尺度不同:BM25 的得分取决于词频和文档长度,向量检索的余弦相似度范围通常是 [-1, 1],直接比较分数没有意义。
RRF(Reciprocal Rank Fusion)
RRF 不关心原始分数,只使用排序位置。一个文档的融合得分是所有路召回中该文档排名倒数的累加:
score(d) = Σ 1 / (k + rank_r(d))其中 rank_r(d) 是文档 d 在第 r 路结果中的排名,k 是平滑常数,取 60 是常见的做法。排名越靠前,贡献越大;某一路没有召回的文档不贡献分数。
RRF 的优点是不需要归一化,也不需要假设各路分数的分布。下面是用 TypeScript 实现的一个 RRF 融合函数:
typescript
function rrfFuse(rankings: string[][], k = 60): Map<string, number> {
const scores = new Map<string, number>();
for (const ranking of rankings) {
ranking.forEach((docId, index) => {
const rank = index + 1;
scores.set(docId, (scores.get(docId) ?? 0) + 1 / (k + rank));
});
}
return new Map(
[...scores.entries()].sort((a, b) => b[1] - a[1]),
);
}
// 两路召回的文档 ID 序列
const bm25Ranking = ["doc-3", "doc-1", "doc-5"];
const vectorRanking = ["doc-1", "doc-2", "doc-4"];
const fused = rrfFuse([bm25Ranking, vectorRanking]);
console.log([...fused.entries()]);
// doc-1:两路排名分别为 2 和 1,得分 1/(60+2) + 1/(60+1) ≈ 0.0325
// doc-3:仅第 1 路排名第 1,得分 1/(60+1) ≈ 0.0164doc-1 在两路结果中都出现,因此融合得分高于只出现在单一路中的文档。doc-1 的两个排名(2 和 1)分别贡献约 0.0161 和 0.0164,合计约 0.0325;而 doc-3 只在第一路出现,得分为 1/61 约 0.0164。
归一化分数融合
有些场景需要保留原始分数信息,此时可以先对分数做归一化,再按权重加权求和。
min-max 归一化把分数映射到 [0, 1]:
s' = (s - min) / (max - min)z-score 归一化把分数转换为标准正态分布下的位置:
s' = (s - mean) / std归一化后的分数可以按路线权重相加。权重是两个检索路线的相对重要性,例如 BM25 权重取 0.4、向量检索权重取 0.6,这里的数值仅用于演示权重相加的形式,实际取值需要通过评测调整。
注意点
- RRF 对排序位置的波动比较敏感:同一文档在两路结果中排在 2 和 100,与排在 50 和 51,融合得分完全不同。
- 归一化分数融合对离群值敏感,min-max 会被极端分数拉偏。
- 无论使用哪种融合方式,都应该在评测集上比较融合前后的检索质量,确认融合确实带来了增益。
重排序模型:从 Bi-Encoder 到 Cross-Encoder
Bi-Encoder
Bi-Encoder 把查询和文档分别编码为独立向量。查询和文档各自经过嵌入模型,再用相似度函数计算得分。
这种方式适合召回阶段:文档向量可以离线计算并建立索引,在线时只需要计算一次查询向量,然后与索引中的向量做近似最近邻搜索,延迟低、吞吐高。
Bi-Encoder 的局限在于,查询与文档在编码过程中没有 token 级交互,模型只能基于两个独立向量的相似度做判断。查询中的修饰词、否定词、条件限定等信息,在独立编码时容易被压缩或丢失。
Cross-Encoder
Cross-Encoder 把查询和文档拼接成一个序列,送入 Transformer,直接输出相关性得分。模型可以看到查询与文档之间每个 token 的交互,因此相关性判断更为精细。
代价是无法预计算文档表示。每一条候选文档都需要与查询拼接后重新过一遍模型,计算量远高于 Bi-Encoder。对于几千条候选,Cross-Encoder 的推理时间会明显增加;对于几十到几百条候选,延迟通常在可接受范围内。
两阶段组合
RAG 检索链路中通常采用两阶段组合:
- 召回阶段使用 Bi-Encoder 或 BM25,从全量文档中快速得到候选集;
- 重排阶段使用 Cross-Encoder 对候选集逐条打分,取分数最高的前 k 条进入上下文组装。
这种方式兼顾了召回阶段的效率和重排阶段的精度。
重排序模型选型与集成
常用重排模型
常见的重排模型可分为自托管模型与托管 API 两类。开源的自托管模型包括 BAAI 的 bge-reranker 系列,它针对多语言语义相关性训练,适合离线部署;Cohere Rerank 是托管重排 API,调用方只需发送查询和候选文档列表,即可获得按相关性排序的结果。此外也有 Jina Reranker 等类似服务。选型时,应在业务评测集上比较不同模型带来的 NDCG 提升与推理延迟变化。
选型考虑
重排序模型的选型主要受以下因素影响:
- 模型结构:较小的 6 层 Transformer 模型推理延迟低,但相关性判断能力弱于更大的 12 层模型。
- 多语言支持:处理中文、日文等多语言内容时,需要选用多语言训练的 reranker。
- 领域适配:通用 reranker 在垂直领域可能不稳定,使用领域数据微调过的模型通常更可靠。
- 部署方式:自托管模型可以完全控制延迟和成本;托管 API 集成简单,但受网络延迟和服务商限流影响。
不管选择哪种模型,重排器在整个链路中的位置是相同的:接收召回阶段的候选集,输出按相关性重新排序后的文档列表。
集成方式
工程上,重排模型通常以独立服务的形式存在。检索服务调用重排服务时,只需把查询文本和候选文档列表通过 HTTP 传递给重排服务。下面是一个通过 HTTP 调用重排服务的示意:
typescript
async function rerank(
query: string,
documents: string[],
topN = 5,
): Promise<{ index: number; score: number }[]> {
const response = await fetch("http://localhost:8080/rerank", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, documents, top_n: topN }),
});
if (!response.ok) {
throw new Error(`rerank failed: ${response.status}`);
}
const data = await response.json();
return data.results;
}
const docs = [
"RAG 检索链路包含召回、重排和上下文组装。",
"今天的天气适合出门散步。",
"重排序使用交叉编码器精排候选文档。",
];
const results = await rerank("重排序是什么", docs, 2);
console.log(results.map((r) => ({ index: r.index, score: r.score })));接口字段的具体定义以实际部署的重排服务为准。代码中的 results 通常包含文档在原始候选列表中的下标和相关性分数,按分数降序排列。
集成位置
重排序一般放在分数融合之后、上下文组装之前。如果链路只有一路召回,则直接从召回结果进入重排。候选数量控制在几十到几百条范围内,因为 Cross-Encoder 的推理时间随候选数量线性增长。
注意点
- 重排后的文档如果数量超过生成模型的上下文限制,需要截断。截断策略会影响生成效果,通常保留排序最靠前的文档。
- 重排分数与召回分数来自不同模型,不要直接比较。
- 如果重排服务出现超时或故障,链路应能降级为直接使用召回阶段的结果,避免整个 RAG 服务不可用。
查询端检索增强:改写、扩展与 HyDE
查询端增强的目标是让查询本身更利于检索。用户的提问往往带有口语表达、指代词或隐含上下文,直接使用原始查询去检索,效果不稳定。
查询改写
查询改写把原始查询转换为更适合检索的形式。典型场景包括:
- 把对话中的指代词补全成完整问题;
- 把口语表达改写为书面表达;
- 把模糊查询拆成更具体的子问题。
下面的示例使用 LLM 把对话历史和当前问题改写为独立查询:
typescript
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
const rewritePrompt = PromptTemplate.fromTemplate(
`根据对话历史,把用户最后一个问题改写为一个可以独立检索的查询。
对话历史:
{history}
用户问题:{question}
改写后的查询:`
);
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const rewrite = rewritePrompt
.pipe(llm)
.pipe(new StringOutputParser());
const standaloneQuery = await rewrite.invoke({
history: "用户:RAG 是什么?\n助手:检索增强生成。",
question: "它有哪些检索优化方法?",
});改写后的查询比原始问句更适合作为检索入口。
查询扩展
查询扩展生成多个语义等价的查询变体,对每个变体分别执行召回,再融合结果。这种方式可以减少单一查询措辞带来的检索偏差。
查询扩展可以用 LLM 生成,也可以使用同义词表等更轻量的方式。生成多个查询后,多路召回的融合可以直接使用上一节介绍的 RRF。
HyDE
HyDE(Hypothetical Document Embeddings)的做法是:先用 LLM 根据查询生成一段假设性的回答文档,然后用这段假设文档的嵌入向量去检索真实文档。
HyDE 的原理是:假设文档在语义空间中比原始查询更接近真实的相关文档。一个短查询的向量可能落在语义空间的边缘位置,而一段完整的假设回答包含了更多与真实文档重叠的词汇和语义特征,因此检索效果可能更好。
下面是一个 HyDE 检索的简化实现:
typescript
import { OpenAIEmbeddings, ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
const hydePrompt = PromptTemplate.fromTemplate(
`请回答下面的问题。即使你不确定答案,也要写出一段看起来合理的回答。
问题:{question}`
);
async function hydeRetrieve(question: string, vectorStore: any, k = 5) {
const hydeChain = hydePrompt
.pipe(new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.7 }))
.pipe(new StringOutputParser());
const hypotheticalDoc = await hydeChain.invoke({ question });
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-small",
});
const queryEmbedding = await embeddings.embedQuery(hypotheticalDoc);
return vectorStore.similaritySearchVectorWithScore(queryEmbedding, k);
}注意点
- HyDE 依赖 LLM 生成的假设文档质量。如果 LLM 生成的假设内容与真实文档的表述差异很大,检索效果可能不升反降。
- 查询改写会引入一次额外的 LLM 调用,增加延迟。
- 查询扩展会放大召回阶段的候选数量,可能增加重排阶段的压力。
进阶检索增强:RAG-Fusion、迭代检索与自反思
RAG-Fusion
RAG-Fusion 是查询扩展的一种特殊形态。它生成多个查询变体,分别检索,再用 RRF 把多路结果融合成一份候选列表。RAG-Fusion 的价值在于:同一个问题从不同角度表述,可以提升相关文档被召回的概率。
RAG-Fusion 与普通查询扩展的区别在于融合方式:查询扩展如果只是把所有结果简单拼接,重复文档会占据多个位置;RAG-Fusion 使用 RRF 按排名融合,在两路结果中排名都靠前的文档会获得更高分数,从而得到更稳定的排序。
迭代检索
迭代检索在“检索 → 阅读 → 检索”之间循环。第一轮检索得到的文档可能包含指向其他文档的信息,例如一段文本提到“该问题的详细推导见第三章”,此时可以根据第一轮结果构造新的查询,再执行第二轮检索。
迭代检索适合多跳问答(multi-hop QA),但需要控制轮数,否则延迟会线性增长,且每一轮新增的文档可能偏离原始查询越来越远。
自反思检索
自反思检索在检索结果的基础上增加一个判断步骤:评估已召回的文档是否足以回答查询。评估结果决定是继续检索、修改查询,还是直接进入生成阶段。
Self-RAG 和 CRAG(Corrective RAG)是这类思路的代表。Self-RAG 通过自反思标记(reflection tokens)控制检索时机和生成过程;CRAG 使用评估器给检索结果打分,如果检索质量低,就触发查询改写或扩大召回范围。
在代码层面,自反思检索可以用一个循环表示:
typescript
async function reflectiveRetrieve(
query: string,
maxRounds = 2,
): Promise<Document[]> {
let currentQuery = query;
const collected = new Map<string, Document>();
for (let round = 0; round < maxRounds; round++) {
const candidates = await retriever.invoke(currentQuery);
for (const doc of candidates) {
collected.set(doc.id ?? doc.pageContent, doc);
}
const allDocs = [...collected.values()];
const enough = await judgeQuerySatisfied(query, allDocs);
if (enough) {
break;
}
const nextQuery = await generateNextQuery(query, allDocs);
currentQuery = nextQuery;
}
return [...collected.values()];
}judgeQuerySatisfied 和 generateNextQuery 分别负责评估相关性和生成下一轮查询,两者通常都由 LLM 实现。
检索质量评测:离线指标与线上实验
构建评测集
离线评测的第一步是构造评测集。每个评测样本包含一个查询和一组与该查询相关的文档 ID:
typescript
interface EvalExample {
query: string;
relevantIds: string[];
}
const evalSet: EvalExample[] = [
{
query: "什么是混合检索",
relevantIds: ["doc-7", "doc-12"],
},
{
query: "Cross-Encoder 和 Bi-Encoder 有什么区别",
relevantIds: ["doc-3"],
},
];相关文档的标注可以由人工完成,也可以先用现有系统检索结果生成候选,再由人工确认。评测集需要覆盖不同类型的查询:事实型、多跳型、模糊表达型等。
离线指标实现
Recall@k 的计算不关心排序,只关心前 k 条结果覆盖了多少相关文档:
typescript
function recallAtK(
relevantIds: Set<string>,
retrievedIds: string[],
k: number,
): number {
const topK = retrievedIds.slice(0, k);
const hits = topK.filter((id) => relevantIds.has(id)).length;
return relevantIds.size === 0 ? 0 : hits / relevantIds.size;
}MRR 计算首个相关结果的排名倒数:
typescript
function mrr(
relevantIds: Set<string>,
retrievedIds: string[],
): number {
const rank = retrievedIds.findIndex((id) => relevantIds.has(id));
return rank === -1 ? 0 : 1 / (rank + 1);
}NDCG 使用折损公式,同时考虑相关度等级和排序位置。下面是一个简化实现:
typescript
function ndcgAtK(relevance: number[], k: number): number {
const dcg = relevance
.slice(0, k)
.reduce((sum, rel, i) => sum + rel / Math.log2(i + 2), 0);
const ideal = [...relevance]
.sort((a, b) => b - a)
.slice(0, k)
.reduce((sum, rel, i) => sum + rel / Math.log2(i + 2), 0);
return ideal === 0 ? 0 : dcg / ideal;
}
console.log(ndcgAtK([3, 2, 0, 1], 4)); // 0.985该示例中,相关性序列 [3, 2, 0, 1] 的 DCG@4 为 3 + 2/log₂3 + 0 + 1/log₂5 ≈ 4.693;按相关性降序排列 [3, 2, 1, 0] 时的理想 DCG 为约 4.762,因此 NDCG@4 ≈ 4.693/4.762 ≈ 0.985。
评测流程
- 对评测集里的每个查询执行检索链路;
- 记录检索结果的文档 ID 序列;
- 计算各项指标并取平均;
- 对比不同链路配置的指标分值。
指标对比时需要固定生成模型和其他环节,否则无法把指标差异归因到检索链路。
线上实验
线上实验需要在真实流量中验证检索链路变更。常用的方法是把用户请求分流到基线链路和实验链路,比较两组的端到端指标。
需要注意的是,线上实验同时受到生成模型、提示词、用户行为等变量的影响。为了隔离检索链路的影响,可以单独记录检索阶段的可观测数据,例如:
- 检索耗时分布;
- 召回文档数量;
- 重排前后排序变化;
- 用户是否对检索结果进行点击或点赞。
这类数据能帮助定位检索链路本身的问题,与生成质量指标相互补充。
注意点
- 离线指标提升不等于线上体验提升,检索链路最终要放到真实用户流量中验证。
- 评测集需要持续更新,防止检索器过拟合到固定样本上。
- 指标计算时要统一相关文档的判定口径,不同标注人员之间可能存在主观差异。
框架实现模式:LangChain 与 LlamaIndex
LangChain:使用自定义 Retriever 封装重排逻辑
LangChain.js 把检索器抽象为 BaseRetriever(定义于 @langchain/core/retrievers)。继承这个类并实现 _getRelevantDocuments,就可以把任意检索逻辑接入 LangChain 的链式调用。调用检索器实例的 invoke(query) 时,基类负责参数校验并最终调用子类的 _getRelevantDocuments。
下面的示例展示了一个组合了召回和重排的检索器:
typescript
import { BaseRetriever } from "@langchain/core/retrievers";
import { Document } from "@langchain/core/documents";
class RerankRetriever extends BaseRetriever {
lc_namespace = ["rag", "rerank_retriever"];
constructor(
private readonly baseRetriever: BaseRetriever,
private readonly reranker: (
query: string,
docs: Document[],
) => Promise<Document[]>,
private readonly topK = 5,
) {
super();
}
async _getRelevantDocuments(query: string): Promise<Document[]> {
const candidates = await this.baseRetriever.invoke(query);
const reranked = await this.reranker(query, candidates);
return reranked.slice(0, this.topK);
}
}使用这个检索器时,构造一个基础检索器和一个重排函数即可:
typescript
const baseRetriever = vectorStore.asRetriever(50);
const rerankRetriever = new RerankRetriever(
baseRetriever,
async (query, docs) => {
const ranked = await rerankByHttp(query, docs.map((d) => d.pageContent));
return ranked.map((r) => docs[r.index]);
},
5,
);调用 rerankRetriever.invoke(query) 时,LangChain 会先调用 _getRelevantDocuments 获取重排后的文档。这样一个自定义 Retriever 可以像普通检索器一样接入链式调用。
LangChain.js 还提供了 EnsembleRetriever 用于组合多个检索器,以及 MultiQueryRetriever 用于生成多个查询变体后合并检索结果。这两个类同样定义在框架的 retrievers 模块中,具体导出位置与构造参数随版本变化,使用前应查阅对应版本的官方文档。
LlamaIndex:用查询引擎组合检索与增强
LlamaIndex 的核心概念是 QueryEngine。一个查询引擎由 Retriever 和 ResponseSynthesizer 构成。Retriever 负责从索引中获取候选节点,ResponseSynthesizer 负责把候选节点组装成上下文并交给 LLM 生成回答。
要加入重排逻辑,可以替换 Retriever 组件:先实现一个内部包含召回和重排的检索器,再把它传给查询引擎。LlamaIndex 也提供了查询变换工具,例如 HyDEQueryTransform,在查询进入检索器之前生成假设文档。
两种框架在检索链路优化上的模式是一致的:
- 检索器负责获取候选;
- 重排逻辑可以在检索器内部或检索器之后;
- 查询变换发生在检索之前。
具体 API 名称和导入路径在不同的框架版本中有差异,建议以各框架官方文档为准。
总结与进一步阅读
RAG 检索链路优化的核心是把“找全”和“找准”分开处理。召回阶段使用 BM25、稠密向量或混合检索扩大候选范围;分数融合阶段用 RRF 或归一化方法合并多路结果;重排阶段用 Cross-Encoder 精细排序;查询端增强则通过改写、扩展、HyDE 等方式改善查询本身的检索质量。
评价检索链路时,应以 Recall@k、MRR、NDCG 等离线指标为主,同时结合线上实验观察真实用户反馈。检索质量与生成质量应分开评估,避免生成模型的偏差掩盖检索链路的问题。
参考链接
- 腾讯云开发者社区,RAG 检索阶段评估指标说明:https://developer.cloud.tencent.com/article/2658116
- Agent-Interview-100,RAG 评估指标整理:https://github.com/BigKunLun/Agent-Interview-100/blob/main/02-rag/020-rag-evaluation-metrics.md
- RAGAS 文档:https://github.com/explodinggradients/ragas
- LangChain.js 官方文档:https://js.langchain.com/docs/
- LlamaIndex 官方文档:https://docs.llamaindex.ai/
- Cohere Rerank 介绍:https://cohere.com/rerank
