14.15-引用来源返回

要点

  • 引用来源让用户可以验证 LLM 的回答——增加可信度
  • 两种实现方式:Prompt 要求标注(依赖 LLM 自觉遵守)和后处理匹配(代码强制匹配)
  • Prompt 标注更自然但不可靠——LLM 可能编造引用编号
  • 后处理匹配更可靠但可能丢失引用——有些句子没有对应来源
  • 生产环境通常结合两者:Prompt 标注 + 后处理校验
  • 引用数据需要传递给前端——结构化的引用信息让前端可以渲染链接

1. 为什么需要引用来源

RAG 的回答如果只给结论不给来源,用户无法验证:

用户:退款多久到账?
LLM:退款一般在 3-5 个工作日到账。

用户不确定这个信息是否准确——是最新政策?是某个特定场景的例外?

加上引用来源后:

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

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

用户可以:

  • 确认信息来源是否权威
  • 点击查看原文,了解更多细节
  • 发现回答错误时,直接对照原文纠正

2. 实现方式一:Prompt 标注

在 System Prompt 里要求 LLM 在回答中用编号标注引用来源。

const systemPrompt = `你是一个知识库助手。请根据参考资料回答用户的问题。
 
## 回答要求
 
1. 只基于参考资料回答,不要编造信息
2. 在每个事实陈述后面用 [编号] 标注来源,比如 [1]、[2]
3. 如果多个来源支持同一个陈述,列出所有编号,如 [1][3]
4. 如果参考资料中没有相关信息,明确说明「根据已有资料无法回答」
5. 不要引用不存在的编号
 
## 参考资料
 
${context}
 
## 用户问题
 
${query}`

LLM 生成的回答:

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

解析引用

从 LLM 的回答里提取引用编号:

function extractCitations(answer: string): {
  cleanAnswer: string
  citations: Array<{ chunkIndex: number; position: number }>
} {
  const citations: Array<{ chunkIndex: number; position: number }> = []
  let cleanAnswer = ''
  let position = 0
 
  // 匹配 [1]、[2][3] 等引用标记
  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 }
}

Prompt 标注的问题

LLM 不一定遵守规则。常见问题:

  1. 编造不存在的编号:参考资料只有 [1]-[5],LLM 引用了 [6]
  2. 引用错误:把 A chunk 的信息标注成 B chunk
  3. 遗漏引用:有些事实陈述没有标注来源
  4. 格式不一致:有时候用 [1],有时候用 (1),有时候不加

可以通过 Few-shot 示例和更强的 Prompt 来减少这些问题,但不能完全消除。

3. 实现方式二:后处理匹配

不依赖 LLM 标注,而是在回答生成后,用代码把每个句子和原始 chunk 做匹配。

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>()
 
  for (const sentence of sentences) {
    const sentenceEmbedding = await embed(sentence)
    const bestMatch = await findBestMatch(sentenceEmbedding, chunks)
 
    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]
}
 
async function findBestMatch(
  sentenceEmbedding: number[],
  chunks: ContextChunk[]
): Promise<{ chunk: ContextChunk; score: number } | null> {
  let best: { chunk: ContextChunk; score: number } | null = null
 
  for (const chunk of chunks) {
    const chunkEmbedding = await embed(chunk.content)
    const score = cosineSimilarity(sentenceEmbedding, chunkEmbedding)
 
    if (!best || score > best.score) {
      best = { chunk, score }
    }
  }
 
  return best
}

后处理匹配的问题

  1. 成本高:每个句子都要调 embedding API
  2. 速度慢:N 个句子 × M 个 chunk = N×M 次相似度计算
  3. 匹配不精确:句子可能是多个 chunk 的综合,无法精确对应
  4. 无引用的句子:有些句子是 LLM 的过渡语(「综上」「总的来说」),不应该有引用

4. 结合两种方式

生产环境通常结合 Prompt 标注和后处理校验:

