把 PDF 每页渲染成图再检索——PixelRAG 的最小可用路径

文章导读
跑通 PixelRAG 不需要先搭完整框架,最少是三段:渲染、写入向量库、查询取回。判断方向是先用一份 PDF、几十页的规模把这三段串起来,只在确实卡住的地方加组件;边界是这种做法把页面外观当作检索单元,问题跨页或需要精确文字定位时,通常不如文本加 OCR 的混合检索稳。
📋 目录
  1. Ⅰ 准备输入:把 PDF 渲染成固定 DPI 的页面图像
  2. Ⅱ 建立索引:把页面图像写入向量库并保存页码映射
  3. Ⅲ 执行一次查询并打印 top-k 结果
  4. Ⅳ 人工比对返回页面截图与问题答案
  5. Ⅴ 记录失败点:渲染、嵌入、写入、查询哪一步先报错
A A

跑通 PixelRAG 不需要先搭完整框架,最少是三段:渲染、写入向量库、查询取回。判断方向是先用一份 PDF、几十页的规模把这三段串起来,只在确实卡住的地方加组件;边界是这种做法把页面外观当作检索单元,问题跨页或需要精确文字定位时,通常不如文本加 OCR 的混合检索稳。

最小可用路径是渲染、嵌入写入、查询取回三步,输入输出固定为 PNG 页面图像、带 page 字段的向量记录、top-k 页码列表。适用场景是原型验证;操作上先固定 DPI 和单份 PDF,用日志与截图人工核对;验证方式是返回页码能打开且覆盖问题所需内容;风险边界是扫描件、跨页问题和更换嵌入模型都会让结果不稳,需要重新渲染并重建索引。

准备输入:把 PDF 渲染成固定 DPI 的页面图像

这一步只产出一个东西:与 PDF 页码一一对应的页面图像目录,后面的嵌入和查询都只依赖它。先在项目根目录用一份不含扫描件的 PDF 试,输出目录写死,避免写索引时再去猜路径。命令行可以先这样跑:

pdftoppm -r 150 -png -cropbox input.pdf pages/page
# 不同版本输出命名可能是 page-1.png 或 page-0001.png
# 先只渲染一页确认命名规则,再批量跑

更省事的做法是用脚本包一层,把 DPI、页数、单页体积写进 manifest,后面判断「是不是渲染这一步就错了」时先看这个文件:

# render_pages.py
import json, pathlib, subprocess

PDF = 'input.pdf'
OUT = pathlib.Path('pages')
DPI = 150
OUT.mkdir(exist_ok=True)

subprocess.run(['pdftoppm', '-r', str(DPI), '-png', PDF, str(OUT / 'page')], check=True)

meta = []
for f in sorted(OUT.glob('page-*.png')):
    meta.append({'file': f.name, 'bytes': f.stat().st_size, 'dpi': DPI})
(OUT / 'render_meta.jsonl').write_text(
    '\n'.join(json.dumps(m) for m in meta), encoding='utf-8')
print(len(meta), 'pages')

输出目录建议固定成 pages/page-0001.png 这种零填充命名,按文件名排序就等于按页码排序;render_meta.jsonl 记录每页文件名、字节数和 DPI。单页体积是判断渲染是否正常的直观指标:同样 DPI 下某页体积接近 0,通常是该页空白或渲染失败,只重跑这一页即可。

建立索引:把页面图像写入向量库并保存页码映射

这一步的目标是让每条向量都能追溯回原始页面。向量库可以换,但元数据字段尽量统一,页码用 1-based,和阅读器里显示的页码保持一致,免得人工比对时对不上。通用骨架如下,嵌入函数和客户端按你的环境替换:

把 PDF 每页渲染成图再检索——PixelRAG 的最小可用路径
# index_pages.py
import pathlib
import chromadb  # 可替换为 pgvector、faiss 等,接口自行适配

client = chromadb.PersistentClient(path='./pixel_index')
col = client.get_or_create_collection('pdf_pages')

def embed_image(path):
    # 换成实际使用的图像嵌入模型,返回 list[float]
    raise NotImplementedError

for f in sorted(pathlib.Path('pages').glob('page-*.png')):
    page = int(f.stem.split('-')[-1])      # page-0001.png -> 1
    vec = embed_image(f)
    col.upsert(
        ids=[f.name],
        embeddings=[vec],
        metadatas=[{'source': 'input.pdf', 'page': page,
                    'file': f.name, 'dpi': 150}],
        documents=['input.pdf 第 %d 页' % page],
    )
