Milvus 3.0开源解读之Regex | 从=~到NGRAM,如何选择最优性价比的正则过滤

2026-08-117 分钟阅读

向量检索的本质,是解决语义相似度的模糊匹配问题。但在真实工业级的生产场景中,单纯靠相似远不足以支撑业务判定。

假设我们正在排查一次 checkout 服务故障。向量检索可以找到与当前 incident 语义相似的日志,但工程师通常还需要更精确的结构约束:错误码必须符合 E 加四位数字,消息中必须先出现错误码、后出现 timeout,同时只关注 checkout 服务。

results = client.search(
    collection_name="logs",
    data=[incident_embedding],
    anns_field="embedding",
    filter=r'service == "checkout" and message =~ r"E[0-9]{4}:.*timeout"',
    limit=20,
    output_fields=["timestamp", "service", "message"],
)

这就要求向量数据库在向量计算之外,具备强力的结构化文本过滤能力。也是因此,在 Milvus 3.0 中,我们正式引入了 =~(匹配)与 !~(不匹配)语法,将正则表达式从应用层的后处理移入了数据库查询执行路径,使其能够与向量搜索、全文搜索、标量过滤和索引执行组合使用。

当然,增加两个操作符并不难, 难的是回答下面这些数据库问题:

  • 用户能否安全地提交任意 pattern?
  • NULL、missing JSON path 和 negation 是否保持一致语义?
  • 哪些 pattern 可以改写成更便宜的操作?
  • 哪些行必须进入 RE2,哪些可以提前排除?
  • 建立 NGRAM 后,查询收益是否覆盖索引成本?

接下来,这篇文章会从运算符语义、RE2 校验、原始执行路径、NGRAM 候选生成以及基准测试结果这几个问题出发,解释 Milvus 3.0 的 regex filtering 是如何工作的。

以下是太长不看版结论:

第一,Regex 不是用来替代向量搜索、全文检索或 LIKE 的。
它解决的是字符串结构问题,比如字符范围、重复次数、锚点、多个片段的先后顺序。

第二,Milvus 并不是对每一行无脑跑一次正则。
简单 pattern 会被改写成更便宜的字符串操作;复合查询会优先执行廉价谓词;没有索引时也会先做字面量预筛;有 NGRAM 时,还可以先生成一个更小的候选集,最后才交给 RE2 做精确匹配。

第三,NGRAM 不是“Regex 加速开关”。
它有没有价值,取决于一个 pattern 能否暴露出足够有区分度的固定字面量,从而在 RE2 之前大量排除数据。Benchmark 里,候选集缩减 99.99% 时能达到 3.26x 加速;只能缩减 50% 时,加速只剩 1.17x。

我们为何推出Regex?仅有LIKE已经远远不够

在 Regex (正则表达式)之前,Milvus 已经原生支持用 LIKE 处理简单的字符串模式:

message LIKE "ERROR%"
message LIKE "%timeout%"
message LIKE "node_12_"

对于前缀、后缀和普通包含查询,LIKE 的优势在于足够简单直观,明确的结构也能让执行引擎选择更直接的路径。

LIKE 主要依赖 %_ 两类通配符。一旦需求变成下面这些形式,LIKE 就不够了:

  • 错误码:E[0-9]{4}
  • 版本号:v[0-9]+.[0-9]+.[0-9]+
  • 多种状态:ERROR|WARN
  • URL 路由:^/api/v[0-9]+/users/[0-9]+$
  • 先后顺序:checkout.*timeout
  • 大小写不敏感:(?i)connection refused

理论上,你可以继续把这些模式拆成 LIKE、范围判断和一长串 Boolean expression。但这样做的工程上代价未免太高。不仅表达越来越复杂,维护越来越困难,更麻烦的是边界条件很容易被遗漏。

Regex (正则表达式)的价值就在这里:它提供了一个专门描述字符串结构模式的表达层,填补了简单通配和完整文本检索之间的关于字符本身长什么样、以什么顺序出现的过渡地带。

具体在选择选择时,我们可以参考下表

需求推荐操作
精确值== / IN
简单前缀、后缀或子字符串LIKE
字符类、重复、锚点或结构顺序Regex =~ / !~
Token 级词法相关性text_match / BM25
有序短语phrase_match
拼写变体Milvus 当前没有原生 fuzzy match;需要在应用层处理

