PixelRAG 找不到图里的表格 / 是切块粒度还是嵌入模型没选对?

文章导读
PixelRAG 找不到图里的表格,切块粒度和嵌入模型都不是能先猜出来的答案。更常见的排查顺序是:先确认表格文字有没有进索引(解析与渲染阶段),再确认切块有没有把表格切碎,最后才轮到嵌入模型能不能把问题与表格内容匹配上。如果表格在 PDF 里本身就是位图,或者由细线条和分散文本块拼成,文本抽取往往只拿到表头和零散数字,这种情况下换嵌入模型照样找不到。
📋 目录
  1. 固定一份文档和一组问题,先跑一遍基线并保存结果
  2. 只改切块粒度,其余配置不动,再跑同一组问题
  3. 只改嵌入模型,记录向量维度与预处理方式
  4. 把图像型表格裁出来单独提问,验证是否渲染层面就丢了信息
  5. 把两轮结果做成对照表,给出可复现的归因结论
A A

PixelRAG 找不到图里的表格,切块粒度和嵌入模型都不是能先猜出来的答案。更常见的排查顺序是:先确认表格文字有没有进索引(解析与渲染阶段),再确认切块有没有把表格切碎,最后才轮到嵌入模型能不能把问题与表格内容匹配上。如果表格在 PDF 里本身就是位图,或者由细线条和分散文本块拼成,文本抽取往往只拿到表头和零散数字,这种情况下换嵌入模型照样找不到。

把切块粒度与嵌入模型拆成两轮独立对照实验:先用同一份文档、同一组问题跑一遍基线,保存返回页码与分数;第二轮只改切块粒度,第三轮只改嵌入模型;最后把图像型表格裁出来单独提问。若单独特问能读到表格内容而整页提问不能,问题在解析或渲染阶段,不在粒度也不在模型;若两轮页码与排名都无明显变化,先怀疑变量没生效。这些判断只对本次文档与问题集成立。

固定一份文档和一组问题,先跑一遍基线并保存结果

基线的作用是留一份可对照的记录,否则换完参数只能凭印象说“好像好一点”。选一份含图像型表格的文档,问题清单一共准备四类:表头能对上的、只存在于表格单元格里的、需要跨行汇总的,再加一条正文能稳定检索到的对照问题。对照问题的用途是验证链路本身没坏。

  • Q1:表格标题里的关键词,正文也出现过
  • Q2:只出现在表格某个单元格里的字段值
  • Q3:需要读两行以上才能回答的汇总问题
  • Q4:正文段落里的问题,作为链路是否正常的参照
# baseline.py —— 骨架,按你实际使用的库替换函数名
import json, hashlib, platform

DOC = 'docs/report.pdf'
QUESTIONS = [q.strip() for q in open('questions.txt', encoding='utf-8') if q.strip()]
CFG = {'chunk_size': 512, 'chunk_overlap': 64,
       'embed_model': 'your-embed-model', 'normalize': True}

chunks = split_document(DOC, chunk_size=CFG['chunk_size'],
                        chunk_overlap=CFG['chunk_overlap'])
index = build_index(chunks, embed_model=CFG['embed_model'],
                    normalize=CFG['normalize'])

with open('runs/baseline.jsonl', 'w', encoding='utf-8') as f:
    for q in QUESTIONS:
        hits = index.search(q, top_k=5)
        f.write(json.dumps({
            'question': q,
            'pages': [h.page for h in hits],
            'scores': [round(h.score, 4) for h in hits],
            'chunk_ids': [h.id for h in hits],
            'config': CFG,
            'config_hash': hashlib.md5(
                json.dumps(CFG, sort_keys=True).encode()).hexdigest()[:8],
            'env': platform.platform() + ' py' + platform.python_version(),
        }, ensure_ascii=False) + '\n')

结果按一行一个问题保存,字段至少包括问题原文、top-k 页码、对应分数、命中的 chunk id、当轮配置和环境记录。环境记录不必复杂:Python 版本、嵌入模型名与版本、向量库与解析库版本,再加上配置 hash,写在每行里或用单独文件保存都行。

只改切块粒度,其余配置不动,再跑同一组问题

这一轮只动切分参数。粒度参数通常在切分器配置里,常见键名是 chunk_size、chunk_overlap,也可能是按页、按段落、按标题切分的模式开关;PDF 解析层还可能有按布局切分、跨页合并之类的选项。改之前先把最终生效的配置打印出来确认,很多“改了没效果”其实是配置没被读取。

# 确认参数真的被用上,而不是被上层默认值覆盖
print(json.dumps(splitter.get_config(), ensure_ascii=False, indent=2))

运行完按问题逐个对照,重点看表格所在页有没有进入 top-k,以及它的排名有没有前移:

