本文来自一次真实的搜索系统重构。为便于公开分享,文中的业务对象、示例、规模和系统名称均已抽象;保留的是可以迁移到内容、电商、赛事、知识库等场景的技术问题、设计取舍与工程经验。

引子:用户明明看得见,为什么就是搜不到?

搜索系统最令人困惑的一类故障,不是服务报错,也不是延迟飙升,而是:

页面上明明展示着一段文字,用户输入同样的词,搜索结果却是空的。

这类问题很容易被当成“少查了一个字段”。于是我们把字段加入 multi_match,补一轮数据,再调一点 boost。短期内问题消失了,但很快又出现新的例外:

  • 根标题能搜到,子条目标题搜不到;
  • 英文能搜到,翻译后的标题搜不到;
  • 完整词能搜到,输入到一半却没有结果;
  • 拼错一个字符直接零召回;
  • “Lions vs Tigers”搜到了 Lions 的另一场内容,并排在正确结果之前;
  • 主语言明明已经命中,却被低权重的全语言 fallback 反超;
  • 两段看起来完全相同的重音文本,因为 Unicode 编码形式不同而走向不同查询;
  • 加入长描述后召回变多了,但无关结果也大量出现;
  • 词法、向量和热度一起参与评分,却没人能解释最终排序;
  • 全量回填时结果正常,运行几天后新旧索引开始悄悄分叉。

当问题不断以不同形式复现时,继续补字段已经不是修复,而是在掩盖模型本身的缺陷。

我们最终将搜索从 V1 演进到 V2。最大的变化不是换了某个 Elasticsearch 参数,而是重新回答了三个基础问题:

  1. 一个业务对象中,哪些文本具有“可检索语义”?
  2. 这些文本应该在写入时组织,还是在查询时临时拼装?
  3. 召回、排序、业务约束和发布安全分别由哪一层负责?

一、先建立问题模型

为了脱离具体业务,可以把原始数据抽象成下面的结构:

{
  "id": "...",
  "title_i18n": { "en": "...", "zh": "..." },
  "short_title_i18n": { "en": "...", "zh": "..." },
  "sections": [
    {
      "title_i18n": { "en": "...", "zh": "..." },
      "question_i18n": { "en": "...", "zh": "..." },
      "candidates": [
        { "name_i18n": { "en": "...", "zh": "..." } }
      ]
    }
  ],
  "category_i18n": { "en": ["..."], "zh": ["..."] },
  "entity_names": ["..."],
  "popularity": 0,
  "vector": [0.0, 0.0]
}

搜索结果的单位是最外层“主题”,但用户可能输入的有效文本分布在根对象、嵌套子条目、候选项、结构化扩展字段、翻译数据甚至另一张元数据表中。

因此,业务存储模型和搜索模型并不是同一个模型:

  • 业务模型关心数据归属、更新边界与展示完整性;
  • 搜索模型关心哪些词应该被召回、它们的重要性以及如何稳定排序。

V1 的根本问题,就是让查询层直接理解不断变化的业务模型。每增加一种数据来源,查询都要跟着认识一个新字段。

二、V1 为什么一开始是合理的

V1 的目标很朴素:把复杂列表查询从关系型数据库迁到 Elasticsearch,同时支持关键词搜索。

它采用“一条主题一篇文档”的方式,保留子条目的嵌套结构,并在查询时对多个字段执行 multi_match。下面仅示意字段及相对权重,不是可直接执行的 Query DSL:

title_i18n.*          ^ 3.0
short_title_i18n.*    ^ 1.5
question_i18n.*       ^ 0.5
description_i18n.*    ^ 0.x

这个方案有几个明显优点:

  • 数据结构和展示结构接近,理解成本低;
  • 上线快,适合验证 ES 查询链路;
  • 过滤、排序和详情读取可以共享一份文档;
  • 调整字段权重就能快速响应早期相关性问题。

所以 V1 并不是一个错误设计。它解决了当时的问题,只是它隐含了一个前提:可检索文本的来源比较少,而且字段语义相对稳定。

随着数据形态和语言数量增加,这个前提不再成立。

三、V1 遇到的系统性问题

3.1 展示字段不等于检索字段

嵌套结构适合表达“某个问题属于哪个子条目”,但搜索结果返回的是根文档。直接查询 nested 字段需要额外的 nested query;不同层级的分数如何合并,也会让查询迅速复杂化。

另一种情况更隐蔽:部分对象只需要保存在 _source 中供展示,并未建立倒排索引。它们可以被接口返回,却天然不能被搜索。

于是 V1 开始增加顶层聚合字段:把子条目标题、候选项名称、实体名称分别扁平化到根文档。每补一个字段都能关闭一批问题,但也让字段列表、写入逻辑和查询权重同步膨胀。

3.2 字段越多,相关性不一定越好

长描述通常包含更多关键词,看起来能提高召回,实际却常常成为短 query 的主要噪声源。一个只在标题中出现一次的精确实体名,可能被另一个文档的长描述中多次出现的同义词压过。

聚合字段也有类似问题。如果根标题和子条目标题相同,同一段文本会在多个字段中重复计分。使用 most_fields 时,各字段分数相加,重复内容反而获得额外奖励。

更麻烦的是“通用词”。候选项里可能充满“是/否”“更多/更少”“胜者”“默认选项”等结构性文本。它们对于展示必不可少,却没有检索价值。一旦进入倒排索引,就会制造大面积低质量召回。

因此,搜索字段不是“把页面上的字全复制一遍”。它需要一份明确的语义白名单和噪声规则。

