检索能给出编号,并不等于读者能回到原件。本文用 Node.js 把 PDF 的页码、bbox 和页内字符偏移做成可传递的证据链,再在阅读器里按同一套坐标高亮;目标不是再包一层 citation 字段,而是让「有依据」真的点得开。坐标约定按 PDF 用户空间来存,换解析库或换阅读器时仍能用。
引用编号为什么经常是空的
多数 RAG 演示会在答案末尾挂 [1][2]。点进去若只是另一段被切片后的文本,读者仍然无法核对上下文、图表和脚注。产品层面这排编号很快会变成装饰:它证明模型「调用过检索」,却没有把人带回去。
断点通常不在提示词,而在切片。解析库吐出纯文本之后,页码被丢掉,阅读器坐标被丢掉,字符位置更没有。后面无论 citation 字段写得多工整,前端也没有可跳转的锚点。
所以要把「证据」从切片那一步就带上。文本负责嵌入和生成,元数据负责回到原件。两者不要混在同一个字符串里碰运气。
先约定一条能活过半年的证据链
PDF 没有稳定的「全文 offset」。换库、改提取顺序、是否插入换行,字符下标都会变。能跨半年继续用的是页码和页面坐标系。
建议把一条证据写成下面这样的 JSON,而不是只有 source_id:
{
"chunkId": "report.pdf:p3:c120-c920",
"text": "……切片正文……",
"evidence": {
"file": "report.pdf",
"page": 3,
"pageWidth": 595.32,
"pageHeight": 841.92,
"charStart": 120,
"charEnd": 920,
"bboxes": [
{ "x": 72.0, "y": 520.4, "w": 448.1, "h": 11.2 }
]
}
}
约定要写死,避免前后端各翻一次 Y 轴:
page从 1 起算,与 PDF.js 的getPage(n)一致。bboxes使用 PDF 用户空间,原点在左下,单位是点(pt)。y是文本项基线,不是 CSS 的top。pageWidth/pageHeight来自scale: 1的 viewport,前端换缩放时按比例乘。charStart/charEnd只相对于「本页按文本项顺序拼接后的字符串」,给调试和二次切片用,不当作跨库主键。
需要跟阅读器解耦时,可以再存一份归一化框:nx = x / pageWidth,ny = y / pageHeight。主键仍然是页码加用户空间框,不要改成「当前窗口像素」。
用 pdfjs-dist 抽出页码、bbox 和字符偏移
下面脚本只做提取,不渲染,因此在 Node.js 18+ 里不需要 canvas。依赖是 pdfjs-dist。把 PDF 放到脚本同目录,命名为 sample.pdf 即可运行。
// extract-evidence.mjs
import fs from "node:fs/promises";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
function asTextItem(item) {
return item && typeof item.str === "string" && Array.isArray(item.transform);
}
async function extractPdf(filePath) {
const bytes = new Uint8Array(await fs.readFile(filePath));
const pdf = await getDocument({ data: bytes, useSystemFonts: true }).promise;
const pages = [];
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1 });
const { items } = await page.getTextContent();
let cursor = 0;
const textParts = [];
const spans = [];
for (const item of items) {
if (!asTextItem(item)) continue;
const str = item.str;
const extra = item.hasEOL ? "\n" : "";
const piece = str + extra;
const [a, b, c, d, e, f] = item.transform;
spans.push({
charStart: cursor,
charEnd: cursor + piece.length,
bbox: { x: e, y: f, w: item.width, h: item.height },
});
textParts.push(piece);
cursor += piece.length;
}
pages.push({
pageNumber,
pageWidth: viewport.width,
pageHeight: viewport.height,
text: textParts.join(""),
spans,
});
}
await pdf.destroy();
return pages;
}
const pages = await extractPdf("./sample.pdf");
await fs.writeFile("./pages.json", JSON.stringify(pages, null, 2));
console.log(`pages=${pages.length}`);
这里有两处不要美化。第一,item.height 是字形度量,高亮框会略高或略偏,对溯源够用,当不了印刷级标注。第二,旋转页应交给阅读器的 viewport 做坐标转换;提取端先把用户空间原样存下来,比在 Node 里自行旋转更不容易写错。
切片时带着证据走,而不是事后再贴 citation
常见做法是先 page.getText().join("\n"),切完再猜页码。页一跨、栏一变,引用就开始漂移。正确顺序是:先有带偏移的 span,再按字符窗口切,最后用区间重叠把 bbox 收回来。
// chunk-evidence.mjs
import fs from "node:fs/promises";
function overlappingBboxes(spans, start, end) {
const boxes = [];
for (const span of spans) {
if (span.charEnd <= start || span.charStart >= end) continue;
const { x, y, w, h } = span.bbox;
const prev = boxes[boxes.length - 1];
const sameLine = prev && Math.abs(prev.y - y) < 2 && x >= prev.x + prev.w - 1;
if (sameLine) {
prev.w = x + w - prev.x;
prev.h = Math.max(prev.h, h);
} else {
boxes.push({ x, y, w, h });
}
}
return boxes;
}
function chunkPage(page, chunkSize = 800, overlap = 120) {
const chunks = [];
let start = 0;
const { text } = page;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push({
chunkId: `${page.pageNumber}:c${start}-c${end}`,
text: text.slice(start, end),
evidence: {
page: page.pageNumber,
pageWidth: page.pageWidth,
pageHeight: page.pageHeight,
charStart: start,
charEnd: end,
bboxes: overlappingBboxes(page.spans, start, end),
},
});
if (end >= text.length) break;
start = Math.max(end - overlap, start + 1);
}
return chunks;
}
const pages = JSON.parse(await fs.readFile("./pages.json", "utf8"));
const chunks = pages.flatMap((p) => chunkPage(p));
await fs.writeFile("./chunks.json", JSON.stringify(chunks, null, 2));
console.log(`chunks=${chunks.length}`);
入库时 text 进向量,evidence 进元数据。生成阶段引用的是 chunkId 和 evidence,不要让模型自己编页码。检索命中后把对应 evidence 原样交给前端;中间层不要把 bbox 转成像素再存,否则换 DPI 或分栏阅读器时会全部作废。
跨页的切片可以允许,但每页单独保留一组 bbox。用一个「合并大框」去框两页,跳转时一定是错的。
前端用同一套坐标把原文亮出来
若阅读器也是 PDF.js,不要自己翻 Y 轴,用 viewport.convertToViewportRectangle。下面是一段可直接放进页面的逻辑:假定你已经用 PDF.js 把某一页画到 canvas 上,并且拿到了该页的 viewport 与命中的 evidence。
<div id="page" style="position:relative;display:inline-block">
<canvas id="pdf-canvas"></canvas>
<div id="overlay" style="position:absolute;inset:0;pointer-events:none"></div>
</div>
<script type="module">
function paintEvidence(overlay, viewport, evidence) {
overlay.replaceChildren();
if (evidence.pageWidth <= 0 || evidence.pageHeight <= 0) return;
for (const box of evidence.bboxes) {
const [x1, y1, x2, y2] = viewport.convertToViewportRectangle([
box.x,
box.y,
box.x + box.w,
box.y + box.h,
]);
const el = document.createElement("div");
el.style.position = "absolute";
el.style.left = `${Math.min(x1, x2)}px`;
el.style.top = `${Math.min(y1, y2)}px`;
el.style.width = `${Math.abs(x2 - x1)}px`;
el.style.height = `${Math.abs(y2 - y1)}px`;
el.style.background = "rgba(255, 196, 0, 0.28)";
el.style.outline = "1px solid rgba(210, 140, 0, 0.8)";
overlay.appendChild(el);
}
}
// 渲染完当前页后:
// paintEvidence(document.getElementById("overlay"), viewport, hit.evidence);
</script>
跳转顺序同样要固定:先打开 evidence.page,等该页 getViewport 完成,再画框。缩放只改 viewport 的 scale,bbox 仍用提取时的用户空间值。这样 Node 端和浏览器端可以共用同一份 chunks.json。
如果半年后阅读器换成别的,只要它能按页打开、并接受 pt 或归一化框,这批元数据仍能用。需要改的是坐标映射函数,不是重切整库。
总结
引用编号要能回到原件,必须在切片时同时留下页码、用户空间 bbox 和页内字符区间。页码加 bbox 是稳定主键,字符偏移只作辅助;不要把像素坐标写进索引。PDF.js 的文本框不是排版精修结果,高亮会有误差,但足够完成溯源。前端用同一套坐标做转换,而不是另造一套 citation 展示格式。约定一旦写清,解析库和阅读器都可以换,证据链不必推倒重来。
评论