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]-[5],LLM 引用了 [6]
- 引用错误:把 A chunk 的信息标注成 B chunk
- 遗漏引用:有些事实陈述没有标注来源
- 格式不一致:有时候用 [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
}后处理匹配的问题
- 成本高:每个句子都要调 embedding API
- 速度慢:N 个句子 × M 个 chunk = N×M 次相似度计算
- 匹配不精确:句子可能是多个 chunk 的综合,无法精确对应
- 无引用的句子:有些句子是 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 可能在编造信息
下一篇讲知识库权限控制——不同用户看到不同范围的知识库。