Skip to content

14.15-引用来源返回

要点

  • 引用来源让用户可以验证 LLM 的回答——增加可信度
  • 两种实现方式:Prompt 要求标注(依赖 LLM 自觉遵守)和后处理匹配(代码强制匹配)
  • Prompt 标注更自然但不可靠——LLM 可能编造引用编号
  • 后处理匹配更可靠但成本高——每个句子都要 embedding
  • 生产环境通常组合使用:Prompt 标注 + 后处理校验
  • 引用数据需要结构化传递给前端——支持可点击引用和来源预览
  • 引用覆盖率低于 70% 是警告信号——说明模型在编造无出处信息

1. 模型编造了一个不存在的引用

假设你已完成了上下文拼接(第 14 篇),把检索到的 5 个 chunk 喂给 LLM。用户问「退款多久到账」,模型回答:

退款一般在 3-5 个工作日到账 [1]。超过 500 元的退款需要额外 1 个工作日
的人工审核 [2]。退款会退回原支付账户,信用卡渠道可能延迟 1-2 天 [3]。

看起来不错——每个事实都有引用。但你检查 chunk 列表,发现只有 3 个 chunk 提到了退款时间,没有 chunk 提到「信用卡渠道延迟」。[3] 这个编号对应的信息是模型编造的。

这就是引用来源的核心冲突:你希望模型在回答中标注来源,但模型不保证标注是真的。

加上真实的引用来源后,回答变成了这样:

退款一般在 3-5 个工作日到账 [1]。超过 500 元的退款需要额外 1 个工作日
的人工审核 [2]。

参考来源:
[1] 退款政策 > 退款到账时间
[2] 退款政策 > 大额退款审核流程

用户看到这个回答,可以做三件事:确认信息来源是否权威、点击引用查看原文了解更多细节、发现回答有误时直接对照原文纠正。没有引用,回答就只是模型的一面之词。

你的任务是让回答的每个事实陈述都能追溯到原始文档。代码层面需要解决两个问题:在回答中插入引用标记,以及确保引用标记指向正确的文档。方案有两条路——在 Prompt 中要求模型自己标注,或者用代码在后处理阶段强制匹配。前者更自然但不可靠,后者更可靠但有成本和精度损耗。两种方案都有代价,你需要在可靠性和自然度之间找到平衡。

2. 方案一:Prompt 内标注

在 System Prompt 里明确要求 LLM 用 [编号] 标注每个事实的来源:

typescript
const systemPrompt = `你是一个知识库助手。请根据参考资料回答用户的问题。

## 回答要求

1. 只基于参考资料回答,不要编造信息
2. 在每个事实陈述后面用 [编号] 标注来源,比如 [1]、[2]
3. 如果多个来源支持同一个陈述,列出所有编号,如 [1][3]
4. 如果参考资料中没有相关信息,明确说明「根据已有资料无法回答」
5. 不要引用不存在的编号

## 参考资料

${context}

## 用户问题

${query}`

上下文中的每个 chunk 已经用 [1][2] 等编号标记,模型需要做的就是把回答中的事实对应回这些编号。

多数情况下模型能正确标注:

退款一般在 3-5 个工作日到账 [1]。如果金额超过 500 元,
需要额外 1 个工作日的人工审核 [2]。退款会退回原支付账户 [1][3]。

2.1 Prompt 标注为什么会失败

模型不一定遵守规则。 常见问题分四类:

编造不存在的编号——参考资料只有 [1]-[3],模型引用了 [4]。这是最严重的问题:模型生成了看似有出处但实际不存在的信息。

引用错位——把 chunk A 的信息标注成了 chunk B。回答内容是对的,但引用指向了错误的来源。用户点击引用后看到的原文和回答对不上。

遗漏标注——部分事实陈述没有加引用标记。模型「知道」答案,但没有告诉你这个答案从哪来。

格式不一致——有时用 [1],有时用 (1),有时不加标记。这给解析带来麻烦。

