主题
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 本身就和问题无关,模型只能靠自己「编」答案,引用覆盖率自然上不去。
引用来源让你可以验证模型的回答,但引用的前提是检索到的文档确实包含了回答问题所需的信息。如果检索质量不达标,再好的引用机制也无济于事。另一个维度的约束是权限:不同用户能看到不同范围的知识库内容,检索和引用都不能跳过权限检查。这是下一篇的主题。