表格问答里答案落在相邻页或半张表,先别急着调模型。PixelRAG 这类按整页图像建索引的做法,召回单位天然就是“页”,答案能不能定位到页,取决于三件事是否同时成立:渲染出的页面图像与原始 PDF 页码一一对应、索引元数据里真的写进了页码、以及切块粒度确实是整页而不是页面局部。建议的顺序是先确认召回的是哪一页,再回头看粒度和分辨率,否则很容易在错误的层面上反复调参。
表格问答出现跨页或半张表答案时,先按“整页渲染 → 打印每条元数据 → 同一组问题复跑”的顺序确认召回页码,再动切块粒度或渲染 DPI。可用判据是返回页码是否稳定、返回图像是否包含完整表头。若元数据缺页码或向量维度不一致,任何调参都定位不到问题;渲染 DPI 低于某档会丢表格线,具体阈值需要结合文档版式确认。
把一份含表格的 PDF 渲染成页面图像并记录尺寸
这一步的目标是可复现:同一份 PDF、同一个 DPI,重跑后应该得到同样的文件名和同样的页码映射。渲染库可以用 PyMuPDF(fitz)一类,也可以用别的,关键是输出目录带来源标识和 DPI,文件名用零填充页码,避免排序错位。下面是一段通用骨架,执行前替换 pdf_path 与 out_dir。
import fitz, os, csv
pdf_path = "input/table_report.pdf"
out_dir = "pages/table_report_dpi150"
dpi = 150
zoom = dpi / 72
os.makedirs(out_dir, exist_ok=True)
rows = []
doc = fitz.open(pdf_path)
for i, page in enumerate(doc): # i 从 0 开始
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
name = f"p{i+1:04d}_dpi{dpi}.png" # 写盘时统一 +1,和阅读器页码对齐
path = os.path.join(out_dir, name)
pix.save(path)
rows.append({
"page_no": i + 1,
"file": name,
"rel_path": path,
"width": pix.width,
"height": pix.height,
"dpi": dpi,
"source": pdf_path,
})
with open(os.path.join(out_dir, "page_map.csv"), "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader(); w.writerows(rows)
page_map.csv 建议至少包含:page_no(从 1 开始,与 PDF 阅读器一致)、file、rel_path、width、height、dpi、source。渲染库的页码索引通常从 0 开始,写盘和写元数据时统一加 1,是本步最容易被忽略的错位来源。DPI 的记法是把 dpi 同时写进文件名和 CSV,宽度高度由渲染结果直接读出,不要靠事后回忆或重新量图。渲染完成后,抽 2 到 3 页把图像和 PDF 同页并排看一次,确认页眉页脚和表格位置一致,再进入建索引。
用最小脚本建立页面级索引并打印每条的元数据
建索引前先确认一件事:索引里实际存了什么。很多“召回错页”的情况不是编码器的问题,而是只写了向量、没写页码。下面的骨架把元数据和向量放在同一条记录里,写入前做一次维度一致性检查。
import csv, json
records = []
for r in csv.DictReader(open("pages/table_report_dpi150/page_map.csv")):
vec = embed_image(r["rel_path"]) # 换成你用的图像编码函数
records.append({
"id": f'page-{r["page_no"]}',
"vector": vec, # 一般存 list,入库时按索引格式转换
"page_no": int(r["page_no"]),
"image_path": r["rel_path"],
"dpi": int(r["dpi"]),
"width": int(r["width"]),
"height": int(r["height"]),
"dim": len(vec),
"source": r["source"],
})
dims = {x["dim"] for x in records}
assert len(dims) == 1, f"向量维度不一致: {dims}"
for x in records[:5]:
print(x["page_no"], x["image_path"], x["dim"], x["width"], x["height"])
必须落库的字段是 page_no、image_path、dim,其次是 dpi、width、height 和 source。没有 page_no 时,检索结果只能给出“某张图”,人无法判断它对应哪一页;没有 image_path 时,无法把返回结果和原文比对。维度一致性检查用一个集合断言就够,混入不同编码器或不同模型版本的向量时,这个断言会先失败,比等到查询阶段发现分数异常更容易定位。写入完成后,可以直接从索引或向量库里按 id 取回几条打印,确认元数据和 CSV 对得上,再开始提问。
用同一张表格问三个问题,记录每次召回的页码与相似度
准备三个问题:一个只能由表头回答、一个需要某一行数据、一个需要跨列组合。每个问题固定 top_k(例如 3 或 5),把返回结果的页码和分数按顺序记下来。
q = "该表格中第二季度的营收是多少"
qv = embed_text(q) # 与图像编码器配套的文本编码函数
hits = index.search(qv, top_k=5)
for rank, h in enumerate(hits, 1):
print(rank, h["page_no"], round(h["score"], 4), h["image_path"])
记录表建议包含这些列:问题、top_k、返回页码(按分数从高到低)、相似度分数、是否命中目标页、返回图像是否含完整表头、备注。人工比对的方式是打开返回的 image_path,与 PDF 同一页并排看:表头是否在图的最上方、被问的那一行有没有被切断、是不是拼了两页的内容。如果 top1 落在相邻页而目标页排在第二位,同时两张图的分数很接近,通常说明整页向量对这种“长表格 + 相似版式”不够敏感,这属于粒度问题,而不是页码映射问题。反之,如果返回图像的页码标注就和肉眼看到的页对不上,先回去查 page_map.csv 和写盘时的加 1 逻辑。
把切块粒度从整页改成页面局部,再跑同一组问题
验证粒度是不是原因,只改这一个变量:索引的切分方式,渲染 DPI、编码器、top_k、问题都保持不变。整页粒度下,一条记录对应一张完整页面图像,chunk_id 就是页码;页面局部粒度下,把每页按上下两段或按行块切开,块之间留一点重叠,记录里需要同时保留 page_no 和 block_index 以及块的 bbox。
# 整页粒度
chunk = {"id": f"p{page_no}", "page_no": page_no, "block": 0,
"bbox": [0, 0, W, H], "image_path": full_page_png}
# 页面局部粒度(示意,按实际版式调切分点)
for bi, (y0, y1) in enumerate([(0, 0.55), (0.45, 1.0)]):
chunk = {"id": f"p{page_no}-b{bi}", "page_no": page_no, "block": bi,
"bbox": [0, y0 * H, W, y1 * H],
"image_path": crop(full_page_png, y0, y1)}
对比记录沿用上一节的表,两列并排:整页粒度的返回页码与表头完整性、局部粒度的返回页码与表头完整性。判断标准看两点:一是同一问题在两种粒度下返回的页码是否稳定在目标页,二是返回片段里表头是否完整。若改成局部粒度后页码从相邻页回到目标页、且表头齐全,说明此前的主要矛盾是切块粒度;若页码依旧漂移,粒度不是唯一原因,回去查页码映射和渲染分辨率。局部粒度也会带来新的边界,比如某一行被切在块与块之间,这时需要重叠比例来兜底,而不是继续缩小块。
调低渲染分辨率并复跑,确认它是不是隐藏变量
分辨率和粒度容易互相掩盖:分辨率太低时,表格细线和小字号数字在页面上就已经糊成一团,编码器拿到的信息本身不完整,此时无论粒度怎么切都救不回来。做法是固定粒度不变,只改 DPI,重新渲染、重建索引,再跑同一组问题。下面这张对照表按实际记录填写,不要凭印象。
| DPI | zoom(dpi/72) | 页面图像宽高(记录实际值) | 表格线/小字观察 | 返回页码是否与基准一致 | 备注 |
|-----|--------------|--------------------------|-----------------|------------------------|------|
| 200 | 2.78 | | | | 基准 |
| 150 | 2.08 | | | | |
| 110 | 1.53 | | | | |
| 72 | 1.00 | | | | |
观察点是现象层面的:表格线是否连续、小字号数字是否粘连、合并单元格的边界还看不看得出。低于某个档位后,如果返回页码开始偏离高 DPI 下的结果,同时返回图像上目标行已经无法辨认,就可以把渲染分辨率列为可疑的隐藏变量;如果低 DPI 下页码仍然一致,只是肉眼看着模糊,那说明这一档对当前文档还够用。不同版式、不同字号的 PDF 阈值不一样,需要结合自己的文档抽样确认,不建议直接照搬某个固定 DPI。确认顺序上,建议先固定一个够用的 DPI 作为基准,再单独去比较切块粒度,两项分开跑,避免两个变量同时改动后无法归因。