根本原因是:LLM 生成文本时不是在运行一个可靠的引用程序,而是在预测下一个 token。 模型没有内置机制在生成 [3] 时回查上下文确认 chunk 3 是否真的包含这个信息。它只是基于注意力分布「记住」了信息来源,但这个记忆会随着生成文本变长而衰减。实测中,回答前几句的引用准确率明显高于后面的句子。

通过 Few-shot 示例和更强的 Prompt 约束可以减少这些问题,但不能完全消除——这是自回归生成的本质限制。

2.2 解析引用标记

从 LLM 的回答中提取引用编号,得到干净文本和引用位置:

typescript
function extractCitations(answer: string): {
  cleanAnswer: string
  citations: Array<{ chunkIndex: number; position: number }>
} {
  const citations: Array<{ chunkIndex: number; position: number }> = []
  let cleanAnswer = ''
  let position = 0
  const regex = /\[(\d+)\]/g
  let lastIndex = 0
  let match

  while ((match = regex.exec(answer)) !== null) {
    cleanAnswer += answer.slice(lastIndex, match.index)
    position = cleanAnswer.length

    citations.push({
      chunkIndex: parseInt(match[1]) - 1,  // 转成从 0 开始
      position,
    })

    lastIndex = match.index + match[0].length
  }

  cleanAnswer += answer.slice(lastIndex)

  return { cleanAnswer, citations }
}

这个函数把 [1][2][3] 这类标记从文本中剥离,记录每个引用在干净文本中的字符位置。前端拿到 position 后可以在对应位置插入可点击的引用标记。

3. 方案二:后处理匹配

第二种思路:不信任 LLM 的标注,在回答生成后用代码把每个句子和原始 chunk 做匹配。

流程是把回答拆成句子,对每个句子做 embedding,然后在所有候选 chunk 中找相似度最高的那个。超过阈值就标注引用,不超过就不标。

typescript
async function addCitations(
  answer: string,
  chunks: ContextChunk[],
  threshold = 0.7
): Promise<{
  answerWithCitations: string
  references: Array<{ chunkId: string; source: string; snippet: string }>
}> {
  const sentences = splitIntoSentences(answer)
  const result: string[] = []
  const referencedChunks = new Set<number>()

  // 预计算所有 chunk 的 embedding,避免重复调用
  const chunkEmbeddings = await Promise.all(
    chunks.map((c) => embed(c.content))
  )

  for (const sentence of sentences) {
    const sentenceEmbedding = await embed(sentence)
    const bestMatch = findBestMatch(sentenceEmbedding, chunks, chunkEmbeddings)

    if (bestMatch && bestMatch.score >= threshold) {
      const chunkIndex = chunks.indexOf(bestMatch.chunk)
      referencedChunks.add(chunkIndex)
      result.push(`${sentence} [${chunkIndex + 1}]`)
    } else {
      result.push(sentence)
    }
  }

  const references = Array.from(referencedChunks).map((i) => ({
    chunkId: chunks[i].id,
    source: chunks[i].metadata.documentTitle ?? '未知来源',
    snippet: chunks[i].content.slice(0, 100) + '...',
  }))

  return {
    answerWithCitations: result.join(''),
    references,
  }
}

function splitIntoSentences(text: string): string[] {
  return text.match(/[^.!?。!?]+[.!?。!?]?\s*/g) ?? [text]
}

function findBestMatch(
  sentenceEmbedding: number[],
  chunks: ContextChunk[],
  chunkEmbeddings: number[][]
): { chunk: ContextChunk; score: number } | null {
  let best: { chunk: ContextChunk; score: number } | null = null

  for (let i = 0; i < chunks.length; i++) {
    const score = cosineSimilarity(sentenceEmbedding, chunkEmbeddings[i])
    if (!best || score > best.score) {
      best = { chunk: chunks[i], score }
    }
  }

  return best
}

