距离我们发表关于如何使用 Qdrant 构建混合搜索系统的第一篇文章已经过去一年多了。其核心理念很简单:通过结合不同的搜索方法来提高检索质量。在 2023 年,你仍然需要使用额外的服务来实现词法搜索功能,并合并所有中间结果。从那时起,情况发生了变化。自从我们引入对稀疏向量的支持后,额外的搜索服务已不再必要,但你仍然需要在本地端合并来自不同方法的结果。
Qdrant 1.10 引入了全新的查询 API (Query API),让你能够通过组合不同的搜索方法来构建搜索系统,从而提升检索质量。现在一切都可以在服务器端完成,你可以专注于为用户打造最佳的搜索体验。在本文中,我们将向你展示如何利用全新的 查询 API 构建混合搜索系统。
引入全新的查询 API
在 Qdrant,我们认为向量搜索的能力远不止于简单的最近邻搜索。正因如此,我们针对不同的搜索用例提供了独立的方法,例如 search(搜索)、recommend(推荐)或 discover(发现)。在最新版本中,我们很高兴推出全新的查询 API,它将所有这些方法集成到一个单一的端点中,并支持创建嵌套的多阶段查询,可用于构建复杂的搜索管道。
如果你已经是 Qdrant 的用户,你可能已经有了一套运行中的搜索机制(无论是稀疏还是密集向量),并希望对其进行改进。在进行任何更改之前,都应先对其有效性进行适当的评估。
你的搜索系统有多有效?
如果你不衡量质量,所有的实验都将毫无意义。否则,你如何比较哪种方法更适合你的用例?最常用的方法是使用标准指标,例如 precision@k(准确率)、MRR(平均倒数排名)或 NDCG(归一化折损累计增益)。目前已有现成的库(例如 ranx)可以帮助你实现这一目标。我们需要有一个基准(ground truth)数据集来计算这些指标,但整理该数据集是一项独立的工作。
from ranx import Qrels, Run, evaluate
# Qrels, or query relevance judgments, keep the ground truth data
qrels_dict = { "q_1": { "d_12": 5, "d_25": 3 },
"q_2": { "d_11": 6, "d_22": 1 } }
# Runs are built from the search results
run_dict = { "q_1": { "d_12": 0.9, "d_23": 0.8, "d_25": 0.7,
"d_36": 0.6, "d_32": 0.5, "d_35": 0.4 },
"q_2": { "d_12": 0.9, "d_11": 0.8, "d_25": 0.7,
"d_36": 0.6, "d_22": 0.5, "d_35": 0.4 } }
# We need to create both objects, and then we can evaluate the run against the qrels
qrels = Qrels(qrels_dict)
run = Run(run_dict)
# Calculating the NDCG@5 metric is as simple as that
evaluate(qrels, run, "ndcg@5")
查询 API 支持的嵌入选项
在 Qdrant 中,每个点支持多个向量早已不是新鲜事,但查询 API 的引入使其更加强大。1.10 版本支持多向量(multivectors),允许你将嵌入列表视为一个单一实体。利用此功能的方法有很多,最突出的是对延迟交互模型(late interaction models)的支持,例如 ColBERT。与为每个文档或查询创建一个单独的嵌入不同,这类模型为文本的每个 Token 创建一个嵌入。在搜索过程中,最终分数是基于查询 Token 和文档 Token 之间的交互计算出来的。与交叉编码器(cross-encoders)相反,文档嵌入可以预先计算并存储在数据库中,这使得搜索过程快得多。如果你对细节感兴趣,请查看由 Jina AI 的朋友们撰写的关于 ColBERT 的文章。

除了多向量,你还可以使用常规的密集和稀疏向量,并尝试使用更小的数据类型来减少内存使用。命名向量(Named vectors)可以帮助你存储不同维度的嵌入,如果你使用多个模型来表示数据,或者想要利用 Matryoshka 嵌入,这非常有用。

构建混合搜索并没有唯一的方法。设计过程是一个探索性的练习,你需要测试各种设置并衡量其有效性。构建良好的搜索体验是一项复杂的任务,最好以数据驱动为核心,而不是仅仅依靠直觉。
融合 (Fusion) 与重排序 (Reranking)
我们可以将构建混合搜索系统的两种主要方法区分开来:融合和重排序。前者是指仅基于每种方法返回的分数来组合不同搜索方法的结果。这通常涉及某种归一化处理,因为不同方法返回的分数可能处于不同的范围内。此后,使用一个公式获取相关性度量并计算最终分数,我们随后利用该分数对文档进行重新排序。Qdrant 内置了对“倒数排名融合”(Reciprocal Rank Fusion)方法的支持,这是该领域的行业标准。