3.3 多语言让字段权重变成了隐形预算

V1 的每个 i18n 对象都可能展开成多个语言字段。为了覆盖新增语言,最直接的做法是使用通配符:

title_i18n.*^3

但 multi_match(type=most_fields) 会累加多个字段的得分。同一个英文缩写如果同时出现在若干翻译字段中,文档可能因为“被复制了多次”而不是“更相关”获得高分。

这个问题在“主语言 lane + 全语言 fallback”结构中更加隐蔽。我们最初给 fallback 降低了 boost,以为它只会补召回;但 fallback 仍然使用 most_fields,一个 token 在多个语言字段中的分数相加后,仍可能反超主语言 lane。一次代表性 _explain 显示:低权重 fallback 的总分接近主 lane 的两倍,最终由兜底路径决定了排序。

新语言回填不完整时还会出现 IDF 膨胀。一个全局常见词,在只有少量文档的新语言字段中看起来非常“稀有”,因而获得异常高分。随着回填覆盖率上升,这个分数又会自行下降。也就是说,多语言字段的真实权重并不只是配置中的 boost,而更接近:

实际影响 ≈ 字段 boost × 命中字段数量 × 各字段的语料分布

因此,多语言搜索不能只监控索引总文档数,还要观察每个语言字段的覆盖率与 _explain 分解。

反过来,如果查询字段按请求 locale 严格收窄,用户使用英文界面输入中文名称,又可能完全失去召回。

这里暴露了两个容易混淆的概念:

  • 展示语言决定返回哪个翻译;
  • 查询文本的文字系统决定用户输入更可能属于哪组索引字段。

两者不应该被同一个 locale 参数强绑定。

3.4 前缀、拼写容错和精确匹配互相拉扯

开启 fuzzy 可以挽救拼写错误,但对过短的 token 使用 fuzzy,会扩大候选词集合、增加查询成本,也容易把短品牌名或缩写改成另一个高频词。

使用 match_phrase_prefix 可以做输入联想,却可能在多 token 查询上带来更高开销和难以控制的词序偏好。把 prefix 只作为 boost 又不够:当 BM25 完全没有命中时,它无法真正救回文档。

召回和精度其实是两类信号:

  • fuzzy、prefix 的任务是“别漏掉”;
  • phrase、完整双实体匹配的任务是“把最像用户意图的结果排前面”。

如果把它们塞进同一个 clause,很难同时调好。

3.5 BM25 与向量分数不能直接假装同量纲

V1 后期加入了向量召回。最初的混合方式类似:

final_score = bm25_score + knn_score + popularity_boost

问题在于三者并不处于同一尺度:

  • BM25 原始分数会随字段、语料和 query 长度发生明显变化;
  • cosine kNN 的 ES _score 通常落在一个较窄区间;
  • 热度又可能经过对数、乘法或封顶函数变换。

当 BM25 分数是十几,而向量分数不到一时,所谓“混合搜索”很可能只是 BM25 搜索加了一个几乎不可见的小数。继续调全局阈值也无法解决,因为 BM25 分数并不适合跨 query 比较。

3.6 数据新鲜度比全量回填更难

新索引第一次回填成功,只能证明“这一刻的数据是完整的”。真正困难的是之后的每一次变更:

  • 根标题更新了,派生检索字段是否同步更新?
  • 子条目变更了,是否能找到所属根文档?
  • 翻译或实体元数据来自另一张表时,谁负责触发重建?
  • 语言码已经规范化后,旧 locale alias 是否仍残留在 JSON 中参与索引?
  • 某张子表没有接入变更订阅时,仅更新它是否会漏掉根文档重建?
  • V1 写成功、V2 写失败时,重试队列会不会提前删除?
  • 向量异步生成失败后,系统能否识别 V2 单边缺失?

搜索索引是派生数据。派生数据最危险的状态不是完全不可用,而是“多数时候看起来没问题,少部分文档悄悄过期”。

四、三种方案的比较

面对这些问题,我们讨论过三种路线。

方案 做法 优点 主要问题
继续扩展 V1 新增聚合字段,继续维护字段列表和 boost 改动小,单点问题见效快 查询持续理解业务结构;重复计分和语言扩展越来越难控制
查询时遍历原始字段 对根字段和 nested 字段分别查询,再合并分数 不增加派生字段,来源看似直接 Query DSL 复杂;打分难解释;每增加一种结构仍要改查询
建立统一检索投影 写入时把可检索文本编译成稳定字段,查询只理解检索语义 查询简单、可测试、可观察;语言和来源扩展与查询解耦 写入成本更高;所有 writer 必须遵守重建契约

我们最终选择第三种。

关键判断是:当查询层需要知道越来越多业务细节时,系统缺少的不是另一个 helper,而是一层搜索专用的数据模型。

五、V2:把业务文档“编译”为搜索文档

V2 保留业务字段用于过滤与展示,但新增三组由应用写入侧生成的统一检索字段:

字段族 内容 作用
search_text.{lang} 根标题、有效子标题、非通用候选名、实体名 主召回与主要相关性
search_text_secondary.{lang} 短标题、问题、分类名称 低权重补充召回
search_text_prefix.{lang} 适合输入联想的短实体文本 独立前缀召回

原始业务字段仍然存在,但只承担事实来源和展示职责;查询侧不再逐一理解它们。

业务事实字段
  ├─ 根标题
  ├─ 子条目标题
  ├─ 候选项名称
  ├─ 分类翻译
  └─ 实体元数据
          │
          ▼
   写入侧聚合、过滤、去重、按语言分桶
          │
          ├─ search_text.*
          ├─ search_text_secondary.*
          └─ search_text_prefix.*

