跳到主要内容

数据截至 (上游 commit 8c51b8dc5408)

ColPali:把整页当图片检索

本章讲什么: 为什么一页要编码成上千个向量而不是一个、MaxSim 到底在算什么、Morphik 怎么把模型跑起来。这是全项目最核心的一个机制。


1. 它要解决的小问题

传统文本嵌入把一整块文字压成一个向量。这个压缩比太狠了:6000 字符压进 768 个浮点数,细节必然丢失。对一页有图有表的文档,先 OCR 再压缩,丢两次。

有没有办法既不丢版面,又不做那么狠的压缩?


2. 思路:不压缩,改成"多对多打分"

ColPali 的两条思路缺一不可:

第一条:输入端不抽文本,直接吃图。 一页 PDF 渲染成 PNG,交给一个视觉语言模型。图表、版式、手写全部原样进入模型。

第二条:输出端不做池化。 视觉模型内部会把图片切成很多个 patch(视觉 token),每个 patch 出一个向量。常规做法是把这些向量平均成一个;ColPali 全部保留。一页就是一个 (N_patch, 128) 的矩阵。查询侧同理,一句话是 (N_token, 128)

打分时不再是两个向量点积,而是:

对查询里的每一个 token:
在这一页的所有 patch 里,找出和它最像的那个,记下相似度
把所有 token 的最高相似度加起来 = 这一页的得分

这个打分函数叫 MaxSim(逐查询词取最大值再求和),也叫 late interaction(晚交互)——查询和文档的交互推迟到打分那一刻,而不是提前压缩掉。

图示:一次 MaxSim

查询 "扭矩 规格" 页面第 37 页
| |
| q1 = [0.2, -0.5, ...] p1 p2 p3 ... p1024
| q2 = [0.9, 0.1, ...] (每个 patch 一个 128 维向量)
v v

q1 --对 p1..p1024 逐个算相似度--> 最大值 0.83 (落在图里的"扭矩"文字上)
q2 --对 p1..p1024 逐个算相似度--> 最大值 0.71 (落在表头上)
--------
这一页的得分 = 1.54

为什么这样更强: 查询里每个词都能独立"指"到页面上的某个位置。词多的长查询自然拿到更高的分,而单向量方案里长查询会被平均稀释。


3. 原理演示

下面这段用最直白的方式把 MaxSim 演出来。

# 示意,非源码
import numpy as np

def max_sim(query_vecs, page_vecs):
"""query_vecs: (n_query_token, 128);page_vecs: (n_patch, 128)"""
# 一次矩阵乘就得到所有 token 对所有 patch 的相似度
sim = query_vecs @ page_vecs.T # (n_query_token, n_patch)
# 每个 query token 只保留它最匹配的那个 patch
best_per_token = sim.max(axis=1) # (n_query_token,)
return float(best_per_token.sum()) # 求和就是这一页的得分

重点看 axis=1 那个 max:它决定了"每个查询词各找各的落点",而不是"整句话找一个落点"。

代价也在这里:相似度矩阵是 n_query_token × n_patch,一页上千个 patch,几百页就是几亿次乘加。这正是第 03 章要解决的问题。


4. 真实实现:模型怎么加载和调用

模型

core/embedding/colpali_embedding_model.py:47 加载的是:

self.model = ColQwen2_5.from_pretrained(
"tsystems/colqwen2.5-3b-multilingual-v1.0",
dtype=torch.bfloat16,
device_map=device,
attn_implementation=attn_implementation,
).eval()

一个 3B 参数的多语言 ColQwen2.5,bfloat16 精度。设备按 mps → cuda → cpu 依次探测(第 27 行)。

几个性能开关:

开关条件代码位置
TF32 矩阵乘CUDA 设备colpali_embedding_model.py:32-35
FlashAttention2装了 flash_attn 且在 CUDAcolpali_embedding_model.py:37-45,否则退回 eager
autocast bf16仅 CUDA 推理时colpali_embedding_model.py:252-256
inference_mode全设备注释说比 no_grad 更快,少了版本追踪

批大小按部署模式切换

self.batch_size = 8 if self.mode == "cloud" else 1

core/embedding/colpali_embedding_model.py:61。自托管默认逐张处理,因为本地显存通常吃不下 8 张页面图片同时前向。

图片和文本走不同的处理器方法

embed_for_ingestion(core/embedding/colpali_embedding_model.py:66)先把一批 chunk 按 metadata["is_image"] 分成两拨,分别批处理,最后按原始下标塞回同一个数组:

results: List[np.ndarray | None] = [None] * len(chunks)
...
for original_index, embedding in zip(batch_indices, batch_embeddings):
results[original_index] = embedding

这一步保证了顺序不乱——而顺序不乱正是页号不错位的前提。

真正调模型的地方分三个方法:

方法处理器调用用途
generate_embeddingsprocess_images / process_queries单条,查询侧走这里
generate_embeddings_batch_imagesprocess_images摄取时一批页面
generate_embeddings_batch_textsprocess_queries摄取时一批文本块

