Qdrant 入门系列08 – 过滤与载荷索引:高级搜索模式

过滤器让向量搜索从演示走向实际应用。单纯的近邻查询只会返回“最接近的 10 个向量”,并不关心商品是否有货、是否位于配送范围,或者是否符合预算。Qdrant 的过滤语言与载荷索引负责表达这些约束。本篇聚焦它们的实际用法。

原文在包含 100,000 件商品的 Qdrant 1.18.1 环境中测试了过滤模式,并对比创建索引前后的延迟。其中,品牌与评分组合过滤的向量查询获得约 62 倍加速,索引构建耗时约 5 秒。以下测量均沿用原文记录,并非译稿另行实测。

原文测试环境:2026 年 5 月,Ubuntu 24.04.4 LTS、Qdrant 1.18.1、qdrant-client 1.18.0、fastembed 0.8.0,模型为 BAAI/bge-small-en-v1.5 与 Qdrant/bm25。

为什么过滤字段需要独立索引

HNSW 擅长快速遍历向量空间,但仅靠向量图不能高效回答“只返回 category 为 Laptops 的点”。缺少载荷索引时,服务器需要更多载荷检查或扫描;当符合条件的数据很少时,额外工作会明显影响延迟与召回。

载荷索引为查询规划器提供匹配集合与基数估计,并配合可过滤 HNSW 或直接扫描等执行策略完成查询。它并非简单地先搜索再删除不符结果,而是帮助引擎选择更合适的搜索路径。对选择性高的过滤器,收益尤为明显:在原文的 10 万个点中,一个品牌约匹配 6,700 个,不到总量的 7%;覆盖 80% 商品的价格区间则难以排除大量候选。

本文使用一个合成电商目录,每件商品含 8 个载荷字段和两种向量:384 维 BGE-small 稠密向量,以及 BM25 稀疏向量。原文通过配套 load_ecommerce.py 脚本加载,两个段中分别索引了约 100,000 个稠密向量和 100,000 个稀疏向量。在 Web UI 中可以直接看到数据结构:

products 集合的稠密、BM25 命名向量和商品载荷

按字段类型创建载荷索引

本文涉及以下七类常用索引。应根据字段类型和查询操作选择,而不是为每个字段机械创建相同索引;这里也不是对所有版本索引类型的穷举。

索引类型 适用数据 过滤操作
KEYWORD 分类、品牌、SKU 等精确匹配字符串 match、match_any、match_except
INTEGER 年份、数量等整数及范围 match、range
FLOAT 价格、评分、重量等数值范围 range
BOOL 是否有货、是否归档等布尔标记 match
GEO 半径、矩形范围或多边形地理条件 geo_radius、geo_bounding_box、geo_polygon
DATETIME RFC3339 时间戳 使用时间值的 range
TEXT 载荷字符串全文检索 match_text

可以在一个 Python 脚本中完成创建。原文的 10 万点集合中,每次调用通常耗时数百毫秒,Qdrant 会按段构建索引:

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

# Keyword with is_tenant: splits the index per category so single-tenant
# queries skip the rest of the collection entirely.
client.create_payload_index(
    collection_name="products", field_name="category",
    field_schema=models.KeywordIndexParams(type="keyword", is_tenant=True),
    wait=True,
)

# Plain enums work for the simpler types
for field, schema in [
    ("brand",      models.PayloadSchemaType.KEYWORD),
    ("price",      models.PayloadSchemaType.FLOAT),
    ("rating",     models.PayloadSchemaType.FLOAT),
    ("in_stock",   models.PayloadSchemaType.BOOL),
    ("created_at", models.PayloadSchemaType.DATETIME),
    ("location",   models.PayloadSchemaType.GEO),
]:
    client.create_payload_index(
        collection_name="products",
        field_name=field, field_schema=schema, wait=True,
    )

# Text index needs an explicit tokenizer
client.create_payload_index(
    collection_name="products", field_name="description",
    field_schema=models.TextIndexParams(
        type="text",
        tokenizer=models.TokenizerType.WORD,
        min_token_len=2, max_token_len=20, lowercase=True,
    ),
    wait=True,
)

原文创建全部 8 个字段索引共耗时 4.7 秒。通过 get_collection 返回的 payload_schema 核对结果:每个已索引字段都应出现在列表中,遗漏字段则不会出现。