这更像一个编译过程:业务字段是源代码,统一检索字段是面向搜索引擎的中间表示。

5.1 为什么在应用层聚合,而不是使用 copy_to

Elasticsearch 的 copy_to 很适合简单字段合并,但我们的聚合规则包含:

  • 按语言归桶;
  • 去除空值和精确重复值;
  • 过滤通用候选名;
  • 过滤只有数字或符号的标题;
  • 过滤描述玩法而非实体的模板标题;
  • 将只存在于结构化扩展字段中的实体名提取出来;
  • 控制某些英文来源只能进入 en;
  • 为主召回、次级召回和 prefix 使用不同来源集合。

这些规则放在应用代码中更容易单测,也能直接检查 _source 中最终生成了哪些检索值。copy_to 产生的目标内容默认并不会作为独立原始值出现在 _source 中,排查“某个词为什么进入索引”会更困难。

代价也很明确:任何修改源字段的 writer,都必须重新生成统一字段。V2 因此规定:

不允许局部 patch 派生检索字段;更新相关源数据时,必须从权威数据重建整篇搜索文档。

这牺牲了一部分写入效率,换来了源字段与派生字段的一致性。

5.2 为什么使用数组,而不是拼成一条长字符串

同一语言下的多个值以数组写入,而不是用空格连接:

{
  "search_text": {
    "en": ["A complete title", "Lions", "Tigers"]
  }
}

配合较大的 position_increment_gap,短语查询不会跨数组元素拼出一个现实中不存在的 phrase。例如第一个值以 “Lions” 结尾、第二个值以 “Tigers” 开头时,不应该因此命中 “Lions Tigers”。

这是一处很小但重要的建模细节:检索字段可以统一,原始值之间的边界不能丢。

六、多语言查询:locale 负责展示,脚本负责召回

V2 将 mapping 视为“索引支持哪些语言”的事实来源,查询不再维护一份与业务字段绑定的长列表。但我们也没有简单地对所有语言执行 most_fields。

6.1 主语言与 fallback 不能使用同一种计分方式

第一版 lane 已经为可识别脚本设置了主语言,并保留全语言 wildcard 兜底,但两条 lane 都使用 most_fields。这保证了召回,却让 fallback 可以累加每个翻译字段的得分,反过来主导排序。

最终查询结构调整为:

dis_max(tie_breaker = 0)
├─ primary lane:  精确语言字段,most_fields,高权重
└─ fallback lane: 全语言 wildcard,best_fields,低权重

主 lane 的 most_fields 仍可合并主字段与次级字段的证据;fallback 的 best_fields 只保留得分最高的单个语言字段,不再奖励同一内容的多语言副本。外层 dis_max(tie_breaker=0) 再保证两条 lane 不互相叠分。

这不是简单地把所有查询改成 best_fields。在新语言字段回填稀疏时,单个字段可能因 IDF 偏高成为“最佳字段”;因此主 lane、fallback 权重、语言字段覆盖率必须一起验收。

6.2 为什么不用 cross_fields 或固定 Latin 字段组

cross_fields 看起来适合把多个语言字段当成一个大字段,但它要求字段 analyzer 兼容。英语、西班牙语、葡萄牙语等字段使用不同 analyzer 时,Elasticsearch 会按 analyzer 分组,效果会退化成多个组重新计分,并没有消除我们面对的问题。

另一种方案是把 Latin 语言显式列成固定字段组。它的问题是:

  • 同一个实体的多语言副本仍会被累加;
  • 语言集合会写死在查询代码中;
  • 新语言上线必须同时修改 mapping、字段组和测试;
  • 纯 Latin 短 query 往往无法仅靠字符判断具体语种。

最终采用脚本特征加 fallback:

Han
→ Hangul
→ 具有明确越南语特征的 Latin 文本
→ 纯 ASCII / 其他纯 Latin 文本
→ 其余脚本走 wildcard

包含汉字的查询使用中文主 lane,同时保留繁体中文和日文 fallback;Hangul 使用韩文主 lane;带有 Ă/ă、Ơ/ơ、Ư/ư、Đ/đ 等特征的文本进入越南语主 lane;无法可靠细分的纯 Latin 输入使用英文主 lane 加全语言 fallback,而不是假装已经识别出具体语种。

这里刻意没有引入重型语言识别服务。短 query 的语言识别本来就不稳定,脚本检测加 fallback 更简单,也更容易解释。请求 locale 仍只负责结果展示,不直接决定检索字段。

6.3 Unicode 规范化是检索协议的一部分

带重音的字符可能使用预组合形式,也可能由基础字母加 combining mark 组成。两段文本肉眼完全相同,Java 字符序列和脚本判断结果却可能不同。

我们将 query 统一规范化为 NFC,并要求规范化发生在所有下游处理之前:

raw query
→ NFC normalize
→ 热词词典
→ 语言 lane 判断
→ Elasticsearch 查询
→ embedding

只在语言检测器内部 normalize 还不够。否则检测器按 NFC 选择了某条 lane,词典、ES 或 embedding 收到的仍是 NFD 文本,整条链路对“同一个词”的理解依旧不一致。

6.4 读时规范化之外,还要清理写侧 locale alias

上游可能同时使用 vi、vi-VN 一类 locale 表达。仅把新写入值放到规范 key,旧 alias 并不会自动消失;下一次完整重建仍可能把两份值同时带进统一字段。

写入翻译时,我们使用同字段、字段级的 JSON patch:写入规范 key,同时把该字段中的旧 alias 设为 null,让数据库真正删除旧成员。