async function generateWithCitations(
  query: string,
  chunks: ContextChunk[],
  llm: LLMClient
): Promise<{
  answer: string
  citations: Citation[]
  references: Reference[]
}> {
  // 1. 构建带编号的上下文
  const context = buildContext(chunks)
 
  // 2. 让 LLM 生成带标注的回答
  const rawAnswer = await llm.chat({
    messages: [
      {
        role: 'system',
        content: `根据参考资料回答,用 [编号] 标注来源。不要编造编号。`,
      },
      { role: 'user', content: `${context}\n\n问题:${query}` },
    ],
  })
 
  // 3. 解析引用
  const { cleanAnswer, citations } = extractCitations(rawAnswer)
 
  // 4. 校验引用——过滤掉不存在的编号
  const validCitations = citations.filter((c) => c.chunkIndex < chunks.length)
 
  // 5. 构建引用列表
  const references = 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),
  }))
 
  // 去重
  const uniqueRefs = deduplicateBy(references, 'chunkId')
 
  return {
    answer: cleanAnswer,
    citations: validCitations,
    references: uniqueRefs,
  }
}

5. 传递给前端的结构化数据

前端需要结构化的引用信息来渲染可点击的引用标记。

// API 响应格式
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 预览用)
  }>
}

前端渲染示例(React):

function RAGAnswer({ answer, citations, references }: RAGResponse) {
  // 把引用标记替换成可点击的标记
  const renderedAnswer = renderWithCitations(answer, citations, references)
 
  return (
    <div>
      <div className="answer">{renderedAnswer}</div>
      <div className="references">
        <h4>参考来源</h4>
        {references.map((ref) => (
          <div key={ref.index} className="reference-item">
            <span className="ref-index">[{ref.index}]</span>
            <a href={ref.url} target="_blank">
              {ref.source}
              {ref.section && ` > ${ref.section}`}
            </a>
          </div>
        ))}
      </div>
    </div>
  )
}
 
function renderWithCitations(
  answer: string,
  citations: RAGResponse['citations'],
  references: RAGResponse['references']
) {
  // 按位置从后往前替换,避免位置偏移
  const sorted = [...citations].sort((a, b) => b.position - a.position)
  let result = answer
 
  for (const citation of sorted) {
    const ref = references.find((r) => r.index === citation.index)
    if (!ref) continue
 
    const citationMark = (
      `<sup class="citation-link" data-ref="${ref.index}" ` +
      `title="${ref.source}${ref.section ? ` - ${ref.section}` : ''}">` +
      `[${ref.index}]</sup>`
    )
 
    result = result.slice(0, citation.position) + citationMark + result.slice(citation.position)
  }
 
  return result
}

6. 提高引用质量

在 Prompt 中给出明确的示例

## 示例

参考资料:
[1] 退款一般在 3-5 个工作日到账。
[2] 超过 500 元需要人工审核。

用户问题:退款多久到账?

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

错误回答:
退款一般在 3-5 个工作日到账。(没有引用标注)
退款一般在 3-5 个工作日到账 [3]。(编造了不存在的编号 [3])

在 chunk 元数据里添加 URL

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

前端可以用 URL 做跳转——用户点击引用标记,直接跳到原文位置。

引用覆盖率监控

追踪有多少句子有引用,多少没有:

function citationCoverage(answer: string, citations: Citation[]): number {
  const sentences = splitIntoSentences(answer)
  const factualSentences = sentences.filter(
    (s) => !isTransitionSentence(s)  // 过滤过渡句
  )
 
  const citedSentences = factualSentences.filter((s) => {
    return citations.some((c) => s.includes(`[${c.index}]`))
  })
 
  return citedSentences.length / factualSentences.length
}

如果引用覆盖率持续低于 70%,说明 LLM 在编造信息——需要优化 Prompt 或检查检索质量。

总结

回顾这一节的要点:

  • 引用来源增加回答的可信度——用户可以验证信息来源
  • Prompt 标注:自然但不可靠——LLM 可能编造编号或遗漏标注
  • 后处理匹配:可靠但成本高——每个句子都要 embedding
  • 生产环境通常结合两者:Prompt 标注 + 后处理校验
  • 结构化引用数据传递给前端——支持可点击引用和 hover 预览
  • 提高引用质量:Few-shot 示例、文档 URL、覆盖率监控
  • 引用覆盖率低于 70% 是警告信号——LLM 可能在编造信息

下一篇讲知识库权限控制——不同用户看到不同范围的知识库。