把“证据可点击、来源可核验”做成产品体验,不需要大改架构。思路只有一句话:生成阶段不是只返回一段文字,而是同时返回一份结构化的“引用账单”。账单里包含文档 ID、页码、坐标、表格行列等最小可核验单元;前端拿到这份账单,把答案里的标记变成可点击锚点,并在 PDF 里滚动定位加高亮。
先定协议。后端统一返回一个 JSON 包,既有自然语言答案,也有证据清单。答案里用稳定标记占位,清单里给出可视化所需的全部坐标与上下文。长这个样子:
{
"answer": "公司未来三年的增长主要来自海外与高端产品线。〔1〕 同期毛利改善与费用优化提供额外杠杆。〔2〕 平均售价提升见年度报表相应表格。〔3〕",
"citations": [
{
"id": "blk-12-a1b2c3d4",
"doc_id": "report_2024.pdf",
"type": "paragraph",
"page": 12,
"bbox": [72.0, 418.2, 468.5, 486.3],
"anchor": "p12 · 段落",
"preview": "我们预计未来三年海外业务保持两位数增长……"
},
{
"id": "blk-27-f9e8d7c6",
"doc_id": "report_2024.pdf",
"type": "paragraph",
"page": 27,
"bbox": [68.0, 310.0, 470.0, 350.0],
"anchor": "p27 · 段落",
"preview": "费用率优化将来自渠道精简与供应链整合……"
},
{
"id": "tbl-33-2",
"doc_id": "report_2024.pdf",
"type": "table_cell",
"page": 33,
"table_id": "tbl-33-2",
"row": 4,
"col": "ASP(元)",
"cell_bbox": [120.0, 540.0, 220.0, 565.0],
"anchor": "p33 · 表2 r4·ASP",
"preview": "ASP 由 1680 升至 1890"
}
]
}
坐标使用 PDF 点(1/72 英寸),以页面左上角为原点,下方为正方向。对于跨行段落可以放多个 bbox 数组;对于表格尽量在抽取阶段把单元格坐标也带上,实在没有就退化高亮整行。anchor 给人看,preview 给 hover 看,id 给程序对齐标记。
后端的改造不复杂。你已经在检索阶段拿到了带元数据的段落与表格命中;只需要把它们规整到上述 schema,并把编号顺序固定下来。然后给生成阶段一个“硬约束提示词”,要求把引用标注成 〔1〕、〔2〕 这样的序号,且只使用你分配的编号。生成完成后,原样把自然语言与清单一起返回,不要试图让模型自己造坐标。
def build_citations(hits_text, hits_table, calc=None):
seq, items = 1, []
for d, m, _ in hits_text:
items.append({
"id": m["id"], "doc_id": m.get("doc_id", "report.pdf"),
"type": "paragraph", "page": int(m["page"]),
"bbox": m.get("bbox"), "anchor": f"p{m['page']} · 段落",
"preview": d
})
seq += 1
for d, m, _ in hits_table:
cell_bbox = m.get("cell_bbox") or None
items.append({
"id": m["table_id"], "doc_id": m.get("doc_id", "report.pdf"),
"type": "table_cell", "page": int(m["page"]),
"table_id": m["table_id"], "row": m.get("row"),
"col": m.get("col"), "cell_bbox": cell_bbox,
"anchor": f"p{m['page']} · {m['table_id']} r{m.get('row','?')}·{m.get('col','?')}",
"preview": d
})
seq += 1
if calc:
items.insert(0, calc) # 需要的话把“计算结论”放到最前并分配 〔1〕
return items
RAG_STRICT_PROMPT = """
你是严格的事实性回答助手。只依据“证据池”生成答案。
请在需要引用的句子末尾插入 〔n〕 形式的引用标记,n 只能使用系统分配的编号顺序,不得跳号、不得新增。
如果证据不足,请回答:“根据提供的资料,我无法回答该问题。”
证据池(按编号顺序使用):
{numbered_previews}
问题:{question}
答案:
"""
渲染层的工作是把 〔n〕 做成可点击锚点,并在 PDF 里定位与高亮。前端推荐使用 pdf.js 渲染原文,覆盖一层透明高亮层。点击锚点时,先根据 n 找到对应清单项,再滚动到它的页码,最后用坐标画一个半透明矩形。pdf.js 的 viewport 会处理缩放与方向,省去坐标系转换烦恼。
<div id="answer"></div>
<div id="viewer"></div>
<script type="module">
import * as pdfjsLib from "https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.mjs"
const data = await fetch("/api/rag/answer?id=123").then(r=>r.json())
renderAnswer(data.answer, data.citations)
const pdf = await pdfjsLib.getDocument(`/files/${data.citations[0].doc_id}`).promise
const pageViews = new Map()
async function ensurePage(pageNo) {
if (pageViews.has(pageNo)) return pageViews.get(pageNo)
const page = await pdf.getPage(pageNo)
const viewport = page.getViewport({ scale: 1.5 })
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
canvas.width = viewport.width; canvas.height = viewport.height
document.querySelector("#viewer").appendChild(canvas)
await page.render({ canvasContext: ctx, viewport }).promise
const overlay = document.createElement("div")
overlay.className = "overlay"
overlay.style.position = "absolute"
overlay.style.left = canvas.offsetLeft + "px"
overlay.style.top = canvas.offsetTop + "px"
overlay.style.width = canvas.width + "px"
overlay.style.height = canvas.height + "px"
overlay.style.pointerEvents = "none"
document.querySelector("#viewer").appendChild(overlay)
const pv = { page, viewport, canvas, overlay }
pageViews.set(pageNo, pv)
return pv
}
function renderAnswer(answer, citations){
const withAnchors = answer.replace(/〔(\d+)〕/g, (m, n) => {
return `<a href="#" class="cite" data-n="${n}">〔${n}〕</a>`
})
document.querySelector("#answer").innerHTML = withAnchors
document.querySelector("#answer").addEventListener("click", async e=>{
const a = e.target.closest(".cite"); if (!a) return
e.preventDefault()
const n = parseInt(a.dataset.n, 10) - 1
const c = citations[n]; if (!c) return
const pv = await ensurePage(c.page)
highlight(pv, c)
pv.canvas.scrollIntoView({ behavior: "smooth", block: "start" })
})
}
function highlight(pv, c){
pv.overlay.innerHTML = "" // 清空旧高亮
const rects = (c.bbox ? [c.bbox] : (c.cell_bbox ? [c.cell_bbox] : []))
rects.forEach(rc=>{
const [x0, y0, x1, y1] = rc
const viewRect = pv.viewport.convertToViewportRectangle([x0, y0, x1, y1])
const [vx0, vy0, vx1, vy1] = viewRect
const hl = document.createElement("div")
hl.style.position = "absolute"
hl.style.left = Math.min(vx0, vx1) + "px"
hl.style.top = Math.min(vy0, vy1) + "px"
hl.style.width = Math.abs(vx1 - vx0) + "px"
hl.style.height = Math.abs(vy1 - vy0) + "px"
hl.style.background = "rgba(255, 230, 0, 0.35)"
hl.style.outline = "2px solid rgba(255, 180, 0, 0.9)"
pv.overlay.appendChild(hl)
})
}
</script>
<style>
#viewer{ position: relative; }
.overlay div{ border-radius: 3px; }
.cite{ color:#2563eb; text-decoration:none; cursor:pointer }
.cite:hover{ text-decoration:underline }
</style>
体验要稳,边界就得兜住。命中段落没有坐标时,退化到只滚动到页;命中表格没有 cell_bbox 时,高亮整行或整列;证据跨多段多行时,bbox 给数组,前端循环画多个矩形;答案渲染时不要依赖模型自己编号,编号严格按后端清单顺序,防止跳号;富文本排版会换行,标记始终贴在句子末尾,避免点击错位。
再把生成端拧紧。指令里反复强调“不得新增引用、不得改编号、不得擅自复述未在证据池出现的事实”,同时把 numbered_previews 明确列出,最好给到每条证据的人类可读摘要,减少模型乱贴的冲动。对于数值型回答,把“单位与口径”也写死在提示词里,并建议模型在答案里写清“单位、口径、时间范围”,这样你在审计时才不会被埋雷。
这套改造做完,你从“看起来有出处”跃迁到“点得过去、对得上、亮得出来”。当领导问“这句哪来的”,你只需要让他点一下 〔2〕;当法务问“你真的引用了表格里的数字吗”,你让他看高亮落在哪个单元格;当自己回归问题时,你直接用 id + page + bbox 复刻现场。这就是工程化 RAG 的底层可信。
明天继续把“可审计”向前推进一步,我们把证据从“能点”升级到“能比”。我们会让系统在回答旁边自动生成“差异对照”,把同一指标在不同年份或不同口径下的值并排展示,并把计算过程以可核验的公式附上。这才是一个能进生产的答案形态。