^ERRORtimeout$ 这种表达式当然也是合法正则,Milvus 甚至可能把它们自动改写成更便宜的执行方式。不过从可读性和维护性来说,最好还是尽量用最贴合实际意图的运算符。

在Milvus中, =~!~ 到底怎么匹配?

Milvus 3.0 使用 =~ 表示匹配,使用 !~ 表示不匹配。例如:

  • message =~ "E[0-9]{4}":保留包含这类错误码的记录
  • message !~ "DEBUG|TRACE":排除包含 DEBUGTRACE 的记录
  • message =~ "timeout":默认采用 substring match,只要字符串里出现 timeout 就算匹配
  • message =~ "^timeout$":要求整段字符串必须恰好等于 timeout

这里有一个容易忽略的点,使用时,默认是“子串匹配”,所以只要字符串任何位置包含 timeout 就算成功;如果想限定字符串开头或结尾,需要自己加 ^$

Regex 也不只支持普通 VARCHAR,还可以用于能够明确解析到某个字符串值的 JSON、Array 和 StructArray:

  • 普通 VARCHAR 字段,例如 message =~ r"timeout";
  • 解析为字符串的 JSON path,例如 metadata["error_message"] =~ r"E[0-9]{4}:.*timeout";
  • VARCHAR 数组的单个元素,需要显式指定下标,例如 tags[0] =~ r"release-v[0-9]+";
  • StructArray 的字符串子字段,需要在结构表达式中引用,例如 MATCH_ANY(events, $[name] =~ r"error.*timeout")。

另外,考虑到写正则表达式时,最让人痛苦的一件事,就是转义。一层是编程语言,一层是 Milvus 表达式,里面还有一层正则本身。几层叠在一起,很快就会出现满屏的反斜杠。Milvus 3.0 支持在过滤表达式里使用 Raw String,例如 filter_expr = r'message =~ r"\d+.\d+.\d+"'

这里内层的 r"..." 告诉 Milvus 表达式解析器:反斜杠按原样保留。

外层 Python 又用了 Raw String,于是 Python、Milvus 表达式和正则这三层之间,不需要反复转义。

类似的逻辑,模板参数同样适用:

client.query(
    collection_name="logs",
    filter="message =~ {pattern}",
    filter_params={"pattern": r"E[0-9]{4}:.*timeout"},
    output_fields=["message"],
)

这种写法比手工拼接过滤字符串更稳妥,也方便复用同一套查询模板。

为什么 Milvus 选 RE2:数据库不能接受失控的正则

很多常见正则引擎依赖回溯执行。

问题在于,有些精心构造的 pattern会让回溯次数爆炸,执行时间甚至可能随着输入长度呈指数级增长。这就是常说的 catastrophic backtracking,也是 ReDoS 的来源之一。

放在本地脚本里,最坏的情况可能只是一个进程被卡死。

但数据库不一样。在数据库里,pattern 是查询输入的一部分。一条查询可能扫描数百万行,多个用户还可能并发提交不同 pattern。如果 regex 的最坏执行时间不可控,一个看似普通的过滤条件就可能把整台机器的 CPU 拖垮。

Milvus 选择 RE2,就是为了避免这种情况。

RE2 的匹配时间可以控制在和输入长度线性相关的范围内。代价则是,它不会支持所有正则语法,尤其是不支持依赖回溯的能力,例如backreference;lookahead;lookbehind。

这是一个明确的数据库工程取舍:Milvus 更看重执行成本可预测,而不是追求最完整的正则语法。而且,在真正开始扫描数据之前,Milvus 会先编译并校验正则。如果模式本身有问题,就直接报错,不会等扫到一半才出问题。

从语法到执行:Milvus 对正则表达式的优化

最直接的 regex 实现其实很简单:编译 pattern,然后对每一行调用一次 RE2。这样可以得到正确结果,但没有利用数据库已经知道的任何信息。

比如: 这个正则本身是不是能简化? 过滤条件里有没有更合适的判断? 字段上有没有可用的索引? 能不能先排掉一大批肯定不可能匹配的数据?

所以 Milvus 并不是上来就把所有字符串扔给 RE2,而是分几层处理。每一层都只做能够严格保证正确性的优化;一旦无法证明某种缩减不会漏掉结果,系统就回退到完整 RE2 验证。

第一层:先识别可以降级为更简单操作的 pattern

有些所谓“正则”,其实只是普通字符串判断:

