RAG 每日一技(二十):答案必须可审计——可点击引用与证据高亮的端到端落地

72 阅读5分钟

把“证据可点击、来源可核验”做成产品体验,不需要大改架构。思路只有一句话:生成阶段不是只返回一段文字,而是同时返回一份结构化的“引用账单”。账单里包含文档 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 的底层可信。

明天继续把“可审计”向前推进一步,我们把证据从“能点”升级到“能比”。我们会让系统在回答旁边自动生成“差异对照”,把同一指标在不同年份或不同口径下的值并排展示,并把计算过程以可核验的公式附上。这才是一个能进生产的答案形态。