使用 Qdrant 扩展 PDF 检索

| 时间:30分钟 | 难度:中等 | 输出:GitHub |
|---|
高效的 PDF 文档检索是(智能体)检索增强生成(RAG)和许多其他基于搜索的应用程序中的常见需求。同时,设置 PDF 文档检索很少能不遇到额外的挑战。
许多传统的 PDF 检索解决方案依赖于光学字符识别 (OCR) 和特定用例的启发式方法来处理表格、图像和图表等视觉复杂元素。这些算法通常不可移植——即使在同一个领域内——其任务定制的解析和分块策略、劳动密集型、容易出错且难以扩展。
视觉大型语言模型 (VLLMs) 的最新进展,例如 ColPali 及其后续模型 ColQwen,开始了 PDF 检索的转型。这些多模态模型直接将 PDF 页面作为输入,无需预处理。任何可以转换为图像的内容(将 PDF 视为文档页面的截图)都可以通过这些模型有效处理。VLLMs 使用起来更简单,在 PDF 检索基准测试(如 视觉文档检索 (ViDoRe) 基准测试)中实现了最先进的性能。
VLLMs 如何用于 PDF 检索
像 ColPali 和 ColQwen 这样的 VLLMs 为每个 PDF 页面生成多向量表示;这些表示存储并索引在向量数据库中。在检索过程中,模型动态地为(文本)用户查询创建多向量表示,并通过晚期交互机制实现精确检索——PDF 页面和查询之间的匹配。
扩展 VLLMs 的挑战
VLLMs 生成的沉重多向量表示使得大规模 PDF 检索在计算上非常密集。如果未经优化使用,这些模型对于大规模 PDF 检索任务效率低下。
扩展背后的数学原理
ColPali 每 PDF 页面生成超过 1,000 个向量,而其后续模型 ColQwen 生成的向量略少——最多 768 个向量,根据图像大小动态调整。通常,ColQwen 每页生成约 700 个向量。
为了理解其影响,考虑构建一个 HNSW 索引,这是一种常见的向量数据库索引算法。让我们粗略估计将一个新 PDF 页面插入到索引中所需的比较次数。
- 每页向量数: ~700 (ColQwen) 或 ~1,000 (ColPali)
- ef_construct: 100 (默认)
向量比较次数的下限估计将是
$$ 700 \times 700 \times 100 = 49 \ \text{百万} $$
现在想象一下,在 20,000 页上构建索引需要多长时间!
对于 ColPali,这个数字会翻倍。结果是索引构建时间极慢。
我们的解决方案
我们建议在第一阶段检索中减少 PDF 页面表示中的向量数量。在第一阶段检索使用减少的向量数量后,我们建议使用原始未压缩的表示对检索到的子集进行重排。
向量的减少可以通过对多向量 VLLM 生成的输出应用平均池化操作来实现。平均池化对选定子组中所有向量的值进行平均,将多个向量压缩为一个代表性向量。如果操作得当,它可以在显著减少向量数量的同时保留原始页面的重要信息。
VLLMs 生成的向量对应于代表 PDF 页面不同部分的补丁。这些补丁可以按 PDF 页面的行和列分组。
例如
- ColPali 将 PDF 页面划分为 1,024 个补丁。
- 通过对这个补丁矩阵的行(或列)应用平均池化,可以将页面表示减少到仅 32 个向量。