注意 chunkEmbeddings 是预计算的——如果你用向量数据库,直接用 ANN 搜索替代暴力遍历,这里只是为了说明原理。

3.1 后处理匹配的代价

embedding 成本高——每个句子都要调一次 embedding API。回答有 10 句话就有 10 次额外调用,还有预计算 chunk embedding 的成本。

匹配不精确——回答中的一个句子可能综合了多个 chunk 的信息,但匹配只选了一个「最佳」chunk。引用是对的,但不完整。

阈值敏感——阈值太高,很多事实句匹配不上,引用丢失;阈值太低,过渡句也会被错误标注,引用噪声增加。0.7 是一个常用的起点,但你需要根据自己的 chunk 粒度和 embedding 模型调整。

语义相近不等于来源正确——embedding 的余弦相似度衡量的是语义接近程度,不是「信息是否来自这个 chunk」。两个语义相近但内容不同的 chunk 可能导致错误归因。

后处理匹配解决了 Prompt 标注「不可靠」的问题,但引入了新的成本和精度损耗。

4. 两种方案对比

维度Prompt 内标注后处理匹配
可靠性不可靠——模型可能编造或遗漏引用较可靠——代码强制匹配
多来源支持天然支持 [1][3]只选最佳匹配,多来源需要额外处理
额外成本每句一次 embedding + N×M 相似度计算
延迟无额外延迟增加数百毫秒(取决于句数和 chunk 数)
过渡句处理模型自行判断需要代码过滤,否则会被错误标注
引用自然度高——引用在句子内的位置合理低——统一追加在句末
适用场景对引用准确性要求不极端的场景需要高可靠性、可以接受成本增加的场景

单一方案都不够可靠。 Prompt 标注的问题是模型可能编造编号;后处理匹配的问题是每个句子都要额外 embedding,且无法处理多来源综合。生产环境通常组合两者。

5. 组合方案:Prompt 标注 + 后处理校验

用 Prompt 让 LLM 生成带标注的回答,再用代码校验引用的合法性:

typescript
async function generateWithCitations(
  query: string,
  chunks: ContextChunk[],
  llm: LLMClient
): Promise<{
  answer: string
  citations: Citation[]
  references: Reference[]
}> {
  const context = buildContext(chunks)

  // 1. LLM 生成带标注的回答
  const rawAnswer = await llm.chat({
    messages: [
      {
        role: 'system',
        content: `根据参考资料回答,用 [编号] 标注来源。不要编造编号。`,
      },
      { role: 'user', content: `${context}\n\n问题:${query}` },
    ],
  })

  // 2. 解析引用标记
  const { cleanAnswer, citations } = extractCitations(rawAnswer)

  // 3. 校验:过滤指向不存在 chunk 的引用
  const validCitations = citations.filter((c) => c.chunkIndex < chunks.length)

  // 4. 构建引用列表
  const references = deduplicateBy(
    validCitations.map((c) => ({
      index: c.chunkIndex + 1,
      chunkId: chunks[c.chunkIndex].id,
      source: chunks[c.chunkIndex].metadata.documentTitle ?? '未知来源',
      section: chunks[c.chunkIndex].metadata.sectionTitle,
      snippet: chunks[c.chunkIndex].content.slice(0, 200),
    })),
    'chunkId'
  )

  return { answer: cleanAnswer, citations: validCitations, references }
}

function deduplicateBy<T>(items: T[], key: keyof T): T[] {
  const seen = new Set()
  return items.filter((item) => {
    const k = String(item[key])
    if (seen.has(k)) return false
    seen.add(k)
    return true
  })
}

这个流程做了三件事:解析引用编号、过滤无效引用、去重构建引用列表。校验步骤虽然简单,但挡住了最常见的问题——模型引用了超出范围的编号。

如果需要更高的可靠性,可以在第 3 步加入 embedding 校验:对每个被引用的 chunk 和对应句子做相似度检查,低于阈值的引用标记为不可靠。但这会引入后处理匹配方案的额外成本,需要根据业务需求权衡。