组合 must、should 与 must_not

过滤器可以组合三组布尔条件:must 要求全部满足,should 要求至少满足一项,must_not 要求各项均不匹配;不同组之间共同构成约束。

技术修正: should 在这里是“至少满足一个”的硬过滤条件,不是软偏好,也不会按匹配条件数自动提高向量相似度分数。

flt = models.Filter(
    must=[
        models.FieldCondition(key="category",
            match=models.MatchValue(value="Laptops")),
        models.FieldCondition(key="price",
            range=models.Range(gte=200, lte=800)),
    ],
    should=[
        models.FieldCondition(key="brand",
            match=models.MatchValue(value="Stark")),
        models.FieldCondition(key="brand",
            match=models.MatchValue(value="Wayne")),
    ],
    must_not=[
        models.FieldCondition(key="in_stock",
            match=models.MatchValue(value=False)),
    ],
)

该过滤器表示:商品必须是笔记本电脑,价格在 200 至 800 美元之间,当前有货,并且品牌至少匹配 Stark 或 Wayne 中的一项。向量搜索通过 query_filter 传入,纯遍历通过 scroll_filter 传入;两者使用相同的条件结构与索引。

范围、地理位置、日期时间和全文条件

以下四类条件尤其容易用错。要注意操作数类型:有的类型错误会直接报错,而字段结构不符合预期也可能只得到空结果。

# 1. Numeric range: price between 200 and 800
models.FieldCondition(key="price",
    range=models.Range(gte=200, lte=800))

# 2. Datetime range: created in the last 90 days (RFC3339 string)
models.FieldCondition(key="created_at",
    range=models.DatetimeRange(gte="2026-02-25T00:00:00Z"))

# 3. Geo radius: within 50 km of Tokyo, lon/lat order
models.FieldCondition(key="location",
    geo_radius=models.GeoRadius(
        center=models.GeoPoint(lon=139.6917, lat=35.6895),
        radius=50_000,    # meters
    ))

# 4. Geo bounding box: anywhere inside continental Europe
models.FieldCondition(key="location",
    geo_bounding_box=models.GeoBoundingBox(
        top_left=models.GeoPoint(lon=-10.0, lat=72.0),
        bottom_right=models.GeoPoint(lon=40.0, lat=36.0),
    ))

# 5. Full-text match: words found in the description payload
models.FieldCondition(key="description",
    match=models.MatchText(text="wireless travel"))

首先,地理位置示例使用 lon 与 lat 表示经度、纬度。为避免与习惯的“纬度、经度”顺序混淆,应显式使用命名参数;交换数值可能把门店放到错误位置,最终匹配不到商品。

其次,示例的日期时间使用带时区的 RFC3339 字符串,末尾 Z 表示 UTC。应先把来源日期规范化为服务器接受的格式,避免解析失败或时区歧义。

第三,全文匹配应为字段配置文本索引。本文用 TextIndexParams 显式指定分词器,例如 WORD、WHITESPACE、PREFIX 或 MULTILINGUAL。分词方式在建索引时确定,不能在每次查询时任意切换。原文把裸 PayloadSchemaType.TEXT 一概写成不可用过于绝对;需要自定义行为时,使用显式参数最清楚。

创建载荷索引之前的基准结果

以下基准在没有载荷索引的 10 万点集合上执行 6 种查询,每种重复 50 次:其中三种只做过滤,三种同时执行向量搜索与过滤。延迟单位为毫秒:

BEFORE building payload indexes
======================================================================
  -- Pure filter (no vector) --
  category+price       p50=  2.69  p95=  3.61  ms
  bool+datetime        p50=  1.45  p95=  1.72  ms
  geo_radius 1000km    p50=  1.18  p95=  1.39  ms

  -- Vector search + filter --
  brand+rating         p50=146.77  p95=164.55  ms
  must_not Books       p50=  4.84  p95=  5.19  ms
  must+should          p50= 13.02  p95= 14.24  ms

纯过滤在返回数量较小时仍可提前结束扫描,因此延迟尚可。向量与过滤组合的代价更明显:brand+rating 的中位延迟达到 146 毫秒,载荷检查与候选筛选消耗大量时间。

创建载荷索引前的过滤延迟,p50 为 146 毫秒