{
  "vi": "normalized value",
  "vi-VN": null
}

清理必须限定在当前字段。某个字段完成刷新,不代表其他字段的同名 alias 也已经拿到新值;跨字段清理会把仍然有效的旧翻译误删。

动态新增的语言虽然能被 wildcard 召回,但如果 mapping 没有显式配置 analyzer 和 prefix 索引,就只能获得默认分析能力。动态语言解决的是兼容性,不等于自动获得最佳效果。

七、把召回和精度拆成不同的 lane

V2 不再试图用一个 multi_match 同时完成所有工作。

7.1 主词法召回:BM25

主查询只面向两层稳定字段:

search_text.*            高权重
search_text_secondary.*  低权重

长描述被排除在词法召回之外。它不是“永远没有价值”,而是在当前内容形态下带来的噪声大于召回收益;语义相关性可以交给向量通道补充。

7.2 拼写容错:受限 fuzzy

我们没有对所有 query 无条件开启 fuzzy,而是设置了几道门槛:

  • query 达到一定长度才启用;
  • 保留前若干字符精确匹配;
  • 只对验证过的脚本类型开放;
  • 短缩写默认不做模糊扩展;
  • 所有条件都可以按语料继续收紧。

Fuzzy 的本质是用更多 CPU 和更多误召回来换取拼写容错。它应该是一个受控的召回补丁,而不是默认开启的“智能模式”。

7.3 输入联想:独立 prefix 召回

search_text_prefix 使用 text + index_prefixes,查询采用 bool_prefix。它与 BM25 位于同一个 recall should 中,并设置 minimum_should_match = 1:

must(
  should(
    bm25,
    bool_prefix
  ),
  minimum_should_match = 1
)

这意味着 prefix 不是“已经召回后的加分项”,而是真正可以在完整 token 尚未形成时救回文档的独立通道。Elasticsearch 也会利用 index_prefixes 优化前缀查询。

这里还有一个多语言陷阱:multi_match(type=bool_prefix) 会像 most_fields 一样组合字段得分。若字段写成 search_text_prefix.*,同一个前缀在多个语言字段中出现时又会获得累计奖励。

因此,多语言 prefix 最终不再使用 wildcard multi_match,而是为当前实际有数据的语言逐一生成 match_bool_prefix,再用 dis_max(tie_breaker=0) 合并:

dis_max(tie_breaker = 0)
├─ match_bool_prefix(search_text_prefix.en)
├─ match_bool_prefix(search_text_prefix.zh)
├─ match_bool_prefix(search_text_prefix.ko)
└─ ...

收益是只保留最佳语言的 prefix 分数;代价是代码中需要维护一份“已有数据的 prefix 语言”注册表。新增语言时,mapping、写入覆盖、注册表和数量断言必须同步更新。对于明确只需要英文前缀的入口,仍可以单独收窄到 .en。

我们没有立即采用 search_as_you_type 的 shingle 字段。当前目标只是可靠地召回未输入完成的最后一个 token;引入二元、三元 shingle 会增加索引体积和调参面。若真实语料证明词序敏感的 typeahead 有显著收益,再增加新的索引 revision。

7.4 精度信号:phrase 与双实体只负责提升排序

对于包含多个有效 token 的查询,增加 phrase boost;对于 “A vs B”“A @ B” 一类双实体结构,分别匹配两侧实体,并要求两侧都出现。

双实体的每一侧使用 best_fields,而不是 most_fields。外层双实体 clause 本身已经带有 precision boost;如果一侧实体同时存在于多个翻译字段,再做一次求和,就会在有意的精度加权之上获得一层意外的“翻译数量加权”。

这些 clause 只作为 should 提升排序,不作为 hard filter。原因是自然语言输入并不总是严格符合模板:精度规则如果参与准入,很容易以“排序更准”的名义牺牲普通 query 的召回。

一个实用原则是:

召回 lane 决定“能不能进候选集”,精度 lane 决定“进来之后排在哪里”。

7.5 查询改写不是字符串替换,而是一条可回退的决策链

Fuzzy 解决的是倒排词项之间的编辑距离,同义词和拼写建议解决的则是用户表达与语料表达之间的差异。它们都能增加召回,也都可能在专有名词、缩写和跨语言输入上误判。

因此,查询理解不应该是一次不可逆的 original → rewritten 替换,而应该保留每一步的证据与退路:

原始输入
→ Unicode、空白和大小写归一化
→ 脚本与语言 lane 识别
→ 同义词、别名作为附加召回
→ 原始 query 检索
→ 仅在零结果或低置信度时生成拼写建议
→ 验证建议是否真的能命中文档
→ 展示“是否想搜索……”,或执行受控重试

这里有四条约束:

  • 原始 query 始终参与检索,改写不能覆盖用户输入;
  • 多词同义词要保留 token graph,避免 phrase 关系被打散;
  • 拼写建议的候选必须比原词更可信,并通过实际命中验证;
  • 零结果重试最多执行有限次数,防止改写链导致延迟和查询量失控。

搜索时同义词的优势是词典更新不需要全量重建索引,代价是每次查询都要承担展开成本;索引时展开同义词能让查询更轻,却会把词典版本固化进索引。对经常调整的业务词典,前者通常更适合,但每次规则发布都必须先用 analyzer 测试 token 输出。

拼写建议也不应默认自动执行。一个低频词可能是新出现的专有名词,而不是拼写错误。先展示建议比静默替换更安全;只有在零结果、建议置信度足够高且建议 query 确实有结果时,自动重试才值得考虑。

