PixelRAG 可接在视觉召回这一段、生成与重排沿用原有链路

文章导读
已有文本 RAG 链路想接入页面图像召回,通常不必整套重写:把召回这一段替换或并联成视觉召回,重排与生成继续消费原来的统一结果结构即可。能不能沿用的判断点不在模型差异,而在视觉召回返回的字段能否被现有重排和生成直接读取;只要在中间补一层字段适配,下游改动可以压到很小。
📋 目录
  1. A 梳理现有链路:检索、重排、生成各由什么组件承担
  2. B 把文档渲染成页面图像,作为视觉召回的输入
  3. C 用同一组问题分别跑文本检索与页面图像检索,记录召回差异
  4. D 把视觉召回结果接回原有重排或生成步骤,确认输入格式
  5. E 关掉视觉召回重跑一遍,确认原有链路不受影响
A A

已有文本 RAG 链路想接入页面图像召回,通常不必整套重写:把召回这一段替换或并联成视觉召回,重排与生成继续消费原来的统一结果结构即可。能不能沿用的判断点不在模型差异,而在视觉召回返回的字段能否被现有重排和生成直接读取;只要在中间补一层字段适配,下游改动可以压到很小。

接入顺序建议是:先梳理现有链路的召回点输入输出,再把文档渲染成页面图像并记录页码与文本切块的对应关系,然后用同一组问题对照文本检索与图像检索的召回差异,把视觉结果转成下游期望字段,最后用开关关掉视觉召回重跑一次,确认原链路行为不变。判断依据是日志与命中记录,不依赖性能数字;若视觉结果缺少 text 字段或 score 类型不一致,就需要在适配层补齐。

梳理现有链路:检索、重排、生成各由什么组件承担

先把链路按数据流拆开,逐个确认输入输出,改动通常只落在召回这一格。常见文本 RAG 的构成如下:

环节典型组件输入输出接入时是否替换
查询预处理查询改写、关键词抽取用户原始问题规范化 query + 过滤条件保留
召回文本向量检索 / BM25 / 混合检索query、top_k、过滤条件候选列表 [{chunk_id, text, score, doc_id}]替换或并联(视觉召回的挂载点)
重排cross-encoder、业务规则打分候选列表排序后的候选列表,字段结构不变、分数更新保留
生成LLM 问答或摘要排序后的候选文本 + 提示词模板答案文本 + 引用来源保留

各环节的调用位置通常是:一个 retrieve() 函数负责召回,前面拼 query 与过滤条件,后面依次接 rerank() 和 generate()。只要 retrieve() 的返回契约不变,rerank 和 generate 的调用点就不用动。建议先把这层的返回结构用一份样例数据固定下来,作为后面字段适配的目标格式。

把文档渲染成页面图像,作为视觉召回的输入

视觉侧需要的是整页图像,而不是文本切块。渲染脚本负责按页出图,并留下页码与文本切块的对应关系,这段骨架可以按实际渲染库替换 open_doc 和 render 两个调用:

# render_pages.py —— 通用骨架,渲染库按环境替换
import json, pathlib
from some_pdf_lib import open_doc  # 替换为实际使用的库

SRC = pathlib.Path('docs')
OUT = pathlib.Path('pages')
OUT.mkdir(exist_ok=True)

def page_images(doc_path, dpi=150):
    doc = open_doc(str(doc_path))
    for i, page in enumerate(doc.pages, start=1):
        yield i, page.render(dpi=dpi)

rows = []
for doc in sorted(SRC.glob('*.pdf')):
    doc_id = doc.stem
    d = OUT / doc_id
    d.mkdir(exist_ok=True)
    for page_no, img in page_images(doc):
        name = f'{page_no:04d}.png'
        img.save(d / name)
        rows.append({'doc_id': doc_id, 'page': page_no,
                     'image': f'{doc_id}/{name}'})

with open('page_index.jsonl', 'w', encoding='utf-8') as f:
    for r in rows:
        f.write(json.dumps(r, ensure_ascii=False) + '\n')

输出目录与命名规则建议固定为 pages/{doc_id}/{page:04d}.png,页码补零是为了让字典序等于页序;渲染分辨率也要固定,重建索引时才不会新旧图像混用。

