跑通 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,和阅读器里显示的页码保持一致,免得人工比对时对不上。通用骨架如下,嵌入函数和客户端按你的环境替换:
# 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:写入时放的文本,方便直接在日志里读。
把结果映射回原文页码的片段很短:
for meta in res['metadatas'][0]:
print('打开 input.pdf 第 %d 页(对应 %s)' % (meta['page'], meta['file']))
除了能不能返回,还要看返回页码是否落在 1 到总页数之间,以及同一个问题连查两次结果是否稳定。
人工比对返回页面截图与问题答案
这一步判断的是「召回的是不是真有用」,而不是向量库能不能跑。核对表至少三列:
- 问题:原文写清楚你问的那句话。
- 返回页码:top-k 里每一条对应的 page。
- 是否命中:命中记 1,未命中记 0,并注明缺什么。
命中判断标准建议写成可检查的形式:返回页面的文字或图表里存在回答该问题所需的依据,并且你能指到具体位置,比如某段文字、某个表格行、某张图里的标签。只是「看起来相关」不算命中,避免把模糊结果当成通过。
未命中时的下一步动作按代价从低到高排:先人工确认这个问题在 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 上失败,优先怀疑某一页的渲染结果,而不是向量库或查询逻辑。