• 欢迎关注我的微信公众号“ Falost ” 右边扫描关注 --->>

多模态 RAG 实测:图转文还是 ColPali?我先把两条路线的评测口径定死了

AI / 大模型 神棍 215℃ 0评论

上篇(多模态系列开篇)我盘了三件事:图片进 API 有自己的选型、自己的计价、自己的归宿。结尾我说要拿真实截图库做一次实测,把“图转文”(路线 A)和 ColPali(路线 B)拉出来比一轮召回。

真动手才发现有个坑:评测口径没定死,比出来的就是感觉不是数据。所以这篇先打地基——修正上篇两处过时数据、把两条路线各跑通一条最小链路、再定一套能复现的评测指标。真实截图库的对比分数和选型结论,放下篇给。

一、先修正上篇的两处数据

动手前我重读了 DeepSeek 官方文档,发现上篇有两处已经不准,先更正,免得后面算账全歪。

1)图片 token 封顶不是 384,是 1024。官方 Vision 文档写得很清楚:图片进推理前会统一缩放,总像素低于约 544×544 的放大、更大的缩小,缩放后总像素大致对齐 1300×1300;因此单张图的 token 上限是 1024。文档还给了一个直观例子——2000×2000 和 5000×5000 的图,缩放后消耗的 token 一样多。按官方口径 1 个汉字 ≈ 0.6 token 换算,顶格一张 ≈ 1024 ÷ 0.6 ≈ 1700 个汉字。上篇我按 384 算(≈640 字),差了一倍多,这里更正。

2)模型名和价格都变了。legacy 的 deepseek-v4-flash-vision-exp 已经退役,请求由 deepseek-flash(DeepSeek-V4.1-Flash)承接,视觉能力现在直接挂在 deepseek-flash 上,不用再单独挑一个“vision 模型”。价格这边,cache miss 的输入 off-peak 是 $0.15 / 百万 token、peak 0.3;cache hit off-peak 只要0.003。

顺手把上篇的账重算一遍:一张 1920×1080 截图,就算按顶格 1024 token、off-peak cache miss 0.15 估,1024 × 0.15 ÷ 1000000 ≈ 0.0154 美分。结论没变——图片本身不贵,贵在你没法单独对账。所以不管走哪条路线,resp.usage 一定落日志。

二、路线 A:图转文的链路怎么搭

路线 A 的核心就一句话:检索前先把图“翻译”成文本,再复用我现有的文本 RAG。翻译这个动作,我直接用 deepseek-flash 的多模态能力做。

关键是 prompt。我要的不是一段描述,而是能进向量库的“可检索文本”,所以一次要两块东西:一句摘要(给向量检索)+ 全文转写(给关键词和全文检索)。这是我实际在跑的版本:

import base64
from openai import OpenAI

client = OpenAI(api_key="", base_url="https://api.deepseek.com")

OCR_PROMPT = (
    "你是文档转写器。请只输出两部分:\n"
    "1) SUMMARY: 一句话说清这页在讲什么,控制在 80 字内,用于向量检索;\n"
    "2) TEXT: 逐行转写页面上的全部文字,保留标题、列表、表格的层级。"
    "不要解释,不要补充原文没有的内容。"
)

def image_to_text(path):
    with open(path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode("utf-8")
    resp = client.chat.completions.create(
        model="deepseek-flash",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": OCR_PROMPT},
                {"type": "image_url",
                 "image_url": {"url": f"data:image/png;base64,{b64}", "detail": "low"}},
            ],
        }],
    )
    u = resp.usage
    print("prompt_tokens =", u.prompt_tokens,
          "cache_miss =", getattr(u, "prompt_cache_miss_tokens", None))
    return resp.choices[0].message.content

几点说明,都是文档里确认过的:

  • detail 有三个值:low 把图缩到 512×512,最快最省;high 和 original 等价,都保留原图;auto 目前也等于 original。我第一版统一用 low,截图正文一般够看。
  • 图片只能放在 user 消息里,塞进 system 或 assistant 会直接 400,这个我踩过。
  • 支持 JPEG / PNG / GIF / WebP,且按文件真实内容判断,不看扩展名和 MIME。单请求最多 600 张图、请求体上限 48 MiB(base64 内联的部分也算)。

转写质量怎么量化?我用字符错误率(CER):

import difflib

def cer(ref, hyp):
    """字符错误率 = 编辑距离 / 参考长度。0 最好,越大越差。"""
    sm = difflib.SequenceMatcher(None, ref, hyp)
    edits = 0
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        if tag != "equal":
            edits += max(i2 - i1, j2 - j1)  # 替换按较长一侧计
    return edits / max(len(ref), 1)

拿人工校对过的一页当 ref、模型转写当 hyp,就能给每页打分。但有条纪律必须守住:CER 是路线 A 的内部质检线,不能拿去跟路线 B 的召回率直接比。CER 衡量“转写忠不忠实”,召回衡量“找没找对页”,量纲不是一回事。我的用法是按阈值筛:CER 明显偏高的页标出来人工看一眼,判断要不要放弃从图里转。

三、路线 B:ColPali 的链路怎么搭

ColPali 的思路(论文 arXiv:2407.01449):不做文本抽取,直接把整页图喂给视觉语言模型,取 ViT 的 patch 输出过一层线性投影得到多向量表示,再用 ColBERT 那套 late interaction 跟 query 算分。好处是版面、图表、表格、字体这些视觉线索天然保留,不用维护又长又脆的 OCR 管线。论文还一起放出了 ViDoRe 这个页面级检索 benchmark。

