系统讲解向量搜索中的过滤策略与优化技巧,适用于 RAG、电商搜索等实战场景。
Sabrina Aquino、David Myriel
假设你销售计算机硬件。为了帮助购物者轻松地在你的网站上找到商品,你需要提供一个用户友好的搜索引擎。
如果你销售计算机,并拥有大量关于笔记本电脑、台式机和配件的数据,那么搜索功能应该引导顾客找到他们想要的确切设备——或者至少是非常相似的匹配项。
在 Qdrant 中存储数据时,每件商品都是一个点,由 id、向量和 payload 组成:
{
"id": 1,
"vector": [0.1, 0.2, 0.3, 0.4],
"payload": {
"price": 899.99,
"category": "laptop"
}
}
id 是集合中该点的唯一标识符。向量以数学形式表示它与集合中其他点的相似度。最后,payload 保存直接描述该点的元数据。
虽然我们可能无法解读向量,但可以从元数据中获取该商品的额外信息。在这个具体示例中,我们看到的是一个价格为 899.99 美元的笔记本电脑数据点。
顾客在搜索理想的计算机时,最终得到的结果可能在数学意义上与搜索输入相似,却并不完全符合要求。例如,如果他们搜索价格低于 1000 美元的笔记本电脑,未添加约束的简单向量搜索仍可能显示价格超过 1000 美元的其他笔记本电脑。
这就是为什么仅靠语义搜索可能还不够。为了得到准确的结果,你需要针对价格强制应用 payload 过滤器。只有这样,才能确保搜索结果符合选定的特征。
这称为过滤,是向量数据库的关键功能之一。
下面展示了过滤向量搜索在幕后的样子。我们将在下一节介绍其工作机制。
POST /collections/online_store/points/search
{
"vector": [ 0.2, 0.1, 0.9, 0.7 ],
"filter": {
"must": [
{
"key": "category",
"match": { "value": "laptop" }
},
{
"key": "price",
"range": {
"gt": null,
"gte": null,
"lt": null,
"lte": 1000
}
}
]
},
"limit": 3,
"with_payload": true,
"with_vector": false
}
过滤后的结果将结合语义搜索与查询中施加的过滤条件。在接下来的内容中,我们将说明过滤为何是向量搜索中的一项关键实践,原因有两个:
借助 Qdrant 中的过滤功能,你可以显著提高搜索精度。下一节将对此进行详细介绍。
过滤有助于控制资源并减少计算量。更多内容请参阅 Payload 索引。
本指南将介绍以下内容:
在向量搜索中,过滤与排序之间的依赖关系比传统数据库中更加紧密。虽然 SQL 等数据库使用 WHERE 和 ORDER BY 之类的命令,但在向量搜索中,这些处理过程之间的相互作用要更复杂一些。
大多数人使用默认设置来构建向量搜索应用,却没有针对精确检索进行正确配置,甚至没有完成相应设置。在本指南中,我们将向你展示如何通过一些易于实现的基础和高级策略,利用过滤充分发挥向量搜索的能力。
体验“Hello World”时刻最简单的方法,是在真实集群中尝试过滤。我们的交互式教程将向你展示如何创建集群、添加数据并尝试一些过滤子句。
Qdrant 遵循一种特定的方法,在稠密向量中执行搜索和过滤。
让我们来看一下这张包含三个阶段的示意图。在这个示例中,我们试图找到距离查询向量(绿色)最近的邻居。你的搜索过程从底部(橙色)开始。
默认情况下,Qdrant 会在向量索引中连接所有数据点。引入过滤器后,一些数据点会断开连接。向量搜索无法穿过灰色区域,因此也无法到达最近邻。我们该如何弥合这一间隙?
图 1:Qdrant 如何维护可过滤的向量索引。
可过滤的向量索引:这种技术会在剩余的数据点之间建立额外的连接(橙色)。过滤后保留下来的点现在又可以被遍历了。Qdrant 使用基于类别的特殊方法连接这些数据点。
Qdrant 的可过滤向量索引通过向搜索图添加专用连接,解决了预过滤和后过滤问题。它旨在保留向量搜索的速度优势,同时允许执行精确过滤,避免在向量搜索之后应用过滤器可能导致的低效问题。
在预过滤中,搜索引擎首先根据选定的元数据值缩小数据集的范围,然后在过滤后的子集中进行搜索。这可以避免在一个可能大得多的数据集上执行不必要的计算。
选择预过滤还是使用可过滤 HNSW 索引,取决于过滤器的基数。当元数据基数过低时,过滤器会变得过于严格,并可能破坏图中的连接。这会导致搜索路径碎片化(如图 1 所示)。语义搜索过程开始后,将无法到达这些位置。
不过,在某些条件下,Qdrant 仍然可以从预过滤中获益。在低基数情况下,Qdrant 的查询规划器会停止使用 HNSW,转而仅使用 payload 索引。与使用 HNSW 相比,这会使搜索过程更加经济、快速。
图 2:从用户角度看,过滤过程如下。我们从五件价格不同的商品开始。首先应用 1000 美元的价格过滤器,缩小笔记本电脑的选择范围。然后,向量搜索在这个过滤后的集合中找出相关结果。
总而言之,对于使用低基数元数据的小型数据集,预过滤在特定情况下效率很高。然而,不应在大型数据集上使用预过滤,因为它会破坏 HNSW 图中过多的连接,从而降低准确率。
在后过滤中,搜索引擎首先查找相似向量并检索一个更大的结果集,然后根据元数据对这些结果应用过滤器。使用低基数过滤器时,后过滤的问题就会显现出来。
在执行向量搜索之后应用低基数过滤器,通常会丢弃向量搜索返回的大部分结果。
图 3:在同一个示例中,我们有五台笔记本电脑。首先,向量搜索找出相关性最高的两个结果,但它们可能不符合价格条件。应用 1000 美元的价格过滤器后,其他潜在结果也已被丢弃。
系统会先查找相似向量,然后丢弃许多不符合过滤条件的向量,从而浪费计算资源。此外,你只能在最初的向量搜索结果集中进行过滤。如果想要的商品不在这个初始集合中,那么即使它们确实存在于数据库中,你也无法找到它们。
我们知道,有三台笔记本电脑符合我们的价格要求。接下来看看 Qdrant 的可过滤向量索引如何工作,以及为什么它是捕获所有可用结果的最佳方法。
首先,向你的在线商店添加五台新笔记本电脑。以下是一个输入示例:
laptops = [
(1, [0.1, 0.2, 0.3, 0.4], {"price": 899.99, "category": "laptop"}),
(2, [0.2, 0.3, 0.4, 0.5], {"price": 1299.99, "category": "laptop"}),
(3, [0.3, 0.4, 0.5, 0.6], {"price": 799.99, "category": "laptop"}),
(4, [0.4, 0.5, 0.6, 0.7], {"price": 1099.99, "category": "laptop"}),
(5, [0.5, 0.6, 0.7, 0.8], {"price": 949.99, "category": "laptop"})
]
这个四维向量可以表示笔记本电脑的 CPU、RAM 或电池续航等特征,但这里并未明确说明。不过,payload 明确指定了商品的确切价格和类别。
现在,将过滤器设置为“价格低于 1000 美元”:
{
"key": "price",
"range": {
"gt": null,
"gte": null,
"lt": null,
"lte": 1000
}
}
应用价格小于或等于 1000 美元的过滤器后,向量搜索返回以下结果:
[
{
"id": 3,
"score": 0.9978443564622781,
"payload": {
"price": 799.99,
"category": "laptop"
}
},
{
"id": 1,
"score": 0.9938079894227599,
"payload": {
"price": 899.99,
"category": "laptop"
}
},
{
"id": 5,
"score": 0.9903751498208603,
"payload": {
"price": 949.99,
"category": "laptop"
}
}
]
如你所见,Qdrant 的过滤方法更有可能捕获所有可能的搜索结果。
这个具体示例使用范围条件进行过滤。不过,Qdrant 还提供了许多其他构造过滤器的方式。
如需查看详细用法示例,过滤文档是最佳资源。
你不需要使用我们的搜索和查询 API 来过滤数据。Scroll API 是另一种选择,它让你检索符合过滤条件的点的列表。
如果你不对寻找相似点感兴趣,你可以直接列出与给定过滤条件匹配的点。虽然搜索会根据某个查询向量给你最相似的点,但 scroll 会给你所有与过滤条件匹配的点,不考虑相似性。
在 Qdrant 中,scrolling 用于从集合中迭代检索大量点。当处理大量点且不想一次性加载所有点时,它特别有用。Qdrant 提供了一种逐页滚动查看点的方式。
首先,你向 Qdrant 发送一个带有特定条件的 scroll 请求,如按 payload 过滤、向量搜索或其他标准。
让我们检索按价格排序的商店中排名前 10 的笔记本电脑列表:
POST /collections/online_store/points/scroll
{
"filter": {
"must": [
{
"key": "category",
"match": {
"value": "laptop"
}
}
]
},
"limit": 10,
"with_payload": true,
"with_vector": false,
"order_by": [
{
"key": "price",
}
]
}
响应包含与条件匹配的一批点和一个引用(偏移量或下一页令牌),用于检索下一组点。
Scrolling 设计得很高效。通过每次只返回可管理的数据块,它最小化了服务器负载并降低了客户端的内存消耗。
所有条款和条件都在 Qdrant 的过滤文档中列出。
我们还可以使用嵌套过滤来查询 payload 中的对象数组。在这个例子中,我们有两个点。每个点都代表一只恐龙,其中包含一个食物偏好列表(饮食),表示它喜欢或不喜欢什么类型的食物:
[
{
"id": 1,
"dinosaur": "t-rex",
"diet": [
{ "food": "leaves", "likes": false},
{ "food": "meat", "likes": true}
]
},
{
"id": 2,
"dinosaur": "diplodocus",
"diet": [
{ "food": "leaves", "likes": true},
{ "food": "meat", "likes": false}
]
}
]
为了确保两个条件都应用于同一数组元素(例如,food = meat 和 likes = true 必须指向同一个饮食项),你需要使用嵌套过滤。
嵌套过滤用于在对象数组中应用条件。它们确保条件对每个数组元素单独评估,而不是跨所有元素。
POST /collections/dinosaurs/points/scroll
{
"filter": {
"must": [
{
"key": "diet[].food",
"match": {
"value": "meat"
}
},
{
"key": "diet[].likes",
"match": {
"value": true
}
}
]
}
}
client.scroll(
collection_name="dinosaurs",
scroll_filter=models.Filter(
must=[
models.FieldCondition(
key="diet[].food", match=models.MatchValue(value="meat")
),
models.FieldCondition(
key="diet[].likes", match=models.MatchValue(value=True)
),
],
),
)
client.scroll("dinosaurs", {
filter: {
must: [
{
key: "diet[].food",
match: { value: "meat" },
},
{
key: "diet[].likes",
match: { value: true },
},
],
},
});
use qdrant_client::qdrant::{Condition, Filter, ScrollPointsBuilder};
client
.scroll(
ScrollPointsBuilder::new("dinosaurs").filter(Filter::must([
Condition::matches("diet[].food", "meat".to_string()),
Condition::matches("diet[].likes", true),
])),
)
.await?;
import java.util.List;
import static io.qdrant.client.ConditionFactory.match;
import static io.qdrant.client.ConditionFactory.matchKeyword;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Common.Filter;
import io.qdrant.client.grpc.Points.ScrollPoints;
QdrantClient client =
new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());
client
.scrollAsync(
ScrollPoints.newBuilder()
.setCollectionName("dinosaurs")
.setFilter(
Filter.newBuilder()
.addAllMust(
List.of(matchKeyword("diet[].food", "meat"), match("diet[].likes", true)))
.build())
.build())
.get();
using Qdrant.Client;
using static Qdrant.Client.Grpc.Conditions;
var client = new QdrantClient("localhost", 6334);
await client.ScrollAsync(
collectionName: "dinosaurs",
filter: MatchKeyword("diet[].food", "meat") & Match("diet[].likes", true)
);
这之所以发生是因为两个点都匹配两个条件:t-rex 在 diet[1].food 上匹配 food=meat,在 diet[1].likes 上匹配 likes=true;diplodocus 在 diet[1].food 上匹配 food=meat,在 diet[0].likes 上匹配 likes=true。
为了只检索条件应用于数组中特定元素的点(如本例中 id 为 1 的点),你需要使用嵌套对象过滤。
嵌套对象过滤支持独立地查询对象数组,确保在单个数组元素内检查条件。
这是通过使用嵌套条件类型完成的,它包括一个针对数组的 payload key 和一个要应用的过滤器。该 key 应该引用一个对象数组,可以使用或不使用括号符号(例如 "diet" 或 "diet[]")。
POST /collections/dinosaurs/points/scroll
{
"filter": {
"must": [{
"nested": {
"key": "diet",
"filter":{
"must": [
{
"key": "food",
"match": {
"value": "meat"
}
},
{
"key": "likes",
"match": {
"value": true
}
}
]
}
}
}]
}
}
client.scroll(
collection_name="dinosaurs",
scroll_filter=models.Filter(
must=[
models.NestedCondition(
nested=models.Nested(
key="diet",
filter=models.Filter(
must=[
models.FieldCondition(
key="food", match=models.MatchValue(value="meat")
),
models.FieldCondition(
key="likes", match=models.MatchValue(value=True)
),
]
),
)
)
],
),
)
client.scroll("dinosaurs", {
filter: {
must: [
{
nested: {
key: "diet",
filter: {
must: [
{
key: "food",
match: { value: "meat" },
},
{
key: "likes",
match: { value: true },
},
],
},
},
},
],
},
});
use qdrant_client::qdrant::{Condition, Filter, NestedCondition, ScrollPointsBuilder};
client
.scroll(
ScrollPointsBuilder::new("dinosaurs").filter(Filter::must([NestedCondition {
key: "diet".to_string(),
filter: Some(Filter::must([
Condition::matches("food", "meat".to_string()),
Condition::matches("likes", true),
])),
}
.into()])),
)
.await?;
import java.util.List;
import static io.qdrant.client.ConditionFactory.match;
import static io.qdrant.client.ConditionFactory.matchKeyword;
import static io.qdrant.client.ConditionFactory.nested;
import io.qdrant.client.grpc.Common.Filter;
import io.qdrant.client.grpc.Points.ScrollPoints;
client .scrollAsync( ScrollPoints.newBuilder() .setCollectionName("dinosaurs") .setFilter( Filter.newBuilder() .addMust( nested( "diet", Filter.newBuilder() .addAllMust( List.of( matchKeyword("food", "meat"), match("likes", true))) .build())) .build()) .build()) .get();
```csharp
using Qdrant.Client;
using static Qdrant.Client.Grpc.Conditions;
var client = new QdrantClient("localhost", 6334);
await client.ScrollAsync(
collectionName: "dinosaurs",
filter: Nested("diet", MatchKeyword("food", "meat") & Match("likes", true))
);
匹配逻辑经过调整,将在 payload 中数组的各个元素层面执行,而不是将所有数组元素作为一个整体进行匹配。
嵌套过滤器的工作方式,就像是分别对数组中的每个元素进行求值。只要至少有一个数组元素满足嵌套过滤器的所有条件,父文档就会被视为匹配。
即使不知道数据点的 ID,也可以使用过滤器检索它们。你可以只使用过滤器搜索和管理数据。下面来看一些过滤器的创意用法:
开始使用 Qdrant 时,默认情况下,你的数据会组织在向量索引中。除此之外,我们还建议添加一种辅助数据结构——payload 索引。
正如向量索引用于组织向量一样,payload 索引将用于组织元数据。
图 4:payload 索引是一种支持向量搜索的附加数据结构。payload 索引(绿色)按基数组织候选结果,使语义搜索(红色)能够快速遍历向量索引。
单独对数 TB 的数据执行语义搜索可能会占用大量 RAM。过滤和索引是两种简单的策略,既能减少计算资源的使用,又能获得最佳结果。请记住,这只是一份指南。如需查看完整的过滤选项列表,请阅读过滤文档。
以下是为元数据字段 category 创建单个索引的方法:
PUT /collections/computers/index
{
"field_name": "category",
"field_schema": "keyword"
}
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
client.create_payload_index(
collection_name="computers",
field_name="category",
field_schema="keyword",
)
将某个字段标记为可索引后,你无需执行其他操作。Qdrant 会在后台处理所有优化工作。
payload 索引充当辅助数据结构,可以加快检索速度。每当你使用过滤器执行向量搜索时,如果存在 payload 索引,Qdrant 就会查询该索引。
随着数据集的复杂度不断增加,Qdrant 需要使用更多资源来遍历所有数据点。如果没有合适的数据结构,搜索可能需要更长时间,甚至可能耗尽资源。
payload 索引还用于准确估算过滤器基数,帮助查询规划器选择搜索策略。过滤器基数是指过滤器在数据集中可以匹配的不同值的数量。如果基数过低,Qdrant 的搜索策略可以从 HNSW 搜索切换为基于 payload 索引的搜索。
它对查询的影响:根据搜索中使用的过滤器,查询可能有多种执行方式。Qdrant 会根据可用索引、条件的复杂度以及过滤结果的基数,选择一种查询执行方案。
规划器会先估算过滤结果的基数,然后再选择策略。
如果基数低于阈值,Qdrant 会使用 payload 索引检索数据点。
如果基数高于阈值,Qdrant 会使用支持过滤的向量索引。
在查询中使用过滤器时,Qdrant 需要估算这些过滤器的基数,以制定适当的查询计划。如果不创建 payload 索引,Qdrant 将无法进行这种估算。它最终可能选择次优的搜索方式,从而导致搜索速度极慢或结果准确率较低。
如果只依赖最近邻向量搜索,Qdrant 将不得不遍历整个向量索引。它会计算集合中每个向量的相似度,无论这些向量是否相关。相反,当你借助 payload 索引进行过滤时,HSNW 算法就不必评估每个数据点。此外,payload 索引还能帮助 HNSW 使用额外的连接来构建图。
payload 索引类似于传统的面向文档数据库。它将元数据字段与其对应的数据点 ID 连接起来,以便快速检索。
在这个示例中,你要为 computers 集合中的所有计算机硬件建立索引。下面来看一个针对 category 字段的 payload 索引示例。
按关键字构建的 Payload 索引:
+------------+-------------+
| category | id |
+------------+-------------+
| laptop | 1, 4, 7 |
| desktop | 2, 5, 9 |
| speakers | 3, 6, 8 |
| keyboard | 10, 11 |
+------------+-------------+
正确为字段建立索引后,搜索引擎便大致知道可以从哪里开始搜索。它可以先查找包含相关元数据的数据点,而不必扫描整个数据集。这会大幅减轻搜索引擎的工作负载。因此,查询结果返回得更快,系统也能轻松扩展。
你可以创建任意数量的 payload 索引,并且我们建议为每个用于过滤的字段都创建一个索引。
如果用户在查找产品类别时经常按 laptop 进行过滤,那么为所有计算机元数据建立索引将加快检索速度,并使结果更加准确。
有些应用程序需要隔离数据,让不同用户在同一个程序中看到不同的数据。在为这种复杂应用程序设置存储时,许多用户认为需要为相互隔离的用户使用多个数据库。
我们经常看到这种情况。用户常犯的一个错误,是在同一个集群中为每个租户分别创建一个集合。这可能会迅速耗尽集群的资源。运行向量搜