技术文档的信息不全在文字里。一份 API 文档中的架构图、一篇论文里的实验结果表格、一页 PDF 中的流程图——这些视觉元素承载的信息量常常超过周围的文字描述。传统的文本检索管线对这些内容视而不见:OCR 提取的文字丢失版式信息,表格变成无结构的字符串,流程图直接被忽略。

多模态 embedding 模型提供了另一条路径:直接把页面图像编码为向量,跳过 OCR,保留视觉结构。

OCR 管线的局限

PDF 文档检索的传统做法:

1
PDF → 逐页渲染为图像 → OCR 提取文字 → 文字进入倒排索引/embedding

这条管线在纯文字 PDF 上效果尚可,但遇到以下内容时问题显著:

表格:OCR 把表格读成一行行文字,丢失行列关系。“性能 | 100ms | 200ms"变成"性能 100ms 200ms”——搜"性能低于 150ms 的方案"时,无法区分哪个数字属于哪个方案。

图表:流程图、架构图中的文字被 OCR 提取出来,但箭头、连线、布局信息全部丢失。"A → B → C"的流程关系在纯文字中不存在。

扫描件:手写批注、印章、签名等非标准文字,OCR 错误率高。中文竖排、繁简混合的老文档更是重灾区。

公式:数学公式的 OCR 准确率低,LaTeX 还原困难。搜"梯度下降的更新公式"时,OCR 输出的乱码无法匹配。

视觉 Embedding 的思路

不做 OCR,直接把页面图像编码为向量:

1
PDF → 逐页渲染为图像 → 视觉模型编码 → 向量 → HNSW 索引

查询侧仍然是文本:

1
文本 query → 文本编码器 → 向量 → 与页面向量做最近邻搜索

关键:文本和图像映射到同一个向量空间。文本 query "性能对比表格"与包含性能表格的页面图像在向量空间中距离近。

Qwen3-VL-Embedding

Qwen3-VL-Embedding 是 Qwen3 系列的多模态 embedding 模型:

  • 输入:文本 或 图像(或图文混合)
  • 输出:统一维度的向量
  • 文本和图像共享同一个向量空间
  • 支持中文

使用方式与文本 embedding 类似,区别在于输入可以是图像:

1
2
3
4
5
6
7
8
# 文本 query 编码
query_vector = model.encode(text="Java 并发编程的线程模型图")

# 页面图像编码
page_vector = model.encode(image=page_image)

# 相似度计算
similarity = cosine_similarity(query_vector, page_vector)

ColPali:页面级 Late Interaction

ColPali(Faysse et al., 2024)把 ColBERT 的 late interaction 思路应用到页面图像上:

  1. 页面图像经过视觉模型,生成 patch 向量(每个图像 patch 一个向量,类似 ViT 的 patch embedding)
  2. 文本 query 经过文本编码器,生成 token 向量
  3. 用 MaxSim 计算 query token 与 page patch 之间的匹配分数
1
2
3
4
5
6
7
8
9
10
页面图像 (224×224) → ViT → 14×14 = 196 个 patch 向量

Query: "线程池配置参数"
→ token 向量: q1("线程"), q2("池"), q3("配置"), q4("参数")

MaxSim:
q1("线程") 与 196 个 patch 向量的最大相似度 → 0.82(命中图中"线程"文字区域)
q2("池") 与 196 个 patch 向量的最大相似度 → 0.71
...
总分 = 0.82 + 0.71 + 0.65 + 0.59 = 2.77

ColPali 的优势在于定位能力:MaxSim 的最大值对应的 patch 位置可以告诉用户"匹配的内容在页面的哪个区域"。

实现方案

索引构建

1
2
3
4
5
6
7
8
PDF 文件