Pattern可执行为
^ERROR$equality
^ERRORprefix match
ERROR$postfix match

Milvus 的Parser 会识别这类 anchored literal,并改写成更便宜的操作。

这里的 unanchored literal 是指没有使用 ^$ 锚点的纯文本 pattern,例如 ERROR。它表示在字符串任意位置查找 ERROR,所以当前实现仍会把它当成 RegexMatch 处理。

相比之下,^ERROR 只匹配前缀,ERROR$ 只匹配后缀。当前 parser 会把带锚点的简单 pattern 改写成 prefix、postfix 或 equality 操作,而普通 ERROR 仍按 RegexMatch 执行。

第二层:把正则放到后面再跑

再看一个复合条件:

service_id == 42 and message =~ r"E[0-9]{4}:.*timeout"

service_id == 42 这种数值判断,通常比跑正则便宜得多。如果前面还有索引,成本可能更低。因此,Milvus 会把正则视为比较重的谓词,让查询规划器优先执行更便宜的条件,把候选范围缩小后再执行 regex。

假设 service_id == 42 先把 1000 万行缩成了 10 万行,那 RE2 就只需要处理这 10 万行,而不是把整个表扫一遍。

本文后面的 Benchmark 特意没有混入向量搜索和复合过滤,就是为了单独看正则和 NGRAM 本身的开销。但在真实查询里,过滤条件的执行顺序依然很重要。

第三层:没有 NGRAM 时,也能先做一轮便宜的预筛

如果字段上没有 NGRAM 索引,sealed segment 会直接读取原始字符串做匹配。

但即便如此,Milvus 也不会每处理一行就重新编译一次正则,而是在 segment 范围内复用已经编译好的 RE2 pattern。

对于包含稳定 literal 的 pattern,例如:

ERROR.*timeout

Milvus 还可以先用 Volnitsky 字符串搜索 required literal,快速找出那些“有可能”同时包含 ERRORtimeout 的行,再把这些行交给 RE2 做完整判断。

这里一定要注意:Volnitsky 只是预筛,不负责最终匹配。

一条日志同时包含 ERRORtimeout,不代表它们一定按要求的顺序出现,更不代表完整正则一定成立。它的价值是减少更昂贵的 RE2 调用次数。

第四层:有 NGRAM 时,先把不可能命中的行尽量筛掉

对于大规模 sealed 数据,可以在字符串字段上创建 NGRAM index:

index_params = client.prepare_index_params()
index_params.add_index(
    field_name="message",
    index_type="NGRAM",
    params={"min_gram": 4, "max_gram": 4},
)
client.create_index("logs", index_params)

NGRAM 不会直接“执行正则”。它采用两阶段路径,整个过程大致分成四步:

  1. 从正则中找出“只要匹配成功就一定会出现”的固定字符串。
  2. 把这些字符串切成 n-gram,再通过 posting list 的交集找出候选行。
  3. 只读取这些候选行的原始字符串。
  4. 最后用 RE2 做精确匹配。

ERROR.*timeout 为例,如果 1000 万行中只有 0.1% 同时包含必要 literal,那么绝大多数行会在 Phase 1 被排除,只有候选集合进入 RE2。

所以,这里的性能提升并不是“用 NGRAM 近似执行正则”,而是:先尽量少看数据,再对剩下的数据做完整正则匹配。

这也是它能保证正确性的关键。

只要某个条件不能安全地从正则里推导出来,就不能拿它提前排除数据。最终是否匹配,始终由 RE2 决定。

因此,NGRAM 这一层只会多留候选,不会错误地把本该匹配的数据筛掉。

第五层:无法安全缩减时,正确性优先

并非所有 pattern 都能提取出有用 literal:

E[0-9]{4}
ERROR|WARN
(?i)error.*timeout

E[0-9]{4} 里真正稳定的字面量很短。

ERROR|WARN 有多个分支,需要分别分析。

(?i) 又涉及大小写折叠。

所以当前实现里:

  • Alternation 暂时不会走 NGRAM 粗筛
  • (?i) 大小写不敏感模式也不会走 NGRAM 粗筛
  • 无法安全提取出有效固定字面量时,会直接回退到原始扫描

在这个过程中,回退不是失败,而是索引优化的正确边界:不能证明候选缩减安全,就不使用它。

!~ 不是简单地把结果取反

Regex 加入数据库表达式后,最容易被忽略的不是 pattern,而是 NULL。