另一方面,重排序是指从不同的搜索方法获取结果,并根据除了分数之外的内容进行额外处理来重新排序。这种处理可能依赖于额外的神经模型(如交叉编码器),但直接在整个数据集上使用效率太低。这些方法实际上仅在应用于较快速的搜索方法返回的较小的候选项子集时才可行。延迟交互模型(如 ColBERT)在这种情况下效率更高,因为它们可以在无需访问集合中所有文档的情况下对候选项进行重排序。

为什么不使用线性组合?
人们经常建议使用全文搜索分数和向量搜索分数来形成线性组合公式以对结果进行重排序。例如:
final_score = 0.7 * vector_score + 0.3 * full_text_score
然而,我们甚至没有考虑过这种方案。为什么?因为这些分数并没有使问题变得线性可分。我们将 BM25 分数与余弦向量相似度一起使用,将它们作为二维空间中的点坐标。图表显示了这些点的分布情况:

Qdrant 和 BM25 分数映射到二维空间后的分布。这清楚地表明,相关和不相关的对象在该空间中并非线性可分,因此使用两者的线性组合无法提供有效的混合搜索。
相关和不相关的项混杂在一起。任何线性公式都无法区分它们。 因此,这不是解决问题的正确方法。
在 Qdrant 中构建混合搜索系统
归根结底,任何搜索机制也可以是一个重排序机制。你可以先用稀疏向量预取结果,然后用密集向量重排序,反之亦然。或者,如果你有 Matryoshka 嵌入,你可以先用最低维度的密集向量对候选项进行过采样,然后通过使用更高维度的嵌入进行重排序来逐步减少候选项数量。没有任何因素阻止你结合融合和重排序。
让我们更进一步,构建一个混合搜索机制,它结合了 Matryoshka 嵌入、密集向量和稀疏向量的结果,然后用延迟交互模型进行重排序。同时,我们将引入额外的重排序和融合步骤。

我们的搜索管道由两个分支组成,每个分支负责检索我们最终想要用延迟交互模型重排序的文档子集。让我们先连接到 Qdrant,然后构建搜索管道。
from qdrant_client import QdrantClient, models
client = QdrantClient("https://:6333")
所有利用 Matryoshka 嵌入的步骤都可以在查询 API 中指定为嵌套结构。
# The first branch of our search pipeline retrieves 25 documents
# using the Matryoshka embeddings with multistep retrieval.
matryoshka_prefetch = models.Prefetch(
prefetch=[
models.Prefetch(
prefetch=[
# The first prefetch operation retrieves 100 documents
# using the Matryoshka embeddings with the lowest
# dimensionality of 64.
models.Prefetch(
query=[0.456, -0.789, ..., 0.239],
using="matryoshka-64dim",
limit=100,
),
],
# Then, the retrieved documents are re-ranked using the
# Matryoshka embeddings with the dimensionality of 128.
query=[0.456, -0.789, ..., -0.789],
using="matryoshka-128dim",
limit=50,
)
],
# Finally, the results are re-ranked using the Matryoshka
# embeddings with the dimensionality of 256.
query=[0.456, -0.789, ..., 0.123],
using="matryoshka-256dim",
limit=25,
)
同样,我们可以构建搜索管道的第二个分支,它使用密集和稀疏向量检索文档,并使用“倒数排名融合”方法对它们进行融合。
# The second branch of our search pipeline also retrieves 25 documents,
# but uses the dense and sparse vectors, with their results combined
# using the Reciprocal Rank Fusion.
sparse_dense_rrf_prefetch = models.Prefetch(
prefetch=[
models.Prefetch(
prefetch=[
# The first prefetch operation retrieves 100 documents
# using dense vectors using integer data type. Retrieval
# is faster, but quality is lower.
models.Prefetch(
query=[7, 63, ..., 92],
using="dense-uint8",
limit=100,
)
],
# Integer-based embeddings are then re-ranked using the
# float-based embeddings. Here we just want to retrieve
# 25 documents.
query=[-1.234, 0.762, ..., 1.532],
using="dense",
limit=25,
),
# Here we just add another 25 documents using the sparse
# vectors only.
models.Prefetch(
query=models.SparseVector(
indices=[125, 9325, 58214],
values=[-0.164, 0.229, 0.731],
),
using="sparse",
limit=25,
),
],
# RRF is activated below, so there is no need to specify the
# query vector here, as fusion is done on the scores of the
# retrieved documents.
query=models.FusionQuery(
fusion=models.Fusion.RRF,
),
)
第二个分支其实已经可以被称为混合搜索了,因为它通过融合结合了密集和稀疏向量的结果。然而,我们完全可以构建更复杂的搜索管道。
以下是 Python 中针对查询 API 的目标调用方式:
client.query_points(
"my-collection",
prefetch=[
matryoshka_prefetch,
sparse_dense_rrf_prefetch,
],
# Finally rerank the results with the late interaction model. It only
# considers the documents retrieved by all the prefetch operations above.
# Return 10 final results.
query=[
[1.928, -0.654, ..., 0.213],
[-1.197, 0.583, ..., 1.901],
...,
[0.112, -1.473, ..., 1.786],
],
using="late-interaction",
with_payload=False,
limit=10,
)
选择是无限的,新的查询 API 为你提供了尝试不同设置的灵活性。你很少需要构建如此复杂的搜索管道,但知道在需要时可以做到这一点是一件好事。
经验总结:多向量表示
你们中的许多人已经开始构建混合搜索系统,并向我们提供了问题和反馈。我们看到了许多不同的方法,但一个反复出现的想法是:在通过单向量密集/稀疏方法检索到候选项后,利用具有 ColBERT 风格模型的多向量表示作为重排序步骤。这反映了该领域的最新趋势,因为单向量方法仍然是最有效的,而多向量能更好地捕捉文本的细微差别。