构建索引 4.7 秒之后再次查询

原文为 8 个字段建立索引,其中包括 description 的全文索引,总计耗时 4.7 秒。对相同的 6 种查询再次各执行 50 次:

AFTER building payload indexes
======================================================================
  -- Pure filter (no vector) --
  category+price       p50=  1.00  p95=  1.18  ms   (2.7x faster)
  bool+datetime        p50=  0.99  p95=  1.10  ms   (1.5x)
  geo_radius 1000km    p50=  0.98  p95=  1.04  ms   (1.2x)

  -- Vector search + filter --
  brand+rating         p50=  2.36  p95=  2.49  ms   (62x faster)
  must_not Books       p50=  1.86  p95=  2.04  ms   (2.6x)
  must+should          p50=  1.70  p95=  1.97  ms   (7.7x)

  -- After: full-text payload match --
  match_text 'wireless travel'  p50=  0.96  p95=  1.11  ms

选择性最高的向量加过滤查询从 146 毫秒降到 2.36 毫秒,约为 62 倍加速。其他向量查询提升约 2 至 8 倍;纯过滤查询提升约 1.2 至 2.7 倍,因为它们在低 limit 下原本已能较早停止扫描。

创建载荷索引后的过滤延迟,p50 为 2.36 毫秒

最后一项 match_text 在文本索引建立后可以高效执行,原文测试延迟约为 1 毫秒。它主要利用倒排索引,不必走完整向量搜索路径;数据规模、词项分布与命中数量变化后,耗时仍会变化,不能理解成恒定 1 毫秒。

使用 prefetch 与倒数排名融合进行混合搜索

稠密和稀疏向量反映不同信号。BGE-small 的稠密嵌入侧重语义,例如“轻便无线耳机”可能匹配描述为“便携”“无绳”的商品;BM25 稀疏向量侧重词项匹配,适合找出实际包含查询词的商品。

Qdrant 从 1.10 起支持在一次查询中使用 prefetch 与 FusionQuery 组合多个召回结果。每个预取阶段取得自己的前 N 项,随后进行融合。倒数排名融合(Reciprocal Rank Fusion,RRF)按排名贡献权重,常用表达为 1/(k + rank),再跨检索列表求和:

from qdrant_client import QdrantClient, models
from fastembed import SparseTextEmbedding, TextEmbedding

client = QdrantClient(url="http://localhost:6333")
dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
sparse_model = SparseTextEmbedding("Qdrant/bm25")

q = "lightweight wireless headphones for travel"
dv = next(dense_model.embed([q])).tolist()
sv = next(sparse_model.embed([q]))
sparse_q = models.SparseVector(
    indices=sv.indices.tolist(),
    values=sv.values.tolist(),
)