可以看看这三条 JSON:

{"message": "request timeout"}
{"message": "request completed"}
{}

第三行缺少 message。在 SQL 三值逻辑中,它既不能证明匹配,也不能证明不匹配,因此结果是 UNKNOWN:

输入message =~ "timeout"message !~ "timeout"
"request timeout"TRUEFALSE
"request completed"FALSETRUE
NULL / missing path / invalid typeUNKNOWNUNKNOWN

WHEREfilter 最终只保留结果为 TRUE 的记录,因此第三条记录在两种条件下都会被过滤掉。

这也是为什么 !~ 不能简单实现成“把匹配结果取反”。

如果字段不存在时先记成 false,再一取反,就成了 true。这样一来,根本没有 message 字段的记录,反而会被“does not match”查询选出来。

Milvus 的做法是把 !~NOT (=~) 处理,同时保留 validity bitmap,从而保证 UNKNOWN 在普通扫描和索引路径上的语义一致。

这类细节决定了一项 regex 功能是否真正具备数据库语义,而不只是把一个字符串库接进执行器。

Benchmark:NGRAM 有没有用,关键看它能提前筛掉多少数据

为了把 NGRAM 的收益与其他变量分开,本节聚焦 sealed segment 上的 regex filtering。测试不包含 growing segment,也不叠加 vector search,因此结果主要反映 raw scan、NGRAM candidate generation 和 RE2 verification 的成本。

公开数据集

本次实验使用 Loghub HDFS_v1

  • 11,175,629 行 HDFS system logs;
  • 原始大小 1.47 GiB;
  • 时间跨度 38.7 小时;

实验固定取前 10,000,000 条有效日志。原始日志保留真实的长度、token 和重复分布;为了精确控制命中率,再用固定随机种子对少量行追加 benchmark marker,例如:

level=ERROR code=E4821 operation=checkout result=request_timeout

我们分别构造了 0.01% / 1% / 10% / 50% 四种目标命中率。所有数据版本都使用同样的随机种子和处理规则,因此实验可以复现。

此外,测试还在未经修改的原始日志字段上做了一轮一致性验证,用来确认观察到的趋势并不是人为追加 marker 造成的。

测试环境

项目实测配置
Milvus commit03762320e8
Deploymentstandalone / sealed segment, 1 shard
HardwareApple M5 Pro / 48 GiB memory
Collection rows10,000,000
Fields5 个 VARCHAR(max_length=512) 字段;另有占位 BINARY_VECTOR(dim=8)
NGRAMmin_gram=4max_gram=4
Concurrency1(latency)/ 32(throughput)
Repetition2 次 warm-up + 5 次正式测量,报告 p50 / p95 / p99

raw 和 NGRAM 使用相同数据、相同 query 顺序,并在 warm-up 后报告稳态结果。

(所有数据都是预热后的 warm-cache 结果,因此不能拿这组测试去推断冷启动或第一次查询的延迟。)

测了哪些正则

Pattern主要执行路径测试目的
^ERRORanchored literal / candidate + prefix verification候选率升高时,锚点验证成本如何变化
ERROR.*timeoutliteral-rich,NGRAM candidate + RE2NGRAM 的主要收益区间
E[0-9]{4}required literal 较弱,固定 4-gram 无法有效缩减弱 literal 是否接近 raw scan
`ERRORWARN`alternation fallbackalternation 的当前执行边界
(?i)error.*timeoutcase-insensitive fallback大小写不敏感 pattern 的当前边界

每个 pattern 对比两条 sealed 路径:

  • 无 scalar index:raw string scan;
  • NGRAM index:candidate generation + RE2 verification;无法使用 NGRAM 时自动回退。

不同命中比例下,NGRAM 到底快多少

主表报告每个 pattern 在四种选择率下的 p50。针对 1% 选择率的 ERROR.*timeout,本文还报告 p95、32 并发 QPS 和 CPU time/query,用于观察尾延迟、吞吐与计算成本。

结果概览。下表每个单元格依次为 raw p50 ms / NGRAM p50 ms / 加速比。加速比大于 1 表示 NGRAM 更快。