假设你从不单独使用延迟交互模型进行检索,而仅将其用于重排序,那么这种设置会带来隐性成本。默认情况下,集合中配置的每个密集向量都会创建一个相应的 HNSW 图,即使它是多向量。
from qdrant_client import QdrantClient, models
client = QdrantClient(...)
client.create_collection(
collection_name="my-collection",
vectors_config={
"dense": models.VectorParams(...),
"late-interaction": models.VectorParams(
size=128,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
)
},
sparse_vectors_config={
"sparse": models.SparseVectorParams(...)
},
)
重排序将永远不会使用已创建的图,因为所有候选项都已检索完毕。多向量排名仅应用于前序步骤检索到的候选项,因此无需搜索操作。HNSW 变得多余,但仍需进行索引构建过程,在这种情况下,索引构建会相当耗时。ColBERT 类模型为每个文档创建数百个嵌入,因此开销很大。为了避免这种情况,你可以对此类模型禁用 HNSW 图的创建:
client.create_collection(
collection_name="my-collection",
vectors_config={
"dense": models.VectorParams(...),
"late-interaction": models.VectorParams(
size=128,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
hnsw_config=models.HnswConfigDiff(
m=0, # Disable HNSW graph creation
),
)
},
sparse_vectors_config={
"sparse": models.SparseVectorParams(...)
},
)
你不会察觉到搜索性能有任何差异,但在将嵌入上传到集合时,资源的使用量将显著降低。
一些轶事观察
没有任何一种算法在所有情况下表现最好。在某些情况下,基于关键词的搜索会胜出,反之亦然。下表显示了我们在实验过程中在 WANDS 数据集中发现的一些有趣的例子:
| 查询 (Query) | BM25 搜索 | 向量搜索 |
|---|---|---|
| cybersport desk(电竞桌) | desk(桌子) ❌ | gaming desk(电竞桌) ✅ |
| plates for icecream(冰淇淋盘) | "eat" plates on wood wall décor(墙面装饰盘) ❌ | alicyn 8.5 '' melamine dessert plate(8.5英寸密胺甜点盘) ✅ |
| kitchen table with a thick board(带厚板的餐桌) | craft kitchen acacia wood cutting board(金合欢木砧板) ❌ | industrial solid wood dining table(工业风实木餐桌) ✅ |
| wooden bedside table(木制床头柜) | 30 '' bedside table lamp(30英寸床头台灯) ❌ | portable bedside end table(便携式床头边桌) ✅ |
也有关键词搜索表现更好的例子
| 查询 (Query) | BM25 搜索 | 向量搜索 |
|---|---|---|
| computer chair(电脑椅) | vibrant computer task chair(鲜艳的电脑工作椅) ✅ | office chair(办公椅) ❌ |
| 64.2 inch console table(64.2英寸玄关桌) | cervantez 64.2 '' console table(Cervantez 64.2英寸玄关桌) ✅ | 69.5 '' console table(69.5英寸玄关桌) ❌ |
在 Qdrant 1.10 中尝试新的查询 API
Qdrant 1.10 中引入的新查询 API 是构建混合搜索系统的革命性变革。你不再需要任何额外的服务来合并来自不同搜索方法的结果,甚至可以创建更复杂的管道并直接从 Qdrant 提供服务。
我们关于构建终极混合搜索的网络研讨会将带你了解使用 Qdrant 查询 API 构建混合搜索系统的全过程。如果你错过了,可以观看录像,或者查看笔记本。
如果你有任何问题或在构建混合搜索系统时需要帮助,请随时通过 Discord 与我们联系。