八、混合搜索:为什么从分数相加改成 RRF

V2 将 BM25 和 kNN 拆成两条独立检索腿,再在应用层使用加权 Reciprocal Rank Fusion 合并:

rrf_score(d) = Σ weight_i / (k + rank_i(d))

例如词法权重设为 1.0,向量权重设为 0.5,表达的是“语义召回用于补充,但精确词法结果优先”,而不是试图把两个不可比的原始分数硬调到同一尺度。

RRF 的收益是:

  • 只依赖各通道内部排名,不依赖分数绝对值;
  • BM25 字段或 analyzer 调整后,不必重新标定向量分数;
  • 权重含义比原始分数加法更直观;
  • 两路都命中的文档会自然获得稳定优势。

它也不是免费的午餐:

  • 原始分数中“第一名远胜第二名”的幅度信息会丢失;
  • 每条检索腿都要拉取大于最终 topK 的候选池;
  • rank_window 太小会漏掉本应在融合后上升的文档,太大则增加 ES 与应用层开销;
  • 向量通道总会尽力返回最近的 k 个邻居,因此仍需要最低相似度约束。

对于 cosine 向量,Elasticsearch 返回的 _score 不是原始 cosine,而是:

_score = (1 + cosine) / 2
cosine = 2 × _score - 1

如果要对“只被向量召回”的候选设置几何准入阈值,应比较还原后的 cosine,不能直接把 _score 当 cosine。

另外,embedding 服务失败时必须自动退回 BM25。向量召回应当提升质量,而不应该成为搜索可用性的单点依赖。

8.1 并非每个入口都需要混合搜索

迭代中另一个重要认识是:搜索入口的契约可能不同。

面向最终用户的主搜索更看重语义召回,可以承担 embedding 和第二次 ES 查询的成本;而需要固定候选池、稳定前缀顺序或还要把根文档展开为大量子条目的接口,BM25-only 反而更可控。在后者中,即使全局打开向量能力,也不执行 embedding 和 kNN,以免一个开关无意中改变该入口的成本与排序契约。

因此,V2 的“统一”指统一数据模型与检索原则,不代表所有入口必须执行完全相同的昂贵管线。先明确调用方需要的稳定性、延迟和解释性,再决定是否打开向量腿。

九、热词、分类与热度:不要把不确定信号当事实

词典改写通常同时产生两类结果:同义词或规范词,以及关联分类。

V1 曾把词典生成的分类直接作为 hard filter。它能显著提高精度,但一个错误映射就可能让精确标题也变成零结果。改成纯 boost 更稳健,却可能无法满足某些调用方的强分类约束。

更合理的做法是区分信号来源:

  • 用户或调用方显式传入的分类是约束,可以 hard filter;
  • 词典推导出的分类是推测,应优先作为排序信号;
  • 如果业务确实需要把词典分类作为 filter,至少要在零结果时用原始 query、去掉推导约束后重试一次;
  • 原始 query 始终保留在主词法召回中,改写词只作为附加 should,避免错误改写覆盖用户输入。

热度也只能是相关性的次级信号。我们将热度提升限制在已识别意图的少数类别中,并对增量设置上限;function_score 只包裹 BM25 clause,而不是包裹整个外层 bool。

这样做是为了防止一个危险情况:某文档完全没有命中文本,却因为通过了过滤条件和拥有高热度,获得足以压过真实相关结果的分数。

十、候选池、去重与截断:顺序就是语义

一次很典型的线上问题来自处理顺序。

某些内容会周期性生成多个窗口,它们拥有不同文档 ID,却代表同一个系列。如果先取 topK 再按业务 key 去重,前几十个候选可能全部来自同一系列,去重后只剩一两条;原本排在候选池后面的其他相关系列永远没有机会进入结果页。

错误顺序:

BM25 / kNN
→ RRF
→ topK
→ 业务去重

正确顺序:

BM25 候选池 + kNN 候选池
→ 对完整候选并集做 RRF
→ 文档 ID 去重
→ 业务 key 去重
→ 状态稳定分桶
→ final topK

这里有一个比“多拉几倍候选”更重要的结论:扩大候选池只能缓解问题,不能修正错误的算子顺序。

凡是可能改变结果成员资格的操作——去重、状态过滤、权限过滤、业务替换——原则上都应该在最终截断前完成。分页则要单独设计,因为 offset、融合候选池和去重之间很难天然保持稳定语义。我们没有为了追求“全入口 V2”而强行复用非分页管线,而是把分页迁移留作独立问题。

状态分桶也踩过同样的坑。我们曾经先取 topK,再把可用状态排到历史状态之前。这样只能调整已经进入窗口的文档;一个位于 topK + 1、本应被提升的可用文档已经被提前丢掉。修复不是换一个 comparator,而是对完整去重候选集先做稳定分桶,最后再截断。

10.1 去重之后,还要不要保证结果多样性

业务 key 去重只能消除“同一对象的多个副本”,不能消除语义上高度相似的不同文档。一个模糊 query 的首屏仍可能被同一主题、同一实体或同一模板的内容占满。

多样性可以分两级处理:

  1. 如果存在可靠的系列、实体或主题字段,先使用 group cap,限制每组进入首屏的数量;
  2. 如果缺少明确分组,再考虑使用 Maximum Marginal Relevance(MMR)对较小候选集做语义去重。

MMR 在选择下一条结果时,同时考虑它与 query 的相关性,以及它与已选结果的相似性:

mmr(d) = λ × relevance(query, d)
         - (1 - λ) × max similarity(d, selected)