逐页渲染为图像(固定分辨率,如 224×224448×448

每页图像 → 视觉 embedding 模型 → 向量

存入 Lucene HNSW 索引
metadata: {docId, pageNumber, pdfPath}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 索引一个 PDF
PDDocument pdf = PDDocument.load(pdfFile);
PDFRenderer renderer = new PDFRenderer(pdf);
for (int page = 0; page < pdf.getNumberOfPages(); page++) {
BufferedImage image = renderer.renderImageWithDPI(page, 150);
byte[] imageBytes = toBytes(image, "PNG");

// 调用视觉 embedding 服务
float[] vector = visionEmbeddingClient.encode(imageBytes);

Document doc = new Document();
doc.add(new KnnFloatVectorField("page_vector", vector,
VectorSimilarityFunction.COSINE));
doc.add(new StringField("doc_id", docId, Field.Store.YES));
doc.add(new IntField("page_number", page, Field.Store.YES));
doc.add(new StringField("pdf_path", pdfPath, Field.Store.YES));
writer.addDocument(doc);
}

检索与结果展示

检索结果返回的是页码而非文本片段:

1
2
3
4
5
6
7
8
9
10
11
{
"query": "线程池配置参数",
"results": [
{
"docId": "java-concurrency-guide",
"pageNumber": 42,
"score": 0.87,
"pdfPath": "/docs/java-concurrency-guide.pdf"
}
]
}

前端展示:渲染对应页面的缩略图,高亮匹配区域(如果使用 ColPali,可以根据 patch 位置高亮)。

OCR + 视觉的混合方案

单独使用视觉 embedding 会丢失精确文本匹配能力——搜错误码 NullPointerException,视觉模型可能无法精确识别这个字符串。

混合方案:

1
2
3
4
5
6
7
PDF 页面
├── OCR → 文本 → BM25 索引 + 文本 embedding
└── 视觉模型 → 页面向量 → HNSW 索引

三路融合(BM25 + text dense + vision dense)

结果(带页码定位)
路线 精确匹配 图表理解 版式保留 索引成本
纯 OCR
纯视觉
OCR + 视觉 高(双倍索引)

双倍索引成本是真实代价。对于本系列的小规模语料,成本可以接受;大规模场景下需要评估边际收益是否值得。

中文 PDF 的特殊挑战

中文 PDF 文档有几个特有问题:

字体嵌入:部分中文 PDF 使用图片字体或自定义编码,OCR 和文本提取都可能失败。视觉 embedding 不受影响——它看到的是渲染后的图像。

竖排文字:古籍、部分法律文书使用竖排排版。OCR 的行检测逻辑通常假设横排,竖排场景错误率高。视觉模型对排版方向不敏感。

繁简混合:同一文档中可能混合使用繁体和简体。OCR 需要选择识别模式,可能漏识别另一种字形。视觉 embedding 同样不受影响。

扫描质量:老旧扫描件分辨率低、倾斜、有污渍。渲染为图像前需要做预处理(去噪、纠偏、二值化),否则视觉模型的效果也会下降。

硬件与推理成本

视觉 embedding 模型的推理成本高于文本 embedding:

模型 参数量 单页推理时间(CPU) 单页推理时间(GPU)
Qwen3-Embedding-0.6B(文本) 0.6B ~50ms ~5ms
Qwen3-VL-Embedding(图像) ~2B ~500ms ~30ms

100 页 PDF 的索引时间:CPU 约 50 秒,GPU 约 3 秒。对于离线索引构建可以接受,但在线实时索引需要 GPU。

练习

  1. 用 Apache PDFBox 渲染一个 PDF 的前 5 页为图像,记录每页的分辨率和文件大小
  2. 对同一页 PDF 分别做 OCR 文本提取和视觉描述,对比信息完整度
  3. 设计实验:准备 10 个包含表格/图表的查询,分别用 OCR 文本检索和视觉检索,对比命中率

延伸阅读

  • Faysse et al., “ColPali: Efficient Document Retrieval with Vision Language Models”, 2024
  • Qwen3-VL-Embedding 官方文档与模型卡
  • Apache PDFBox 官方文档