6. 引用数据结构与边界处理

6.1 传递给前端的结构化数据

前端需要结构化信息来渲染可点击的引用标记。API 响应格式:

typescript
interface RAGResponse {
  answer: string
  citations: Array<{
    index: number       // 引用编号,和 answer 里的 [index] 对应
    position: number    // 在 answer 中的字符位置
    chunkId: string     // chunk ID
    source: string      // 文档标题
    section?: string    // 章节标题
  }>
  references: Array<{
    index: number
    chunkId: string
    source: string
    section?: string
    url?: string        // 文档链接,前端可以跳转
    snippet: string     // 内容片段,hover 预览用
  }>
}

citations 告诉前端在每个位置插入什么引用标记,references 提供引用标记对应的完整来源信息。前端拿到这份数据后,可以把 [1] 替换成可点击的 <sup> 标记,hover 时展示来源预览,点击后跳转到原文。

在 chunk 元数据里加上 URL,前端就可以实现点击跳转:

typescript
type ChunkMetadata = {
  documentTitle: string
  sectionTitle?: string
  documentUrl?: string    // 文档原文链接
  sectionUrl?: string     // 章节链接,带锚点
}

6.2 边界条件处理

引用来源的实现有几类必须处理的边界条件:

引用不存在的编号——LLM 引用了 [6],但只有 5 个 chunk。组合方案中的 c.chunkIndex < chunks.length 过滤已经覆盖。

引用丢失——后处理匹配中,过渡句(「综上」「总的来说」)和概括性陈述没有对应 chunk。用阈值过滤:相似度低于 0.7 的句子不标注引用。更好的做法是在匹配前过滤过渡句:

typescript
function isTransitionSentence(sentence: string): boolean {
  const patterns = [/^综上/, /^总的来说/, /^由此可见/, /^因此可以/]
  return patterns.some((p) => p.test(sentence.trim()))
}

错误引用——模型引用了正确的编号,但对应 chunk 的内容和回答不符。这需要 embedding 校验:对被引用的 chunk 和句子做相似度检查,低于阈值的引用标记为不可靠或直接移除。

多来源合并——一句话综合了多个 chunk 的信息。Prompt 标注天然支持 [1][3] 这种多引用格式;后处理匹配需要为每个句子保留 top-K 匹配结果,而不是只取最佳。

6.3 引用覆盖率监控

追踪有多少事实陈述有引用、多少没有,作为系统健康度的指标:

typescript
function citationCoverage(answer: string, citations: Citation[]): number {
  const sentences = splitIntoSentences(answer)
  const factualSentences = sentences.filter(
    (s) => !isTransitionSentence(s)
  )

  if (factualSentences.length === 0) return 1

  // 从句子中提取所有引用编号,精确匹配避免 [1] 误匹配 [10]
  const citedCount = factualSentences.filter((s) => {
    const citedIndices = new Set<number>()
    const regex = /\[(\d+)\]/g
    let match
    while ((match = regex.exec(s)) !== null) {
      citedIndices.add(parseInt(match[1]))
    }
    return citations.some((c) => citedIndices.has(c.index))
  }).length

  return citedCount / factualSentences.length
}

引用覆盖率持续低于 70% 是一个警告信号——说明模型在生成没有出处的信息。应对方式是检查 Prompt 约束是否足够强,或者检查检索质量:如果检索到的 chunk 本身就和问题无关,模型只能靠自己「编」答案,引用覆盖率自然上不去。

引用来源让你可以验证模型的回答,但引用的前提是检索到的文档确实包含了回答问题所需的信息。如果检索质量不达标,再好的引用机制也无济于事。另一个维度的约束是权限:不同用户能看到不同范围的知识库内容,检索和引用都不能跳过权限检查。这是下一篇的主题。

基于 MIT 协议开源