注意文本块也走 process_queries(第 310 行)。也就是说非图片内容进 ColPali 时,是当"查询"格式编码的。

输出形状

result = embeddings.to(torch.float32).numpy(force=True)[0]

core/embedding/colpali_embedding_model.py:262。每条内容出来是一个 (N, 128) 的 float32 数组。128 这个维度是硬约束——下游存储用 BIT(128)[],定长编码器也写死 dimension=128


5. 图片进入模型之前的手脚

页面图片:原尺寸,150 DPI

PDF 渲染保持默认 150 DPI([pdf] colpali_pdf_dpi),不缩放。理由显然:缩小了就看不清小字。

单张图片文件:狠狠压缩

对比之下,直接上传一张图片时的处理很激进(core/services/ingestion_service.py:1490-1499):

max_width = 256
...
img.convert("RGB").save(buffered, format="JPEG", quality=70, optimize=True)

宽度压到 256、JPEG 质量 70。代码里没有解释这个不对称,从行为上推断是为了控制单图场景的存储与带宽 (inferred)。要索引高分辨率图纸的话,这是个需要注意的行为。

原始字节的传递技巧

_image_bytes_to_chunk(core/services/ingestion_service.py:1365)有一个只在本地模式生效的优化:

metadata: Dict[str, Any] = {"is_image": True, "mime_type": mime_type}
if settings.COLPALI_MODE == "local":
metadata["_image_bytes"] = image_bytes

本地模式下把原始 PNG 字节挂在 metadata 上,嵌入时直接用,省掉一次 base64 解码。API 模式下不挂——注释说那是"每页约 5MB 的死重",而 data URI 里已经带着图片了。

用完立刻丢弃(core/embedding/colpali_embedding_model.py:94):

chunk.metadata.pop("_image_bytes", None)

并且 _create_chunk_objects 会再兜一层,过滤掉 _image_bytes 和任何 bytes 类型的值(core/services/ingestion_service.py:1217-1226),防止二进制混进要 JSON 序列化的 metadata。


6. 三种运行模式

morphik.toml[morphik] colpali_mode 有三挡,装配逻辑在 core/services_init.py:122-190:

模式行为适合谁
off完全不加载 ColPali,只有传统文本检索没有 GPU、只处理纯文本
local进程内加载 3B 模型本机有 GPU/MPS
api把嵌入请求发给远端 GPU 服务CPU 机器跑主服务,GPU 单独部署

API 模式的实现 core/embedding/colpali_api_embedding_model.py:58 自带一套多端点调度:

  • 端点列表来自 morphik_embedding_api_domain(可以配多个)。
  • 维护 healthy_endpoints 集合,失败的端点进冷却,冷却到期后重新试探(_recover_endpoints:95)。
  • 多端点时把输入交错切分后并发发出去(_embed_inputs_distributed:170),而不是简单顺序分片,目的是让每个端点拿到的图文比例接近。

7. 关键细节与坑

  • 模型名硬编码。 tsystems/colqwen2.5-3b-multilingual-v1.0 在两处写死:嵌入模型(colpali_embedding_model.py:47)和快路存储里用于重排的处理器(fast_multivector_store.py:346)。换模型要同时改。
  • 快路存储也要加载处理器。 FastMultiVectorStore.__init__ 里那一行 ColQwen2_5_Processor.from_pretrained(...) 意味着即使检索走 API 嵌入,存储层仍需下载处理器权重配置来跑 score_multi_vector
  • 图片查询有 10MB 上限。 core/services/document_service.py:241-246,超了返回 400。
  • 图片查询必须开 ColPali。 同文件第 234-238 行,query_imageuse_colpali 为假时直接 400,不做静默降级。
  • 嵌入失败时会静默降级成文本。 core/embedding/colpali_embedding_model.py:96-98:图片解码失败就把 chunk.content(可能是一长串 base64)当文本嵌入。这会产生一个语义无意义的向量,但不会中断整批。

8. 代码地图

主题文件路径符号名
本地 ColPali 模型core/embedding/colpali_embedding_model.pyColpaliEmbeddingModel
摄取批处理与顺序还原core/embedding/colpali_embedding_model.pyembed_for_ingestion
单条编码(查询侧)core/embedding/colpali_embedding_model.pygenerate_embeddingsembed_for_query
图片批 / 文本批core/embedding/colpali_embedding_model.pygenerate_embeddings_batch_imagesgenerate_embeddings_batch_texts
API 模式与端点调度core/embedding/colpali_api_embedding_model.pyColpaliApiEmbeddingModel_embed_inputs_distributed_recover_endpoints
页面图片块构造core/services/ingestion_service.py_image_bytes_to_chunkimg_to_base64_with_bytes
metadata 清洗core/services/ingestion_service.py_create_chunk_objects
精确 MaxSim 打分调用core/vector_store/fast_multivector_store.pyquery_similar(processor.score_multi_vector)
ColPali 装配开关core/services_init.pycolpali_embedding_modelcolpali_vector_store