我们使用 ColPali 模型测试了这种方法,通过 PDF 页面行对其多向量进行平均池化。结果显示:
- 索引时间加快了一个数量级
- 检索质量与原始模型相当
有关此实验的详细信息,请参阅我们的 GitHub 存储库、ColPali 优化博客文章 或 “大规模 PDF 检索”网络研讨会。
本教程的目标
在本教程中,我们将演示一种使用 Qdrant 和 ColPali & ColQwen2 VLLMs 进行可扩展 PDF 检索的方法。强烈推荐此方法,以避免索引时间过长和检索速度慢的常见陷阱。
在以下部分中,我们将演示一种源于我们成功实验的优化检索算法
使用平均池化向量进行第一阶段检索
- 仅使用平均池化向量构建 HNSW 索引。
- 将其用于第一阶段检索。
使用原始模型多向量进行重排
- 使用 ColPali 或 ColQwen2 的原始多向量对第一阶段检索到的结果进行重排。
设置
安装并导入所需的库
# pip install colpali_engine>=0.3.1
from colpali_engine.models import ColPali, ColPaliProcessor
# pip install qdrant-client>=1.12.0
from qdrant_client import QdrantClient, models
为了运行这些实验,我们使用 Qdrant 集群。如果您刚开始,可以设置一个免费层集群进行测试和探索。请遵循文档中的说明 “如何创建免费层 Qdrant 集群”。
client = QdrantClient(
url=<YOUR CLUSTER URL>,
api_key=<YOUR API KEY>
)
下载 ColPali 模型及其输入处理器。确保选择适合您设置的后端。
colpali_model = ColPali.from_pretrained(
"vidore/colpali-v1.3",
torch_dtype=torch.bfloat16,
device_map="mps", # Use "cuda:0" for GPU, "cpu" for CPU, or "mps" for Apple Silicon
).eval()
colpali_processor = ColPaliProcessor.from_pretrained("vidore/colpali-v1.3")
对于 ColQwen 模型
from colpali_engine.models import ColQwen2, ColQwen2Processor
colqwen_model = ColQwen2.from_pretrained(
"vidore/colqwen2-v0.1",
torch_dtype=torch.bfloat16,
device_map="mps", # Use "cuda:0" for GPU, "cpu" for CPU, or "mps" for Apple Silicon
).eval()
colqwen_processor = ColQwen2Processor.from_pretrained("vidore/colqwen2-v0.1")
创建 Qdrant 集合
我们现在可以在 Qdrant 中创建一个集合,用于存储由 ColPali 或 ColQwen 生成的 PDF 页面多向量表示。
集合将包括按行和列进行平均池化的 PDF 页面表示,以及原始多向量表示。
client.create_collection(
collection_name=collection_name,
vectors_config={
"original":
models.VectorParams( #switch off HNSW
size=128,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
hnsw_config=models.HnswConfigDiff(
m=0 #switching off HNSW
)
),
"mean_pooling_columns": models.VectorParams(
size=128,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
)
),
"mean_pooling_rows": models.VectorParams(
size=128,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
)
)
}
)
选择数据集
本教程中,我们将使用 Daniel van Strien 的 UFO 数据集。它可在 Hugging Face 上获取;您可以直接从那里下载。
from datasets import load_dataset
ufo_dataset = "davanstrien/ufo-ColPali"
dataset = load_dataset(ufo_dataset, split="train")
嵌入和均值池化
我们将使用一个函数,它以批处理方式为每个 PDF 页面(即图像)生成多向量表示及其均值池化版本。为了完全理解,考虑 ColPali 和 ColQwen 的以下具体细节非常重要:
ColPali: 理论上,ColPali 设计为每个 PDF 页面生成 1,024 个向量,但实际上它生成 1,030 个向量。这种差异是由于 ColPali 的预处理器在每个输入前附加文本 <bos>Describe the image.。这段附加文本会产生额外的 6 个多向量。
ColQwen: ColQwen 根据 PDF 页面的大小动态确定 PDF 页面“行和列”中的补丁数量。因此,多向量的数量可能会因输入而异。ColQwen 预处理器在前面添加 <|im_start|>user<|vision_start|>,并在后面附加 <|vision_end|>Describe the image.<|im_end|><|endoftext|>。
例如,这就是 ColQwen 多向量输出的形成方式。