代码上有个变化要注意:colpali-engine 官方已标注 deprecated,README 明确推荐新项目改用 sentence-transformers v6 的 MultiVectorEncoder。旧的 ColQwen2.from_pretrained 那套还能跑,但新项目别用了。新写法就这么几行:

# pip install -U "sentence-transformers[image]"
from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("vidore/colqwen2.5-v0.2")

query_embeddings = model.encode_query(["报销单上的审批人是谁?"])
document_embeddings = model.encode_document(["page_1.png", "page_2.png"])
scores = model.similarity(query_embeddings, document_embeddings)

模型怎么挑?官方 README 直接给了各模型在 ViDoRe 上的分数,越高越好,我整理成一张表:

模型 底座 ViDoRe 分数
vidore/colpali PaliGemma-3B 81.3
vidore/colpali-v1.3 PaliGemma-3B 84.8
vidore/colqwen2-v1.0 Qwen2-VL-2B 89.3
vidore/colqwen2.5-v0.2 Qwen2.5-VL-3B 89.4
vidore/colSmol-500M SmolVLM-500M 82.3

我打算先用 colSmol-500M 这种小模型把链路跑通(显存门槛低),再换 colqwen2.5 看真实提升。代价也在这:得自己扛 VLM 推理,索引和检索整条链路都要重做,不像路线 A 那样直接复用现成的文本 RAG。

四、评测口径:不统一,比出来全是感觉

两条路线要放在一张表里比,得先把下面几件事说死。

1)评测集。N 张截图页,每页写 2–3 个“只有看到这页才答得对”的真实问题,标一个 gold 页码。提问时不能带页码信息,否则等于泄题。

2)指标就三个,别自己造:Recall@k 看 top-k 里有没有命中 gold(翻不翻得到)、MRR 看第一个正确答案排第几(排得前不前)、nDCG@k 看排序质量(考虑多个相关页和位置折扣)。

代码我用标准库手写了一份,不引第三方依赖,方便直接塞进现有脚本:

import math

def recall_at_k(ranked_ids, relevant_ids, k):
    return 1.0 if set(ranked_ids[:k]).intersection(relevant_ids) else 0.0

def mrr(ranked_ids, relevant_ids):
    for i, d in enumerate(ranked_ids, 1):
        if d in relevant_ids:
            return 1.0 / i
    return 0.0

def ndcg_at_k(ranked_ids, relevant_ids, k):
    dcg = 0.0
    for i, d in enumerate(ranked_ids[:k], 1):
        if d in relevant_ids:
            dcg += 1.0 / math.log2(i + 1)
    ideal = sum(1.0 / math.log2(i + 1)
                for i in range(1, min(len(relevant_ids), k) + 1))
    return dcg / ideal if ideal else 0.0

def evaluate(cases, k=5):
    agg = {"recall@" + str(k): 0.0, "mrr": 0.0, "ndcg@" + str(k): 0.0}
    for c in cases:
        agg["recall@" + str(k)] += recall_at_k(c["ranked"], c["relevant"], k)
        agg["mrr"] += mrr(c["ranked"], c["relevant"])
        agg["ndcg@" + str(k)] += ndcg_at_k(c["ranked"], c["relevant"], k)
    n = len(cases)
    return {m: round(v / n, 4) for m, v in agg.items()}

我拿 4 条假数据验过这段逻辑,输出是 {'recall@5': 0.75, 'mrr': 0.3125, 'ndcg@5': 0.4564},手算一遍对得上,可以放心用。

3)三条纪律。两条路线共用同一套问题、同一个 k、同一套 gold,参数不许各调各的;命中判定按“页”不按“片段”,因为两条路线检索粒度不一样(A 进文本块,B 进整页图);结果一律存 JSON 落盘,改一次参数就复跑一次,别靠记忆比。

4)成本也要算进去。路线 A 每次转写花一次视觉调用的钱(顶格 1024 token、0.15 / 百万,单页可忽略);路线 B 的钱花在本地 GPU 上,一次性投入换长期零调用费。这两笔账不能混着算。

五、我现在的选择

理清楚之后,我的判断是这样:文档量小、没有 GPU、已经有跑得动的文本 RAG → 走路线 A,转写一次存文本,后面完全复用已有链路,成本低、出问题好排查;文档量大、图文混排严重(表格图表版式信息不能丢)、手上有能跑 VLM 的卡 → 上路线 B。

我自己是先 A 后 B:截图库还不大,先用 deepseek-flash 把图转文跑通、把 CER 质检加进流水线;等文档量上来、纯文字检索开始明显漏召回,再拿小批量数据跟 ColPali 做一轮对照,用数据决定要不要切。

总结

一句话:多模态 RAG 的第一步不是选模型,是先把评测口径定死——修数据(1024 token 封顶、deepseek-flash、0.15 定价)、通链路(图转文 / MultiVectorEncoder)、定指标(Recall@k / MRR / nDCG@k,两条路线共用一套)。口径不统一,比出来的全是感觉。

下期预告

下篇我拿自己的真实截图库真跑一遍:把路线 A 的 CER 质检线和两条路线的 Recall@5 / MRR / nDCG@5 摆出来,再给一个“文档量到多少该切 ColPali”的经验阈值。想一起折腾的,下篇见。

参考文献

转载请注明:Falost的小窝 » 多模态 RAG 实测:图转文还是 ColPali?我先把两条路线的评测口径定死了

如果你觉得这篇文章不错或者对你有帮助,想请我喝一杯咖啡,可以打赏
喜欢 (0)
发表我的评论
取消评论

表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址