Milvus 3.0开源解读之Regex | 从=~到NGRAM,如何选择最优性价比的正则过滤
向量检索的本质,是解决语义相似度的模糊匹配问题。但在真实工业级的生产场景中,单纯靠相似远不足以支撑业务判定。
假设我们正在排查一次 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;需要在应用层处理 |
像 ^ERROR、timeout$ 这种表达式当然也是合法正则,Milvus 甚至可能把它们自动改写成更便宜的执行方式。不过从可读性和维护性来说,最好还是尽量用最贴合实际意图的运算符。
在Milvus中, =~ 和 !~ 到底怎么匹配?
Milvus 3.0 使用 =~ 表示匹配,使用 !~ 表示不匹配。例如:
message =~ "E[0-9]{4}":保留包含这类错误码的记录message !~ "DEBUG|TRACE":排除包含DEBUG或TRACE的记录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 |
^ERROR | prefix 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,快速找出那些“有可能”同时包含 ERROR 和 timeout 的行,再把这些行交给 RE2 做完整判断。
这里一定要注意:Volnitsky 只是预筛,不负责最终匹配。
一条日志同时包含 ERROR 和 timeout,不代表它们一定按要求的顺序出现,更不代表完整正则一定成立。它的价值是减少更昂贵的 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 不会直接“执行正则”。它采用两阶段路径,整个过程大致分成四步:
- 从正则中找出“只要匹配成功就一定会出现”的固定字符串。
- 把这些字符串切成 n-gram,再通过 posting list 的交集找出候选行。
- 只读取这些候选行的原始字符串。
- 最后用 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" | TRUE | FALSE |
"request completed" | FALSE | TRUE |
| NULL / missing path / invalid type | UNKNOWN | UNKNOWN |
而 WHERE 或 filter 最终只保留结果为 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 commit | 03762320e8 |
| Deployment | standalone / sealed segment, 1 shard |
| Hardware | Apple M5 Pro / 48 GiB memory |
| Collection rows | 10,000,000 |
| Fields | 5 个 VARCHAR(max_length=512) 字段;另有占位 BINARY_VECTOR(dim=8) |
| NGRAM | min_gram=4,max_gram=4 |
| Concurrency | 1(latency)/ 32(throughput) |
| Repetition | 2 次 warm-up + 5 次正式测量,报告 p50 / p95 / p99 |
raw 和 NGRAM 使用相同数据、相同 query 顺序,并在 warm-up 后报告稳态结果。
(所有数据都是预热后的 warm-cache 结果,因此不能拿这组测试去推断冷启动或第一次查询的延迟。)
测了哪些正则
| Pattern | 主要执行路径 | 测试目的 | |
|---|---|---|---|
^ERROR | anchored literal / candidate + prefix verification | 候选率升高时,锚点验证成本如何变化 | |
ERROR.*timeout | literal-rich,NGRAM candidate + RE2 | NGRAM 的主要收益区间 | |
E[0-9]{4} | required literal 较弱,固定 4-gram 无法有效缩减 | 弱 literal 是否接近 raw scan | |
| `ERROR | WARN` | alternation fallback | alternation 的当前执行边界 |
(?i)error.*timeout | case-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 更快。
| Pattern | 0.01% | 1% | 10% | 50% | |
|---|---|---|---|---|---|
^ERROR | 12.65 / 6.99 / 1.81x | 12.98 / 9.63 / 1.35x | 12.73 / 10.98 / 1.16x | 13.96 / 20.27 / 0.69x | |
ERROR.*timeout | 24.31 / 7.47 / 3.26x | 26.06 / 12.65 / 2.06x | 39.86 / 30.44 / 1.31x | 100.24 / 85.73 / 1.17x | |
E[0-9]{4} | 17.24 / 16.43 / 1.05x | 16.77 / 16.91 / 0.99x | 22.14 / 22.18 / 1.00x | 44.90 / 44.73 / 1.00x | |
| `ERROR | WARN` | 287.31 / 280.79 / 1.02x | 282.81 / 285.40 / 0.99x | 292.34 / 290.07 / 1.01x | 315.69 / 312.57 / 1.01x |
(?i)error.*timeout | 73.66 / 72.58 / 1.01x | 75.14 / 75.89 / 0.99x | 86.58 / 86.35 / 1.00x | 136.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 candidates | Candidate reduction | Raw p50 | NGRAM p50 | 加速比 |
|---|---|---|---|---|---|
| 0.01% | 1,000 | 99.99% | 24.31 ms | 7.47 ms | 3.26x |
| 1% | 100,000 | 99% | 26.06 ms | 12.65 ms | 2.06x |
| 10% | 1,000,000 | 90% | 39.86 ms | 30.44 ms | 1.31x |
| 50% | 5,000,000 | 50% | 100.24 ms | 85.73 ms | 1.17x |
实测结果直接展示了 candidate reduction 与收益的关系:候选缩减 99.99% 时加速 3.26x;缩减率降至 50% 时加速仅剩 1.17x。NGRAM 的收益取决于 required literal 能否在进入 RE2 verification 前显著缩小候选集。
结论 什么时候应该使用 regex
Regex 不是默认最优的字符串操作。更实用的选择规则是:
| 需求 | 推荐操作 |
|---|---|
| 精确值 | == / IN |
| 简单前缀、后缀、包含 | LIKE |
| 字符类、重复、锚点、结构顺序 | regex =~ / !~ |
| token lexical relevance | text_match / BM25 |
| 有序词组 | phrase_match |
| 拼写误差 | Milvus 当前没有原生 fuzzy match;需要在应用层处理 |
如果 pattern 只是 ^ERROR 或 timeout$,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 执行。但任何新优化都必须继续遵守同一条原则:候选生成可以保守,最终结果必须由精确匹配保证。
数据来源与引用
- Loghub repository
- HDFS_v1
- Zenodo dataset record
- Jieming Zhu, Shilin He, Pinjia He, Jinyang Liu, Michael R. Lyu. Loghub: A Large Collection of System Log Datasets for AI-driven Log Analytics. ISSRE 2023.