get_patches 函数用于获取 ColPali/ColQwen2 模型将 PDF 页面划分为的 x_patches(行)和 y_patches(列)数量。对于 ColPali,这些数字始终是 32x32;ColQwen 将根据 PDF 页面大小动态定义它们。
x_patches, y_patches = model_processor.get_n_patches(
image_size,
patch_size=model.patch_size
)
对于 ColQwen 模型
model_processor.get_n_patches(
image_size,
patch_size=model.patch_size,
spatial_merge_size=model.spatial_merge_size
)
我们选择保留前缀和后缀多向量。我们的池化操作根据模型确定的行数和列数(ColPali 固定为 32x32,ColQwen 动态为 XxY)压缩表示图像令牌的多向量。该函数保留并整合模型产生的额外多向量,将其返回到池化表示中。
ColPali 模型的简化池化版本
(请参阅教程笔记本中的完整版本,该版本也适用于 ColQwen)
processed_images = model_processor.process_images(image_batch)
# Image embeddings of shape (batch_size, 1030, 128)
image_embeddings = model(**processed_images)
# (1030, 128)
image_embedding = image_embeddings[0] # take the first element of the batch
# Now we need to identify vectors that correspond to the image tokens
# It can be done by selecting tokens corresponding to special `image_token_id`
# (1030, ) - boolean mask (for the first element in the batch), True for image tokens
mask = processed_images.input_ids[0] == model_processor.image_token_id
# For convenience, we now select only image tokens
# and reshape them to (x_patches, y_patches, dim)
# (x_patches, y_patches, 128)
image_patch_embeddings = image_embedding[mask].view(x_patches, y_patches, model.dim)
# Now we can apply mean pooling by rows and columns
# (x_patches, 128)
pooled_by_rows = image_patch_embeddings.mean(dim=0)
# (y_patches, 128)
pooled_by_columns = image_patch_embeddings.mean(dim=1)
# [Optionally] we can also concatenate special tokens to the pooled representations,
# For ColPali, it's only postfix
# (x_patches + 6, 128)
pooled_by_rows = torch.cat([pooled_by_rows, image_embedding[~mask]])
# (y_patches + 6, 128)
pooled_by_columns = torch.cat([pooled_by_columns, image_embedding[~mask]])
上传到 Qdrant
上传过程很简单;唯一需要注意的是 ColPali 和 ColQwen2 模型的计算成本。在资源受限的环境中,建议使用较小的批次大小进行嵌入和平均池化。
上传代码的完整版本可在教程笔记本中找到
查询 PDF
索引 PDF 文档后,我们可以使用我们的两阶段检索方法来查询它们。
query = "Lee Harvey Oswald's involvement in the JFK assassination"
processed_queries = model_processor.process_queries([query]).to(model.device)
# Resulting query embedding is a tensor of shape (22, 128)
query_embedding = model(**processed_queries)[0]
现在,让我们设计一个用于 VLLMs 生成的多向量两阶段检索函数。
- 步骤 1: 使用压缩的多向量表示和 HNSW 索引预取结果。
- 步骤 2: 使用原始多向量表示对预取的结果进行重排。
让我们使用组合的平均池化表示查询我们的集合,进行检索的第一阶段。
# Final amount of results to return
search_limit = 10
# Amount of results to prefetch for reranking
prefetch_limit = 100
response = client.query_points(
collection_name=collection_name,
query=query_embedding,
prefetch=[
models.Prefetch(
query=query_embedding,
limit=prefetch_limit,
using="mean_pooling_columns"
),
models.Prefetch(
query=query_embedding,
limit=prefetch_limit,
using="mean_pooling_rows"
),
],
limit=search_limit,
with_payload=True,
with_vector=False,
using="original"
)
并检查我们查询“李·哈维·奥斯瓦尔德参与肯尼迪遇刺案”的排名靠前的结果。
dataset[response.points[0].payload['index']]['image']

结论
在本教程中,我们演示了一种优化的方法,使用 Qdrant 进行大规模 PDF 检索,其中 VLLM 生成了 ColPali 和 ColQwen2 等重型多向量表示。
如果没有这种优化,检索系统的性能会严重下降,无论是在索引时间还是查询延迟方面,尤其是在数据集大小增加时。
我们强烈建议在您的工作流程中实施此方法,以确保高效且可扩展的 PDF 检索。忽视优化检索过程可能导致性能慢到无法接受,从而阻碍系统的可用性。
立即开始扩展您的 PDF 检索!