对应关系记录:原文本切块如果带页码元数据,直接复用;没有就单独维护一份 chunk_id 到 (doc_id, page) 的映射文件,例如 chunk_page_map.jsonl。视觉召回命中的是某一页,而下游生成需要的是文本块,这张映射表就是从页码回到文本的桥。渲染参数、渲染时间、映射文件路径一并记在渲染日志里,方便后续重建时对照。

用同一组问题分别跑文本检索与页面图像检索,记录召回差异

准备一组覆盖不同问题类型的测试问题,同一组问题分别走两条召回路径,这一步只看命中来源,不看生成答案质量,因为要判断的是召回补没补上缺口。

PixelRAG 可接在视觉召回这一段、生成与重排沿用原有链路
问题类型文本检索表现页面图像检索表现差异描述角度
含图表(趋势线、示意图、嵌入式表格)命中正文段落,图注或图表内文字缺失命中图表所在页文本可答但缺数值,视觉侧覆盖图表区域
含版面结构(跨页表格、多栏排版、页眉页脚)片段可能断裂,表头与数据行分离命中保留完整版面的一页命中粒度差异、是否需要跨块拼接
纯文字命中对应段落命中同一页两侧覆盖重叠,视觉召回未带来新增信息

问题类型分类建议固定为含图表、含版面结构、纯文字三类。差异描述按命中粒度、覆盖区域、是否需要跨块拼接三个角度写,不要写“效果更好”这类无法复核的结论。记录方式建议一行一条 JSONL:{qid, question, route: 'text' 或 'visual', hit_ids, page, note},事后可以按 qid 把两条路径的结果并排比对。

把视觉召回结果接回原有重排或生成步骤,确认输入格式

重排和生成期望的是统一字段,而视觉召回通常只返回图片路径与页码,中间需要补一层适配,把页码映射回文本块,并保证字段名和类型与文本路线一致:

def to_candidate(visual_hit, chunk_page_map):
    page_key = (visual_hit['doc_id'], visual_hit['page'])
    chunk = chunk_page_map.get(page_key)
    if chunk is None:
        raise ValueError(f'no chunk mapped for {page_key}')
    return {
        'doc_id': visual_hit['doc_id'],
        'chunk_id': chunk['chunk_id'],        # 下游排序与去重依赖
        'text': chunk['text'],                # 生成阶段拼接上下文依赖
        'score': float(visual_hit['score']),  # 类型与文本路线保持一致
        'source': 'visual',                   # 仅用于日志区分来源
    }

下游期望的字段可以按 doc_id、chunk_id、text、score(float)、source 五项对齐。任意一项缺失或类型不同,重排阶段一般会直接抛错,或者静默丢掉候选,两种表现都要在日志里看得出来。

不匹配时的报错记录,建议把异常原文连同原始 hit 一起写入 adapter_errors.jsonl。常见有三类:映射缺失导致 KeyError 或 ValueError;score 是字符串导致排序比较失败;text 为空导致生成阶段拼出空上下文。这三类都属于适配层问题,通常不需要改动重排和生成组件本身。改完之后同样用那一组问题重跑,确认候选字段与文本路线一致。

关掉视觉召回重跑一遍,确认原有链路不受影响

开关配置建议放在检索配置里,与文本召回并列,形如:

retrieval:
  visual:
    enabled: false     # true 时并联页面图像召回
    top_k: 5
  text:
    top_k: 20

关闭后的预期表现是:召回只走文本路线,候选数量与字段结构与接入前一致,重排和生成的调用路径不变,日志里不再出现 source=visual 的条目。如果关闭后结果仍与接入前不同,多半是适配层动了共享字段,比如覆盖了 score 或改写了 chunk_id,需要回去检查 to_candidate 是否被无条件调用。

回退验证的记录方式:同一组问题在 enabled=true 与 enabled=false 下各跑一次,分别保存原始候选(含 chunk_id 与 score),做集合对比,记录文本路线结果是否逐条一致。把配置快照、运行时间、两次输出文件路径写进回退验证记录,日后出现回归时就能判断是不是视觉召回这一段引入的。回退验证的目的不是证明谁更好,而是确认接入是双向可切的,而不是只能单向切换。