print(col.count())

元数据字段清单建议固定为 source、page、file、dpi、model、created_at 六项,查询和排查都靠它们。维度一致性在写入前查一次、查询前再查一次,比等报错再回头找省事:

EXPECTED_DIM = 1024   # 与所选图像嵌入模型一致,按实际值改
assert len(vec) == EXPECTED_DIM, '嵌入维度不符: %d' % len(vec)
# 查询前用同一函数生成查询向量,再断言一次长度

如果写入时报维度不符,通常是从中途换了嵌入模型但没清集合;处理方式是新建集合或先删除再重建,不要新旧向量混着写。

执行一次查询并打印 top-k 结果

先拿一个你已知答案的问题跑,确认链路端到端通,再看返回结构:

res = col.query(query_embeddings=[qvec], n_results=5)
for i in range(len(res['ids'][0])):
    print(i, res['ids'][0][i], res['metadatas'][0][i]['page'],
          res['distances'][0][i])
  • ids:写入时的唯一键,这里等于页面文件名。
  • metadatas:带回 page、file、dpi 等字段,是回溯页码的依据。
  • distances:距离值,越小通常越相近,但不同向量库和不同度量方式含义不同,不要跨库比较。
  • documents:写入时放的文本,方便直接在日志里读。

把结果映射回原文页码的片段很短:

把 PDF 每页渲染成图再检索——PixelRAG 的最小可用路径
for meta in res['metadatas'][0]:
    print('打开 input.pdf 第 %d 页(对应 %s)' % (meta['page'], meta['file']))

除了能不能返回,还要看返回页码是否落在 1 到总页数之间,以及同一个问题连查两次结果是否稳定。

人工比对返回页面截图与问题答案

这一步判断的是「召回的是不是真有用」,而不是向量库能不能跑。核对表至少三列:

  • 问题:原文写清楚你问的那句话。
  • 返回页码:top-k 里每一条对应的 page。
  • 是否命中:命中记 1,未命中记 0,并注明缺什么。

命中判断标准建议写成可检查的形式:返回页面的文字或图表里存在回答该问题所需的依据,并且你能指到具体位置,比如某段文字、某个表格行、某张图里的标签。只是「看起来相关」不算命中,避免把模糊结果当成通过。

把 PDF 每页渲染成图再检索——PixelRAG 的最小可用路径

未命中时的下一步动作按代价从低到高排:先人工确认这个问题在 PDF 里确实能查到;再提高 DPI 或检查是否有空白页;再看问题是否跨页,跨页问题需要把相邻页也纳入候选;然后调大 top-k;最后才考虑加 OCR 文本或关键词检索,形成图像加文本的混合召回。

记录失败点:渲染、嵌入、写入、查询哪一步先报错

三段链路加上人工比对,出错的位置比想象中集中。每步都记录同样的几项:执行的命令、输入文件和页范围、退出码、stderr 的前几行、输出文件是否存在,这样下次遇到同样报错能直接对上。

  • 渲染:加密 PDF 会提示需要密码;输入路径写错直接报文件不存在;跑完目录里没有 PNG,说明参数被吃掉了,先只渲染一页验证。
  • 嵌入:模型加载失败或超时、图像解码报错、返回向量长度与预期不符。
  • 写入:维度不符、ids 重复导致覆盖、集合不存在或路径无写权限。
  • 查询:返回空列表(n_results 大于集合条数)、查询向量与库内向量维度不符、入库和查询用了不同模型导致结果明显偏离。

重跑时用最小复现命令,不要每次都跑整份 PDF:

# 只渲染第 3 页
pdftoppm -r 150 -png -f 3 -l 3 input.pdf /tmp/one

# 只写入一条向量,观察返回
col.upsert(ids=['t1'], embeddings=[vec], metadatas=[{'page': 3}])

# 只查这一条,确认能取回
col.query(query_embeddings=[vec], n_results=1)

把每次报错的原样文本留在日志里,比事后凭记忆描述有用。如果同一条命令在单页上能过、整份 PDF 上失败,优先怀疑某一页的渲染结果,而不是向量库或查询逻辑。