λ 越高越偏向相关性,越低越偏向覆盖不同内容。它适合宽泛、探索型 query,却不适合所有场景:当用户输入一个非常精确的名称时,为了“丰富”而降低精确结果密度,反而违背用户意图。

所以顺序仍然是:可靠的结构化分组优先,向量多样化其次;精确 query 默认关闭或弱化多样性。MMR 还会增加候选间相似度计算,必须限制窗口,并把相关性损失作为独立指标观察。

十一、索引迁移:V2 不只是一个查询类

Elasticsearch 的 analyzer、部分 mapping 参数和向量结构不能安全地在原字段上原地改变。与其在 V1 上继续打补丁,我们选择新建版本化索引,让 V1/V2 在一段时间内并存。

完整迁移过程是:

创建 V2 revision index
        │
        ▼
开启增量双写
        │
        ▼
全量回填 V2
        │
        ▼
校验数量、字段覆盖、向量覆盖与新鲜度
        │
        ▼
按规范化 query 的稳定 HMAC 灰度读流量
        │
        ▼
逐级放量并观察质量、延迟与错误率
        │
        ▼
保留 V1 一个观察期后再清理

11.1 为什么先双写,再回填

全量回填可能持续较长时间。如果回填过程中仍有源数据变化,而增量写只进入 V1,那么 V2 在回填完成时就已经过期。

先建立增量双写,再开始扫描历史数据,可以让与回填并发的更新进入 V2,但它本身不保证最终新鲜:历史扫描读取的旧快照,仍可能晚于增量更新写入并覆盖新版本。回填写入必须携带源数据版本或变更序号,让 V2 拒绝旧版本;无法提供版本号时,则需要记录扫描水位、重放水位之后的增量日志,并在切流前做最终 reconciliation。幂等只能避免重复写入产生额外副作用,不能解决旧写覆盖新写。

V2 失败时,重试队列不能删除对应任务,即使这意味着下一轮会重复写一次 V1。

11.2 为什么使用稳定 query HMAC 灰度

随机按请求切流会让同一个 query 一会儿走 V1、一会儿走 V2,用户体验和排查结果都不稳定。

使用服务端密钥对规范化后的 query 计算确定性 HMAC,可以保证等价输入稳定进入同一个版本,同时避免普通 hash 被低熵字典轻易反推。每个入口使用独立开关,避免某类接口的问题迫使整个搜索系统一起回滚。

回滚只切读路由,不删除 V2 索引,也不需要反向数据迁移。这样的回滚才能真正做到秒级。

11.3 全文重建与向量同步的取舍

词法字段适合从权威数据库整篇重建,但向量生成昂贵且通常异步。若 embedding 模型与版本未变化,回填 V2 时可以从 V1 复制现有向量,再让增量向量任务镜像到两个索引。

它降低了回填成本,也留下一个需要正视的缺口:如果 V1 向量读取失败或 V2 镜像局部失败,只扫描 V1 无法发现 V2 的单边缺失。正确的后续方案不是假设异步任务“最终一定成功”,而是增加 V2 缺失向量的 reconciliation,并优先从 V1 复制,必要时才重新生成 embedding。

技术债可以暂留,但必须被准确命名,不能把“有重试日志”写成“已经形成一致性闭环”。

11.4 为什么翻译更新会漏掉搜索重建

完整重建依赖一个前提:所有会改变检索投影的源数据都能触发根文档更新。实践中,根表和主要子表通常已经接入变更订阅,但更深层的候选项或翻译表可能没有。

这会出现一种典型假象:翻译已经成功写入数据库,展示接口也能读到,但搜索索引没有任何刷新事件。解决方法是在翻译批次完成后,显式把所属根文档加入现有重建队列;等同一批数据库写入结束后再入队,确保重建读取的是一致快照。

我们没有为这个低频来源另建一条同步管线,而是复用已有根文档队列、去重和重试语义。代价是根文档会被完整重建,收益是避免维护第二套局部 patch 规则。

十二、我们实际交付了什么

V1 → V2 的交付远不止一份 mapping 和一条查询语句。

交付面 内容
索引模型 版本化 V2 mapping、三层统一检索字段、语言 analyzer、prefix 索引、展示字段 source-only 化
写入模型 应用侧检索投影构建器、噪声过滤、按语言聚合、值去重、完整重建契约
查询模型 BM25 主召回、受限 fuzzy、逐语言 prefix、phrase 与双实体精度 lane、主语言 most_fields + fallback best_fields
混合检索 独立 kNN、加权 RRF、向量-only 准入阈值、embedding 失败降级
业务排序 去重前置、状态稳定分桶、热度有限加权、显式约束与推导信号分离
数据同步 全量/指定范围回填、增量双写、幂等重试、locale alias 清理、深层翻译变更显式触发根文档重建
发布能力 按入口独立开关、确定性 hash 灰度、V1/V2 并存、快速回滚
可观测性 分阶段耗时、各腿候选数、零结果率、prefix 命中、向量-only 命中、写入新鲜度
质量保障 mapping 快照、文档构建单测、Query DSL 测试、融合排序测试、任务锁与失败重试测试、离线语料回归
运维资料 建索引、双写、回填、放量、监控、回滚和遗留问题清单

其中最重要的不是功能数量,而是每一层都拥有了可以独立验证的契约。

十三、如何验证搜索质量

搜索系统不能只靠单元测试验收。

我们把验证拆成四层。

