上篇(多模态系列开篇)我盘了三件事:图片进 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”的经验阈值。想一起折腾的,下篇见。
参考文献
- ColPali 论文(Faysse et al., arXiv:2407.01449):https://arxiv.org/abs/2407.01449
- ColPali 官方仓库(模型清单、ViDoRe 分数、Sentence Transformers v6 迁移指南):https://github.com/illuin-tech/colpali
- DeepSeek API Docs:Vision(缩放规则、1024 token 封顶、detail、格式与限制):https://api-docs.deepseek.com/guides/vision
- DeepSeek API Docs:Models and Pricing:https://api-docs.deepseek.com/quick_start/pricing
- DeepSeek API Docs:Token and Token Usage(1 汉字 ≈ 0.6 token):https://api-docs.deepseek.com/quick_start/token_usage
- DeepSeek API Docs:Context Caching(usage 里的 prompt_cache_hit_tokens / prompt_cache_miss_tokens):https://api-docs.deepseek.com/guides/kv_cache
- 上一篇:多模态系列开篇:图片是上下文里漏算的一笔账:https://www.fedte.cc/p/839.html