PixelRAG 按整页图像建索引,表格问答先确认召回的是哪一页

文章导读
表格问答里答案落在相邻页或半张表,先别急着调模型。PixelRAG 这类按整页图像建索引的做法,召回单位天然就是“页”,答案能不能定位到页,取决于三件事是否同时成立:渲染出的页面图像与原始 PDF 页码一一对应、索引元数据里真的写进了页码、以及切块粒度确实是整页而不是页面局部。建议的顺序是先确认召回的是哪一页,再回头看粒度和分辨率,否则很容易在错误的层面上反复调参。
📋 目录
  1. A 把一份含表格的 PDF 渲染成页面图像并记录尺寸
  2. B 用最小脚本建立页面级索引并打印每条的元数据
  3. C 用同一张表格问三个问题,记录每次召回的页码与相似度
  4. D 把切块粒度从整页改成页面局部,再跑同一组问题
  5. E 调低渲染分辨率并复跑,确认它是不是隐藏变量
A A

表格问答里答案落在相邻页或半张表,先别急着调模型。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),把返回结果的页码和分数按顺序记下来。

PixelRAG 按整页图像建索引,表格问答先确认召回的是哪一页
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 作为基准,再单独去比较切块粒度,两项分开跑,避免两个变量同时改动后无法归因。