13.1 代码行为正确

  • mapping 中 analyzer、index_prefixes、position_increment_gap 符合预期;
  • 源字段是 source-only,统一检索字段由应用写入而非 copy_to;
  • 文档构建能正确归类主字段、次级字段和 prefix 字段;
  • 通用词、模板词、纯数字符号被过滤,真实实体名不会误伤;
  • query 生成符合脚本、长度和开关条件;
  • NFC/NFD、大小写、特征字符和混合脚本进入一致的语言 lane;
  • 主 lane、fallback、prefix 与双实体分别使用约定的 query type;
  • RRF 权重、向量-only 准入、去重和截断顺序有确定性测试;
  • 回填任务有分布式锁,写入失败时不会提前清理重试队列;
  • embedding 或 ES 局部故障能回到预期降级路径。

13.2 搜索结果真的更好

建立包含数千条真实或脱敏 query 的回归集,至少观察:

  • Recall@K:应该出现的结果是否进入前 K;
  • NDCG@K:更相关的结果是否排得更靠前;
  • zero-result rate:无结果比例是否下降;
  • forbidden-result rate:明确不应出现的结果是否增加;
  • P95 / P99:词法、embedding、kNN、融合和总链路延迟;
  • index size:统一字段和 prefix 索引带来的存储增量;
  • per-language coverage:各语言字段的文档覆盖率是否足以支撑稳定 IDF;
  • freshness lag:源数据变化到 V2 可搜索之间的延迟;
  • vector coverage:需要向量的文档中,实际拥有有效向量的比例。

不要用 _score 的绝对值比较 V1 与 V2。字段集合、analyzer 或融合方式变化后,分数尺度本来就不同;应该比较结果集合、顺序和业务判定。对于多语言排序异常,先用 _explain 拆出各 lane 和语言字段的贡献,再判断是 boost、跨字段累加、analyzer,还是回填覆盖率造成的 IDF 偏差。

线上监控也应使用低基数标签和区间桶,不记录原始 query,避免把用户输入带入指标系统。

13.3 评测集必须形成持续反馈闭环

一次性准备的回归集会随着语料、语言和用户表达变化而老化。更可持续的方式,是让线上失败样本不断回流到离线评测:

脱敏查询与结果统计
→ 按问题类型分桶
→ 发现零结果、低交互和排序异常
→ 人工标注相关等级
→ 离线回归
→ 灰度实验
→ 新失败样本重新进入评测集

样本不能只由高频 query 构成。我们需要同时覆盖精确名称、前缀、拼写错误、多语言、混合脚本、双实体、head/torso/tail query,以及明确不应出现的负样本。曾经发生过的线上问题应成为永久回归样本,而不是修复后从视野中消失。

不同指标负责不同阶段:Recall@50 检查第一阶段候选池,NDCG@10 检查最终排序,MRR 检查用户想要的第一个结果是否足够靠前,zero-result rate 和 forbidden-result rate 则分别守住召回与误召回底线。

点击数据适合用于发现问题,却不能直接当作无偏标签。排在前面的结果天然更容易被点击,新结果又可能因为没有曝光而缺少反馈。更稳健的做法是用行为数据挖掘待标注样本,用人工相关性判断构建基线,再通过灰度实验验证整体收益。

13.4 给每次搜索留下可解释的 Search Trace

相关性问题常常不是“某条 query 写错了”,而是某个 lane、规则或处理阶段意外主导了结果。我们为主要 clause 设置稳定名称,并在调试环境中记录经过脱敏的决策摘要:

{
  "queryVersion": "v2.3",
  "languageLane": "latin",
  "matchedLanes": ["lexical_primary", "phrase_boost"],
  "candidateCounts": {
    "bm25": 120,
    "knn": 80,
    "merged": 163,
    "afterDedup": 91
  },
  "timingMs": {
    "normalize": 1,
    "embedding": 18,
    "lexical": 11,
    "vector": 14,
    "fusion": 2
  }
}

Named query 用来回答“命中了哪条规则”,_explain 用来分析单个文档的计分树,Profile API 用来定位查询执行成本。三者用途不同,尤其 Profile 会显著增加开销,只适合抽样或离线排查。

版本也需要拆开记录:

index revision      数据结构与 analyzer 版本
query version       查询、权重与算子顺序版本
embedding version   向量模型版本
dictionary version  同义词与规则版本

只记录“V2”不足以解释一次排名变化。各类版本可以作为低基数指标标签;query 指纹不能进入指标标签,只能在受采样的调试 trace 中使用服务端密钥生成的 HMAC,并设置严格的访问控制和短期留存。这样既能关联同一 query 的排查记录,也不会让指标基数或隐私风险失控。

十四、从 V2 继续往前:重排与稳定分页

V2 建立了稳定的检索面和融合框架,但它仍然是第一阶段搜索系统。两个自然的后续方向是更精细的 Top-N 重排,以及在混合检索下提供稳定分页。

14.1 RRF 之后是否还需要语义重排

RRF 解决的是 BM25 与向量分数不可比的问题,但它只使用各通道的排名,没有直接判断 query 与文档整体是否真正相关。

下一阶段可以把 RRF 产生的较小候选集交给更昂贵的模型:

BM25 + kNN
→ RRF Top 100
→ Cross-Encoder 或 Learning to Rank
→ 业务约束与稳定分桶
→ final Top 20

Cross-Encoder 可以直接判断 query 与文档文本的相关性,冷启动成本相对较低,但增加推理延迟和外部依赖;Learning to Rank 可以组合词法、phrase、语言命中、时效性和质量等特征,可解释性更强,却需要足够多且偏差可控的训练数据。