res = client.query_points(
    collection_name="products",
    prefetch=[
        models.Prefetch(query=dv,       using="dense", limit=50),
        models.Prefetch(query=sparse_q, using="bm25",  limit=50),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=5,
    with_payload=True,
)

每个预取请求的 using= 指向集合中的命名向量。稀疏查询使用 SparseVector,填入 fastembed 生成的 BM25 索引与权重。融合阶段只处理排名列表,无需再次读取原始向量,因此开销较小。

原文在 10 万商品上测试了 5 个电商查询:

Timings (5 queries each)
============================================================
  dense    p50=  2.49  ms
  sparse   p50=  1.85  ms
  hybrid   p50=  4.68  ms

混合查询的耗时大致是两次检索加少量融合开销,原文中位数低于 5 毫秒。收益与成本需要同时考虑:混合搜索经常能改善前 K 项质量,但并非每次都更好,而且必须承担两路召回的成本。

稠密向量与 BM25 稀疏向量的 RRF 混合搜索结果

在测试语料中,5 个查询里有 3 个仍由稠密搜索的第一名胜出;“降噪入耳式耳机”则在融合后返回了另一件商品,因为 BM25 把稠密模型排得较后的精确词项匹配结果带了上来。这正是混合搜索在商品目录中的一个价值所在。

多阶段检索:先 BM25,再按稠密向量重排

预取链不限于两路检索或 RRF。常见生产模式是先用 BM25 将候选缩小到约 200 项,再使用稠密向量对这些候选重排。第一阶段便宜且召回面广,第二阶段在较小候选集上计算相似度:

res = client.query_points(
    collection_name="products",
    prefetch=models.Prefetch(
        query=sparse_q, using="bm25", limit=200,
        filter=models.Filter(must=[
            models.FieldCondition(key="in_stock",
                match=models.MatchValue(value=True)),
        ]),
    ),
    query=dv,
    using="dense",
    limit=10,
)

这段查询先找出有货商品中的前 200 个 BM25 结果,再按稠密向量排序,返回前 10 项。过滤器放在预取阶段,因此后续重排只处理有货候选。in_stock 或地理范围等硬约束也可以按同样思路尽早应用。

基数、索引阈值与段数量带来的性能问题

载荷索引的收益与过滤选择性有关。匹配 90% 数据的条件无法排除多少候选,匹配 0.1% 的条件则能跳过绝大多数数据。优先索引实际高频过滤的字段,因为每个索引都会增加内存与写入成本。

另一项要检查的是优化器的 indexing_threshold。原文把它写成默认 20,000 个向量有误:该阈值按每个段的向量数据大小计算,单位是 KB;Qdrant 1.18.1 官方配置默认值为 10000。 是否跨过阈值取决于维度和数据量,不能仅用每段点数判断。集合切成许多小段时,可能有较多数据尚未构建 HNSW。

可在 Info 页检查 indexed_vectors_count,结合 points_count、每点向量数量、命名向量与段配置分析。需要时降低阈值,或适当减少 default_segment_number,让单段更大;不能把向量计数与点计数简单视为永远一一对应。

多租户字段,例如 customer_id 或 organization,可在关键词索引上使用 is_tenant=True。它向优化器提示该字段用于租户划分,有助于相关数据的组织与访问局部性;查询仍必须携带租户过滤器,不能把索引提示当成租户隔离或权限控制。

容易踩到的五个问题

  • 经纬度次序混淆。 用 GeoPoint(lon=..., lat=...) 显式标注,不依赖位置参数习惯;数值交换可能只表现为空结果。
  • 日期时间格式不统一。 示例使用带 Z 或明确偏移量的 RFC3339 时间,导入前应规范化并核对时区。
  • 全文索引的分词配置。 要自定义分词器,用 TextIndexParams 明确指定;选择在建索引时生效,不是在查询时生效。
  • indexing_threshold 按段且以 KB 计量。 不能以“每段不足 20,000 点”直接断定没有索引,应结合向量维度、配置和实际索引计数判断。
  • 命名向量查询需要选择向量。 本文集合同时包含 dense 与 bm25,查询要通过 using= 明确指定相应向量。缺少所需名称可能返回 400,而不会自动选择列表中的第一项。

实用的载荷索引策略

  • 优先为向量查询中经常使用、选择性高的硬过滤字段建索引。原文 brand+rating 的 62 倍提升是特定数据与查询的测量,并不意味着每个组合都能获得相同加速。
  • 偶尔才过滤的字段应权衡成本。90% 查询都使用的 category 通常值得建索引,每周只查询一次的 internal_note 则未必。
  • 结合向量维度、数据 KB 数和段数量设置索引阈值,并观察稳定负载下的索引覆盖、优化状态和查询延迟;必要时减少段数量,而不是增大段数。

过滤和载荷索引是 Qdrant 实际使用中的关键。导入数据时就规划常用字段索引,随后在 Web UI 持续观察索引状态。对于不到百万点、向量加过滤却明显偏慢的查询,可以先检查索引与执行条件,再结合硬件、查询维度、过滤复杂度和并发判断原因。

Avatar photo

关于 木子

Email: muzi@vip.rockylinux.cn 微信:VebinLee QQ: 2306867585
Founder of the Rocky Linux Chinese community, MVP、VMware vExpert、TVP, advocate for cloud native technologies, with over ten years of experience in site reliability engineering (SRE) and the DevOps field. Passionate about Cloud Computing、Microservices、CI&CD、DevOps、Kubernetes, currently dedicated to promoting and implementing Rocky Linux in Chinese-speaking regions.
用一杯咖啡支持我们,我们的每一篇[文档]都经过实际操作和精心打磨,而不是简单地从网上复制粘贴。期间投入了大量心血,只为能够真正帮助到您。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