Skip to content

课 3 · Rerank 与引用来源

本课目标

在混合检索结果之上加一层重排序(Rerank),进一步提升精度。同时让答案携带引用来源,用户能追溯到原始文档。

先说清楚:Rerank 是精排,不是初始召回。 它对候选集里的文档重新打分,但不会召回新文档。

召回和精排的区别

为什么不直接精排?精排模型(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[] } 而不是在文本里嵌入引用。结构化输出比正则解析更可靠。

面向前端工程师和独立开发者的 AI 应用工程课程