Pattern0.01%1%10%50%
^ERROR12.65 / 6.99 / 1.81x12.98 / 9.63 / 1.35x12.73 / 10.98 / 1.16x13.96 / 20.27 / 0.69x
ERROR.*timeout24.31 / 7.47 / 3.26x26.06 / 12.65 / 2.06x39.86 / 30.44 / 1.31x100.24 / 85.73 / 1.17x
E[0-9]{4}17.24 / 16.43 / 1.05x16.77 / 16.91 / 0.99x22.14 / 22.18 / 1.00x44.90 / 44.73 / 1.00x
`ERRORWARN`287.31 / 280.79 / 1.02x282.81 / 285.40 / 0.99x292.34 / 290.07 / 1.01x315.69 / 312.57 / 1.01x
(?i)error.*timeout73.66 / 72.58 / 1.01x75.14 / 75.89 / 0.99x86.58 / 86.35 / 1.00x136.78 / 134.94 / 1.01x

最明显的是 ERROR.*timeout

在 1% 命中比例下:

  • p95 从 26.47 ms 降到 13.03 ms
  • 并发 32 时,吞吐从 72.70 QPS 提升到 117.60 QPS
  • 吞吐提高 61.77%
  • 单次查询 CPU 时间下降 52.14%

而且它的规律也很清楚。当命中比例只有 0.01% 时,NGRAM 能把绝大多数行提前排除,加速达到 3.26x。命中比例一路升到 50% 后,越来越多的数据还是得交给 RE2,最终只剩 1.17x

其他几类 pattern 则几乎没什么收益:

  • E[0-9]{4} 能提取出的稳定字面量太弱
  • ERROR|WARN 当前直接 fallback
  • (?i) 当前也直接 fallback

^ERROR 比较特殊。低候选率时,它确实更快;但候选比例达到 50% 后,反而只有 0.69x,也就是比直接扫描更慢。

原因和这组数据本身有关:实验注入的 ERROR 出现在日志靠后的位置,所以最终根本没有记录满足 ^ERROR。但 NGRAM 第一阶段仍然生成了大量候选,最后还得额外验证一遍,于是索引带来的额外工作反而成了负担。

Candidate reduction

下表把 ERROR.*timeout 在四种选择率下的 Phase 1 candidates 与 raw/NGRAM p50 放在一起,用于观察候选缩减和查询收益之间的关系。

注入率Phase 1 candidatesCandidate reductionRaw p50NGRAM p50加速比
0.01%1,00099.99%24.31 ms7.47 ms3.26x
1%100,00099%26.06 ms12.65 ms2.06x
10%1,000,00090%39.86 ms30.44 ms1.31x
50%5,000,00050%100.24 ms85.73 ms1.17x

实测结果直接展示了 candidate reduction 与收益的关系:候选缩减 99.99% 时加速 3.26x;缩减率降至 50% 时加速仅剩 1.17x。NGRAM 的收益取决于 required literal 能否在进入 RE2 verification 前显著缩小候选集。

结论 什么时候应该使用 regex

Regex 不是默认最优的字符串操作。更实用的选择规则是:

需求推荐操作
精确值== / IN
简单前缀、后缀、包含LIKE
字符类、重复、锚点、结构顺序regex =~ / !~
token lexical relevancetext_match / BM25
有序词组phrase_match
拼写误差Milvus 当前没有原生 fuzzy match;需要在应用层处理

如果 pattern 只是 ^ERRORtimeout$,Regex 可以正确工作,parser 也可能把它改写到更便宜的路径;但对用户来说,直接使用表达意图最清楚的操作仍然更容易维护。

Regex 最适合描述结构,而不是相关性。它可以表达错误码、版本、ID、URL、协议字段和日志形态,但不会替代全文检索,也不会替代向量相似度。

当前边界与后续演进

Milvus 3.0 的 regex filtering 当前有几条明确边界:

  • RE2 不支持 backreference 和 lookaround;
  • Regex 用于 filter,不提供 extract 或 replace;
  • (?i) case-insensitive pattern 当前不会走 NGRAM coarse filter;
  • alternation 当前不会拆成多个 NGRAM 分支;
  • 无法提取安全 required literal 时回退 raw scan;
  • 普通 inverted index 不代表 regex 一定走索引,NGRAM 才是主要 regex acceleration path。

这些边界也给出了自然的后续优化方向,例如 case-folded NGRAM、alternation branch splitting,以及更丰富的 multi-pattern 执行。但任何新优化都必须继续遵守同一条原则:候选生成可以保守,最终结果必须由精确匹配保证。

数据来源与引用

AI Assistant