商标近似检索的 Elasticsearch Bool 查询优化
1. 背景
商标近似检索同时承担“尽量不漏”和“响应足够快”两个目标。早期实现把精确、包含、倒序、弱相似、拼音等规则一次性展开,并混合使用 regexp、fuzzy、match_phrase 和宽范围过滤。这样的查询表达能力很强,但在商标数据量达到几十万甚至更高时,会产生大量候选文档,后续 scroll 和 Java 筛选成为主要耗时来源。
本次优化的核心思想是:ES 只做可利用倒排索引的粗召回,Java 负责精细规则判断;先用字符重合度和标题长度把候选集缩小。
2. 原查询的主要问题
2.1 复杂查询算子成本高
regexp需要对词项进行模式匹配,难以像term一样直接利用简单倒排查找。fuzzy会扩展编辑距离候选词,扩展规模不可控。match_phrase + slop需要计算词项位置和顺序,数据量大时明显增加查询成本。- 多个规则分支叠加后,即使最终只返回少量结果,ES 仍可能先产生大量中间候选。
2.2 全局长度过滤不够精确
旧逻辑通常是对整个查询统一设置标题长度范围,例如:
titleCharNum > 2 && titleCharNum <= queryLength * 2
它无法表达“命中 2 个字符”和“命中 4 个字符”应有不同长度约束的问题,容易把低相关长标题放入候选集。
2.3 仅依赖去重后的字符会改变匹配门槛
查询词中可能存在重复字符。查询词长度和去重后的字符数不是同一个概念,因此需要明确 minimum-should-match 的计算口径,避免重复字符导致门槛失真。
3. 新的中文粗召回模型
设查询词归一化后包含 N 个不同汉字。对每一个 K 生成一个独立的 Bool 分支:
K = N, N-1, ..., 2
每个分支包含:
- 对每个不同汉字生成一个
term(similarTitle, char); - 设置
minimum_should_match = K; - 在分支内部增加标题长度过滤。
长度规则为:
K >= 3: K <= titleCharNum <= 2K
K = 2: titleCharNum = 2
单字符查询作为边界情况使用 minimum_should_match=1,标题长度限制为 1~2。
外层 Bool 只要求命中一个分支:
outer.minimum_should_match = 1
示例:查询词“牧歌哚哆”
去重后有 4 个字符,因此生成 3 个分支:
| 分支 | 命中门槛 | 标题长度 |
|---|---|---|
| K=4 | 至少命中 4 个字符 | 4~8 |
| K=3 | 至少命中 3 个字符 | 3~6 |
| K=2 | 至少命中 2 个字符 | 2 |
“牧歌朵多”只命中“牧、歌”两个汉字,不能通过中文字面分支;这类顺序变化或同音变化应交给独立的拼音/音形规则处理,而不是降低中文 term 分支的门槛。
4. 生成的 ES 查询结构
以查询词 ABC 为例,核心结构近似如下:
{
"bool": {
"should": [
{
"bool": {
"should": [
{ "term": { "similarTitle": "A" } },
{ "term": { "similarTitle": "B" } },
{ "term": { "similarTitle": "C" } }
],
"minimum_should_match": 3,
"filter": [
{ "range": { "titleCharNum": { "gte": 3, "lte": 6 } } }
]
}
},
{
"bool": {
"should": [
{ "term": { "similarTitle": "A" } },
{ "term": { "similarTitle": "B" } },
{ "term": { "similarTitle": "C" } }
],
"minimum_should_match": 2,
"filter": [
{ "range": { "titleCharNum": { "gte": 2, "lte": 2 } } }
]
}
}
],
"minimum_should_match": 1
}
}
实际请求还会叠加申请人、法律状态、商品分类等业务过滤条件。所有长度过滤放在对应分支内部,避免一个全局范围覆盖所有相似度等级。
5. 为什么不用单字 term 一次性召回再全部交给 Java
“命中任意一个字符”虽然写法简单,但会产生大量噪声。例如查询词 ABC 使用 minimum_should_match=1 时,包含单个 A 的长标题也会进入候选集;查询词越长,噪声增长越快,最终把 ES 的工作转移到 scroll 和 Java 去重/筛选,整体耗时反而更高。
新方案把最低重合度放在 ES:
- 短词保持较高精度;
- 中长词至少要求多个字符重合;
- 标题长度与命中数量绑定,减少“短查询匹配超长标题”的情况;
- Java 只处理已经缩小的候选集。
6. 英文和拼音策略
6.1 英文
英文按空格拆分为单词并转为大写,保留现有 token 召回逻辑。标题长度使用字符数范围,允许查询字符数的 1~3 倍,例如:
APPLE -> titleCharNum 5~15
英文单词数量和字符数量是两个维度,不能直接套用中文单字规则。
6.2 拼音
当前 similarTitlePinyin 是 keyword 字段,例如:
"similarTitlePinyin": {
"type": "keyword"
}
因此整值 mu ge duo duo 是一个完整 term,不能通过 term(similarTitlePinyin, "mu") 匹配。此次优化暂不修改拼音索引,避免查询逻辑和索引重建同时变更。后续若要支持按音节召回,应新增分词字段或预生成拼音 n-gram,并完成全量重建后再启用对应 Bool 分支。
7. 性能收益与复杂度
对于 N 个不同汉字,中文粗召回最多生成 N-1 个分支,每个分支包含 N 个 term。查询复杂度大致为:
O(N × (N - 1))
这比无边界的 regexp/fuzzy 扩展更可预测。长度过滤使用数值字段 titleCharNum,可以作为 filter 缓存和剪枝条件,不参与相关性评分。
但该方案不是无限扩展的:超长查询词仍会生成较多分支。生产环境建议增加输入长度上限,例如超过 10~12 个字符时只保留高门槛分支,或先提取核心词再召回。
8. 验证与监控建议
上线前建议固定以下样本进行对比:
- 2 字、3 字、4 字中文查询;
- 含重复字的查询;
- 顺序颠倒、增字、减字样本;
- 中英文混合和纯英文查询;
- 无结果和高频字查询。
重点记录:
query build耗时;- 首次 ES search 耗时;
- scroll 总耗时;
- ES 命中总量和实际返回量;
- Java 规则筛选耗时。
如果 scroll 仍然很慢,优先检查候选总量,而不是继续增加更多 ES 规则。近似检索的优化顺序应是:减少候选集 → 缩小 scroll → 再优化 Java 精筛。
9. 总结
本次优化没有追求在 ES 中一次性表达所有“近似”语义,而是采用分层粗召回:
term替代高成本的regexp、fuzzy和match_phrase;minimum_should_match控制字符重合度;titleCharNum将标题长度和命中门槛绑定;- 外层 Bool 保持规则组合,Java 继续承担最终精筛;
- 拼音作为独立能力保留,待索引结构支持分词后再增强。
这是一种更适合大规模商标库的工程化折中:在可控查询成本下保留主要召回能力,并让每个性能问题都能通过候选量、分支数和 scroll 耗时定位。

浙公网安备 33010602011771号