跳到主要内容

ColPali 多向量晚交互:Morphik 的招牌

本章是全项目最核心的一章。看懂它,就看懂了 Morphik 为什么能答对「图表里的问题」。

1. 它要解决的小问题

传统 embedding 把「一段文本」压成一个向量。对一整页复杂 PDF 来说,这一个向量既要装下标题、又要装下表格、又要装下角落里的那行小字——信息被压扁,细节丢失。

ColPali 的思路:别压成一个,压成一组。 把整页当图片喂进视觉模型,模型把页面切成很多小块(patch),每个 patch 出一个向量。一页 = 一组向量(通常上千个)。检索时不再是「一个向量 vs 一个向量」,而是「查询的每个 token 去和页面的每个 patch 比,取最像的那个」——这叫晚交互(late interaction)

2. 直觉:MaxSim 晚交互

怎么读:左边是查询(几个 token 向量),右边是一页文档(很多 patch 向量)。每个查询 token 只挑它最匹配的那个 patch,把这些最大相似度加起来,就是这一页的分。

查询 "screw 14-A height" 文档页(patch 网格)
┌──────────────┐ ┌───────────────────────┐
│ q1 (screw) │──最像──▶ p_37 │ p1 p2 p3 ... p_37 │ ← p_37 恰好是那颗螺丝
│ q2 (14-A) │──最像──▶ p_38 │ p_38 是标注 "14-A" │
│ q3 (height) │──最像──▶ p_52 │ p_52 是尺寸线 │
└──────────────┘ └───────────────────────┘

page_score = sim(q1,p37) + sim(q2,p38) + sim(q3,p52) + ...
= Σ_i max_j sim(q_i, p_j) ← 这就是 MaxSim

关键:每个查询 token 各自找自己最匹配的局部——所以「藏在角落的一行小字」也能被单独一个 query token 精确命中,不会被整页平均掉。

3. 摄入侧:整页怎么变成多向量

3.1 PDF 每页渲染成图

_create_chunks_multivector(core/services/ingestion_service.py:1454)按 MIME 分流。PDF 走 _process_pdf_for_colpali(:1572),用 PyMuPDF 把每页渲染成图片(注释:using PyMuPDF for faster processing),DPI 由 settings.COLPALI_PDF_DPI 控制(:1580)。Word/PPT 先转 PDF 再渲染(_process_word_for_colpali :1703_process_powerpoint_for_colpali :1713)。

有意思的例外:Excel 不转图:1554 的 case 明说表格类数据「文本检索远好于图像」,所以 Excel 走 ColPali 的文本路(is_image: False)。

3.2 图片 chunk 算多向量

ColpaliEmbeddingModel(core/embedding/colpali_embedding_model.py:25)加载 ColQwen2_5(:47,模型 tsystems/colqwen2.5-3b-multilingual-v1.0)。摄入入口 embed_for_ingestion(:66)把 chunk 分成图片和文本两拨,分别批量过模型:

  • 图片:process_images(...)self.model(**processed)(generate_embeddings_batch_images,:275)
  • 文本:process_queries(...) → 同样过模型(generate_embeddings_batch_texts,:307)

产物是每个 chunk 一个 np.ndarray(形状 [patch 数, 128],即多向量)。推理用 torch.inference_mode() + CUDA 上 bfloat16 autocast(:251-256)提速。

4. 存储侧:二值化 + BIT(128) 的 MaxSim

多向量存储是个难题:一页上千个 128 维 float 向量,原样存爆炸。Morphik 的默认库 MultiVectorStore(core/vector_store/multi_vector_store.py:39)用了一个精巧的压缩。

4.1 二值化(binary quantization)

每个向量的每一维,只看正负号,压成 1 bit。128 维 float → 128 bit。见 _binary_quantize(:332),底层用 Rust 加速(binary_quantize,注释说 5-10x 更快)。存进 pg 的列类型就是 BIT(128)[](建表在 :251)。

4.2 二值下的 MaxSim = 汉明距离

两个二值向量的相似度,不再是余弦,而是「有多少 bit 相同」。Morphik 把 MaxSim 直接写成一个 PostgreSQL SQL 函数 max_sim(document bit[], query bit[])(:290):

-- core/vector_store/multi_vector_store.py:290 max_sim(核心行)
1.0 - (bit_count(document # query)::float / greatest(bit_length(query),1)::float) AS similarity
-- # 是按位异或;bit_count(异或) = 不同的 bit 数 = 汉明距离
-- 于是 similarity = 1 - 归一化汉明距离

完整逻辑:把 query 拆成多个向量、document 拆成多个向量,两两算相似度,每个 query 向量取 max,再把这些 max 加起来(SELECT COALESCE(SUM(max_similarity), 0.0),:313)——这正是 §2 的 MaxSim 公式,在数据库里跑。

4.3 检索时怎么用

query_similar(core/vector_store/multi_vector_store.py:720)把查询多向量也二值化,inline 成 SQL 数组字面量,直接 ORDER BY max_sim(embeddings, ...) DESC LIMIT k(:745-758)。数据库一条 SQL 就把 MaxSim 排序做完。

5. 内容外置存储(一个容易忽略的细节)

chunk 的内容(尤其图片,很大)不塞在向量行里,而是外置到对象存储(本地/S3),行里只存一个 storage key。写入见 _store_content_externally(:403),检索回来时按 key 下载并按需转回 data URI(_retrieve_content_from_storage,:509)。检索路径里 skip_image_content 能在只需要 key 时跳过下载(query_similar :774),省带宽。

6. 一句话小结

ColPali 路 = 整页当图片 → 每 patch 一向量 → 二值压成 BIT(128) → MaxSim 用汉明距离在 SQL 里排序。这套「晚交互 + 二值多向量」就是 Morphik 相对普通单向量 RAG 的护城河。快版库(TurboPuffer + MUVERA)见 04-deep-dive.md