无论选择哪种方式,都必须承认两个边界:重排无法救回没有进入候选池的文档;重排服务失败时,搜索应直接降级到 RRF,而不是整体失败。rerank_window 因此既是质量参数,也是延迟和成本参数。

14.2 为什么 PIT 也不能独自解决混合搜索分页

单路确定性检索可以使用 Point in Time、search_after 和唯一 tie-breaker 固定索引视图,避免刷新期间结果跨页移动。混合检索却还有额外状态:两路候选窗口、RRF 排名、应用层去重、状态分桶和可能存在的重排结果。

一个真正可复现的游标至少需要绑定:

{
  "queryFingerprint": "HMAC(...)",
  "queryVersion": "v2.3",
  "indexRevision": "...",
  "pitId": "...",
  "candidateWindow": 200,
  "lastRank": 17,
  "expiresAt": "..."
}

如果每次翻页都重新生成 embedding、重新拉取候选并重新融合,即使底层索引视图不变,应用层结果仍可能漂移。要么在游标生命周期内固定完整查询计划和候选状态,要么明确限制搜索深度,只提供少量“加载更多”。后者功能看似保守,却比不可重复的深分页更诚实。

十五、没有解决的事情

一个可信的技术总结不应该把 V2 描述成终点。仍然存在几类明确技术债:

  • 动态新增语言可以被召回,但没有显式 mapping 就得不到专用 analyzer 与 prefix 优化;
  • 逐语言 prefix 查询依赖一份有数据语言注册表,支持范围变化时必须同步代码与测试;
  • 不带明显特征字符的纯 Latin 短 query 无法可靠区分具体语言,只能依赖主 lane 加 wildcard fallback;
  • 并非每条专用语言 lane 都有独立灰度开关,回滚粒度仍可继续细化;
  • analyzer 或 prefix mapping 调整仍需要新建 revision 并全量 reindex;
  • 新 writer 如果绕过统一字段重建,会重新制造源字段与派生字段不一致;
  • 向量双写需要独立 reconciliation 才能形成真正闭环;
  • 融合、去重后的稳定分页已有方向,但候选状态保存、游标过期和资源成本仍需验证;
  • Top-N 语义重排尚未进入 V2,需要先证明收益能够覆盖推理延迟与可用性成本;
  • V1 字段和旧查询模式只能在全量切流并经过观察期后清理;
  • 中文、繁体中文、纯汉字日文之间的权重仍必须由真实语料持续校准。

明确非目标并不会削弱方案。相反,它能防止“已经上线”被误解为“所有一致性和相关性问题都已解决”。

十六、总结与反思

回头看,这次迭代真正改变我们的不是某一个 Elasticsearch 特性,而是对搜索系统边界的理解。

16.1 搜索优化首先是数据建模问题

如果用户可搜索的文本散落在十几个业务字段中,查询 DSL 再精巧也只是在承担本不属于它的复杂度。统一检索投影让查询面对稳定语义,而不是不断变化的数据库结构。

16.2 召回、排序、约束必须分层

Prefix 和 fuzzy 负责扩大候选;phrase、双实体和有限热度负责调整顺序;用户显式过滤负责约束结果。让一个信号同时承担多种职责,往往是相关性事故的起点。

16.3 多语言搜索首先是评分空间问题

为每种语言配置 analyzer 只解决了“如何分词”,没有解决“多个语言字段如何一起计分”。主 lane、fallback、most_fields、best_fields、dis_max、字段覆盖率和 Unicode 规范化共同决定最终排序。多语言能力不能按字段数量验收,必须按 query 和 _explain 验收。

16.4 “混合”不等于“分数相加”

不同检索器的原始分数通常不可比。RRF 用排名融合规避了尺度问题,但候选窗口、权重和向量准入仍需要语料验证。向量搜索也不是所有入口的默认答案。

16.5 算子顺序是搜索语义的一部分

去重、业务分桶、展开和 topK 的顺序会直接改变用户看到什么。它们不是查询结束后的“数据清洗”,而是排名算法的一部分。

16.6 发布设计是功能设计的一部分

如果一个相关性改动不能灰度、不能观测、不能快速回滚,它就还没有达到可交付状态。新索引、双写、回填、稳定分桶和保留旧版本,应在设计之初就一起考虑。

16.7 单元测试通过不代表搜索质量通过

测试可以证明 query 按预期生成,却不能证明结果符合用户直觉。真实语料、负样本、离线指标和灰度观测缺一不可。

16.8 少而相关,通常比多而混乱更好

当高相关候选不足时,用热门内容填满页面看起来更“丰富”,却会持续侵蚀用户对搜索的信任。没有足够相关的结果时,返回更短的列表往往是更诚实的选择。

如果重新做一次,我们会更早完成五件事:

  1. 在第一次出现跨层级文本时就建立搜索投影,而不是连续增加查询字段;
  2. 在开始调权重前建立固定的 query 回归集和负样本集;
  3. 新语言开始回填时就监控字段覆盖率、规范化 locale,并用 _explain 建立跨语言评分基线;
  4. 在引入向量搜索前先让词法召回、数据新鲜度和发布回滚形成稳定基线;
  5. 从第一天起给 query、索引、词典和模型分别编号,让每一次排序变化都能够被回放和解释。

V2 最终带来的价值,不只是“能搜到更多东西”,而是让团队能够回答:一个词为什么被索引、一个文档为什么被召回、一个结果为什么排在这里,以及新版本出问题时如何安全退回去。

这四个问题能被清楚回答时,搜索系统才真正从一组查询参数,成长为一项可持续演进的工程能力。

延伸阅读