Appearance
课 3 · Rerank 与引用来源
本课目标
在混合检索结果之上加一层重排序(Rerank),进一步提升精度。同时让答案携带引用来源,用户能追溯到原始文档。
先说清楚:Rerank 是精排,不是初始召回。 它对候选集里的文档重新打分,但不会召回新文档。
召回和精排的区别
召回 → 精排 两阶段
用户问题
→
粗召回混合检索 Top-20
→
精排 Rerank对 20 个候选重新打分
→
最终 Top-5送给 LLM
粗召回追求覆盖,精排追求精度。两阶段比单阶段效果更好。
为什么不直接精排?精排模型(Cross-Encoder)计算复杂,对 1000 个文档排序代价太高。所以先用快速的粗召回筛到 20-50 个候选,再精排。
本地重排序(Cross-Encoder 思路的简化实现)
进阶课不引入外部 Rerank 服务,用本地实现展示原理:
typescript
// packages/rag-core/src/retrieval/rerank.ts
import { generateObject } from 'ai'
import { z } from 'zod'
import { getModel } from '../config'
const scoreSchema = z.object({
scores: z.array(
z.object({
id: z.string(),
relevance: z.number().min(0).max(10),
reason: z.string(),
})
),
})
/**
* 用 LLM 对候选文档打相关性分数
* 这是演示版本,生产环境用专用 Rerank 模型(Cohere、Jina 等)性价比更高
*/
export async function rerankWithLLM(
query: string,
candidates: Array<{ id: string; content: string }>,
topK = 5,
): Promise<string[]> {
if (candidates.length <= topK) {
return candidates.map((c) => c.id)
}
const { object } = await generateObject({
model: getModel(),
schema: scoreSchema,
prompt: `
你是一个文档相关性评估助手。给定用户问题和候选文档片段,为每个文档打 0-10 分的相关性分数。
用户问题:${query}
候选文档:
${candidates.map((c, i) => `[${c.id}]\n${c.content.slice(0, 300)}`).join('\n\n---\n\n')}
为每个文档评分并说明理由。
`.trim(),
})
return object.scores
.sort((a, b) => b.relevance - a.relevance)
.slice(0, topK)
.map((s) => s.id)
}注意:LLM Rerank 每次都要消耗 token,生产环境建议改用专用 Rerank 模型(如 Cohere Rerank、Jina Reranker),速度更快、费用更低。进阶课用 LLM Rerank 是为了让流程完整,不引入新的 API Key。
完整检索流程
typescript
// packages/rag-core/src/retrieval/index.ts
import { hybridSearch } from './hybrid-search'
import { rerankWithLLM } from './rerank'
export async function retrieve(query: string, finalTopK = 5) {
// 1. 混合检索,多取候选
const candidates = await hybridSearch(query, finalTopK * 3)
// 2. Rerank 精排
const topIds = await rerankWithLLM(query, candidates, finalTopK)
// 3. 按 Rerank 排序返回
return topIds
.map((id) => candidates.find((c) => c.id === id)!)
.filter(Boolean)
}引用来源
答案需要携带引用,让用户能追溯原文:
typescript
// apps/api/src/services/agent.ts
import { retrieve } from '@rag-core/retrieval'
import { generateText } from 'ai'
export async function ask({ message, sessionId }: AskParams) {
// 检索
const chunks = await retrieve(message)
// 构建带引用编号的上下文
const context = chunks
.map((chunk, i) => `[${i + 1}] ${chunk.fileName}(第 ${chunk.pageNumber ?? '?'} 页)\n${chunk.content}`)
.join('\n\n')
// 生成答案时要求引用来源编号
const { text } = await generateText({
model: getModel(),
system: `
你是一个知识库问答助手。根据提供的文档片段回答用户问题。
回答时用 [1]、[2] 等标注引用了哪些文档片段。
如果文档中没有相关信息,说"根据当前知识库,我没有找到相关信息",不要编造内容。
`.trim(),
messages: [
...historyMessages,
{
role: 'user',
content: `文档:\n${context}\n\n问题:${message}`,
},
],
})
// 解析答案里引用的编号,返回对应文档信息
const usedIndices = [...text.matchAll(/\[(\d+)\]/g)]
.map((m) => parseInt(m[1]) - 1)
.filter((i) => i >= 0 && i < chunks.length)
const sources = [...new Set(usedIndices)].map((i) => ({
title: chunks[i].fileName,
page: chunks[i].pageNumber,
content: chunks[i].content.slice(0, 100) + '...',
}))
return { answer: text, sources }
}本节产物
packages/rag-core/src/retrieval/
rerank.ts # LLM Rerank(演示用)
index.ts # 完整检索流程:混合检索 → Rerank → 返回 Top-K
apps/api/src/services/
agent.ts # 带引用编号的答案生成面试追问
为什么不直接用 Rerank 模型替代所有检索?
Rerank 模型是 Cross-Encoder,对每一对(query, document)分别计算,时间复杂度 O(n)。如果对整个知识库的所有 chunk 都 Rerank,几百个文档就要几秒。所以必须先用 Bi-Encoder(向量检索)快速粗召回,再用 Cross-Encoder 精排候选集。
答案引用怎么防止模型乱编号?
在 prompt 里写清楚格式要求,并用结构化输出(generateObject + Zod schema)让模型输出 { answer: string, citations: number[] } 而不是在文本里嵌入引用。结构化输出比正则解析更可靠。