问题ID | 基线top1页码 | 粒度轮top1页码 | 页码是否变化 | 表格页是否进top-k
Q1     |              |                 |              |
Q2     |              |                 |              |
Q3     |              |                 |              |

判断召回页码是否变化,看的是 page 字段的集合与顺序,不是分数绝对值。同一配置内的分数可以比高低;换了模型或归一化设置之后,分数不再可直接比较,跨轮只能比页码和排名。

只改嵌入模型,记录向量维度与预处理方式

先把粒度参数恢复成基线值,再替换嵌入模型。换模型后有三件事必须同步改,否则结果没法解释:向量库 collection 的维度、距离度量(cosine / l2 / ip)、是否归一化;并且必须重建索引,沿用旧向量得到的检索结果没有意义。

PixelRAG 找不到图里的表格 / 是切块粒度还是嵌入模型没选对?
def embed(texts, model, normalize):
    vecs = embed_client.encode(texts, model=model)   # 按实际 SDK 替换
    if normalize:
        vecs = vecs / np.linalg.norm(vecs, axis=1, keepdims=True)
    return vecs

# 每轮 run 的元数据,至少记录这些
meta = {
    'model': 'your-embed-model',
    'dim': 1024,          # 与向量库 collection 维度一致
    'normalize': True,
    'metric': 'cosine',   # 与是否归一化匹配
    'max_tokens': None,   # 截断长度,表格块常在这里被切掉
    'query_prefix': '',   # 有些模型要求 query/document 前缀
}

跑完后对照同一组问题的页码与排名变化,特别留意表格类问题:新模型的 top-k 里有没有出现表格所在页,以及这个问题是否从“无返回”变成“有返回”。同时检查表格块的文本长度是否超过模型的 max_tokens,超了就是被截断,属于预处理问题而不是模型选型问题。

把图像型表格裁出来单独提问,验证是否渲染层面就丢了信息

如果两轮改下来都没起色,先别继续调参,回到输入环节。用文本提取工具看一眼表格所在页抽出来的内容——如果只有标题和零星数字,说明信息在解析阶段就丢了,检索层再怎么调也找不回来。

# crop_table.py,示例按 PyMuPDF 写,按你的解析库替换
import fitz
doc = fitz.open('docs/report.pdf')
page = doc[11]                       # 页号先用渲染结果确认
rect = fitz.Rect(60, 120, 540, 430)  # 坐标来自整页渲染后手工量取
pix = page.get_pixmap(clip=rect, dpi=200)
pix.save('runs/table_p12.png')

把裁出来的图单独提问:有视觉能力的模型直接问;只走文本链路的话先 OCR 再提问。记录返回内容里有没有出现表格内的单元格文字、引用到的是哪一页,再和整页提问的结果放在一起比较。单独特问能答出、整页提问答不出,问题就在渲染或解析,不在切块粒度,也不在嵌入模型;这时值得先做的动作是给解析层开布局识别或对表格区域补一次 OCR,再回到前面两轮实验。

把两轮结果做成对照表,给出可复现的归因结论

把两轮结果整理进同一张表,列建议这样设计:轮次、chunk_size/overlap、嵌入模型、normalize 与 metric、问题ID、是否返回、top1 页码、top1 分数、与基线页码是否一致。

轮次   | 粒度    | 模型 | norm/metric | 问题 | 返回 | top1页 | 分数 | 与基线一致
基线   | 512/64  | A    | True/cosine | Q2   | 是   | 12     | 0.71 | -
粒度轮 | 256/32  | A    | True/cosine | Q2   | 是   | 12     | 0.68 | 是
模型轮 | 512/64  | B    | True/cosine | Q2   | 是   | 12     | 0.62 | 是
  • 单独特问能读到单元格文字,整页提问读不到:解析或渲染阶段丢失,先修输入链路。
  • 只改粒度后表格页进入 top-k 且排名前移:粒度是主因,把 chunk_size 调到能覆盖整块表格,或让表格区域单独成块并保留表头。
  • 只改模型后页码才出现、排名更贴近表格页:语义匹配是主因,同时核对归一化与 metric 是否匹配,索引必须重建。
  • 两轮页码与排名都没变化:先怀疑变量没生效,打印生效配置、确认索引已重建、确认没有复用缓存结果。
  • 部分问题无返回、整体分数偏低:先看这些问题是否只存在于图像型表格,再看截断长度是否把表格块切掉。

边界要提前说清:这些判断只对本次文档和问题集成立,换文档要重跑;不同嵌入模型的分数不能直接横比,跨模型只看页码和排名;改粒度或改模型后都要重建索引,否则两轮数据混在一起,对照表本身就不成立。