搜索建议为什么不能现查:它卖的是提前算好的答案

搜索建议是在你刚敲下几个字时,就把可能的完整查询排好序递上来的服务。它看起来只是前缀匹配,真正的难点是每个前缀的候选在用户输入之前就已经离线算好,线上只做一次查表。最常见的误区是把它当成一条带索引的 LIKE 查询,或者以为点击率越高就代表建议越好。日志聚合决定它见过什么,Top-K 预计算和 FST 与 Trie 的取舍决定它有多快,点击反馈则决定它会不会越走越窄。读完你能算清延迟与内存这笔账,也能判断什么时候该批处理、什么时候该加增量层。

是什么

搜索建议(search suggestion,也叫 autocomplete、typeahead、search-as-you-type)是搜索框在你输入前缀时,返回若干条完整查询的功能。它给人的直觉是“帮你把字补完”,但真正做的事是:拿全体用户过去问过的问题,猜你接下来最可能问哪一句。所以它的候选单位是查询,不是网页,也不是商品标题。

典型场景是电商搜索框、站内检索和地图 POI。它们共享两个硬约束:一是快,用户每敲一个字符都可能触发请求,端到端通常要压在 100ms 以内;二是可用,给出的必须是真有人搜过、点进去能搜到东西的查询。还有一条常被忽略:建议是公开内容,一条诽谤性联想,杀伤力远大于“少返回一条结果”。

核心原理

线上只做一次查表

把搜索建议想成奶茶店的“常点榜”:店员不会等你报口味才统计全店历史,而是提前把每个前缀下最受欢迎的几款抄在卡片上,你报一个字,他直接抽卡。

对应到系统,是一条读写完全分离的链路:原始查询日志 → 清洗(去重、剔除爬虫与刷量、统一大小写和全半角)→ 按时间窗口聚合频次 → 对每个前缀取 Top-K → 构建索引 → 上线服务。线上请求只做前缀定位和读列表,不做全量排序。

如果只在叶子节点存查询,查“p”就得遍历整棵子树再排序;候选有几百万条时,这一步在几万 QPS 下不可能达标。标准做法是在每个节点缓存该前缀下的 Top-K:走到“py”这个节点,直接返回它挂着的 5 条,代价是前缀长度加 K,而不是子树大小。

代价在内存。1000 万条查询、平均 20 字符、只索引前 12 个前缀,每条会被写进约 12 个列表,共一亿多条 Entry;只存 ID 和分数、按 8 字节算就是 800MB 量级,还没算指针开销。所以必须裁剪:限制前缀长度、丢掉低频长尾、只存 ID、按语言和地域拆索引。

from collections import Counter, defaultdict

def build_index(queries, k=3, max_prefix=12):
    freq = Counter(queries)
    idx = defaultdict(list)
    for q, c in freq.items():
        for i in range(1, min(len(q), max_prefix) + 1):
            idx[q[:i]].append((c, q))
    for p, items in idx.items():
        items.sort(key=lambda x: (-x[0], x[1]))
        idx[p] = [q for _, q in items[:k]]
    return idx

index = build_index(["python 列表", "python 装饰器", "python 教程", "pytest 用法"])
print(index["py"])   # 前缀 "py" 下预计算好的 Top-3

这段代码就是预计算的全部思想:写入时把每个前缀的答案排好,读取时只查一次表。真实系统还要把 q 换成 ID,把字典换成压缩过的 Trie 或 FST。

Trie 之外,还有 FST

Trie 每个字符一个节点,实现简单、能增量改;缺点是只共享前缀,指针多、缓存不友好。FST(有限状态转换器)连后缀也合并,把字符串和权重压成一张有向图,更省内存、访存更连续。Elastic 的 completion suggester 就走这条路:Lucene 按 segment 在索引阶段构建 FST,查询时沿图走;官方博客的维基百科标题测试里,单次 completion 是亚毫秒级,普通 prefix 查询是毫秒级。

但 FST 构建后接近不可变,更新通常要写新 segment 或整体重建;而查询流每天都在变。所以常见组合是:头部稳定词库用 FST,实时变化的部分用小的 Trie 或哈希表做增量层,查询时合并。

排名不等于按次数从大到小

原始频次有三个坑:刷量和爬虫能把词顶上去;同一个人反复刷新会被当成很多人;老词累积时间长,永远压着新词。工程上一般按去重用户数计数、给分数加时间衰减,再叠加安全、地域、语言和多样性规则。

Google 的自动补全说明列的信号也是这个方向:真实搜索、语言、位置、趋势和登录用户的历史搜索;它同时明确会拦截暴力、色情、仇恨等内容,并对选举、健康、涉及个人的诽谤性联想有额外政策。排序从来不是纯数学问题,过滤层是主路径的一部分。

新鲜度:批处理之外再挂一个增量层

纯离线重建的问题是“慢半拍”:突发新闻词、刚上的新品,在下一轮重建前根本不在索引里。做法是双结构:按小时或按天重建的基线索引负责稳定前缀,只保留最近几分钟计数的增量结构负责趋势词;查询时取基线 Top-K,再用增量归并截断。

增量层不能无限长,否则会退化成现算;只留高频、只覆盖较短前缀,过期并回基线。

客户端也在系统里

一个被低估的放大系数:建议请求数不等于搜索请求数。按一次搜索敲 10 到 20 个字符估算,建议 QPS 可能是搜索的十倍以上。客户端要做三件事:停顿 100 到 200ms 再发、少于 2 个字符不发、发新请求时取消旧请求。服务端在边缘缓存前缀结果,按前缀分片,让一次请求只落一个分片;分片要按历史流量切,热门前缀还得加副本。

反馈回路会把排名越推越窄

这是最容易被忽略的一点。建议展示得越多,被点击越多;点击记进日志又抬高分数;下一轮展示更多。富者愈富,搜索框里最后永远是那几个词,长尾说法没有机会冒头。

更隐蔽的是位置偏差:第一条被点的概率天然高于第五条,直接拿点击率当质量分,是在奖励“排得靠前”而不是“答得准”。解法是记录曝光而不只是点击,按曝光归一,留一小部分流量随机探索,并用“点击后是否真的完成了搜索”这类下游指标校验,而不是只看 CTR。

常见考点与误区

  • “建个索引,然后 LIKE '前缀%' 就行了”:B+ 树能靠最左前缀把范围缩小,但缩小之后仍要按频次排序取前 K,候选多时就是一次大范围扫描加排序;LIKE '%词%' 更是用不上索引。低流量可以这么做,几万 QPS 时数据库会先倒下。面试里答“加索引”只答了一半,缺的是“按另一个维度取 Top-K”。
  • “Trie 查询是 O(1)”:只有在每个节点预存 Top-K 时,查找才近似是“前缀长度 + K”;不预存就要遍历子树,复杂度跟前缀下的候选数成正比。另一个常见漏答是内存:Top-K 存在每个节点上,空间换时间,估容量时必须算进去。
  • “用户每搜一次就更新一次索引”:一条查询会影响它的所有前缀节点,写放大会随查询长度线性增长,还要和读请求抢锁;而且单次搜索本来就不该改变全局排名。正确姿势是批处理加短窗口增量,实时性用趋势层补,不是把 Trie 变成事务库。
  • “搜索建议就是搜索补全”:两者索引的东西不同。查询建议索引的是“人搜过的句子”,按行为频次排名;文档/实体补全索引的是“库里已有的标题或名字”,按权重排名。Elasticsearch 的 completion suggester 属于后者,它用 weight 控制顺序,而不是查询热度。
  • “点击率高的建议就是好建议”:位置偏差和反馈回路会让 CTR 失真。要看曝光归一后的点击率、首次点击位置、以及点击之后有没有真的完成搜索。没有曝光日志,这个指标基本不可用。
  • “过滤可以上线后再加”:建议是公开内容,违规词一旦被推到所有人面前,补救成本远高于拦截。过滤层要放在缓存和 API 之间,并且失败时倾向关闭返回,而不是放行。

实践建议

  • 先算请求量,再谈架构:用“日搜索次数 × 每次字符数”估建议 QPS,按峰值 2 到 3 倍留余量;SLO 建议端到端 p99 小于 100ms,服务端自身小于 30 到 50ms。客户端 debounce、最少字符数、请求取消三件套先做,往往比换索引结构更能降延迟。
  • 预计算要敢裁剪:前缀长度封顶(常见 8 到 16 个字符)、低频查询直接不进索引、Top-K 只留 5 到 10 条、节点存 ID 不存字符串。上线前用真实日志估算 Entry 数和内存,别等 OOM 才发现“每个节点都存一份”有多贵。
  • 分片按历史分布,不按字母:用前缀流量表决定切分点,热门前缀单独加副本或本地缓存;同时给“查不到”的前缀做短 TTL 的负缓存,避免长尾请求反复穿透。
  • 排序用去重用户数加时间衰减,并保留探索流量:原始 PV 会被刷量和重度用户带偏;固定抽 1% 到 5% 流量展示非最优候选,才能拿到无偏的反馈,避免排名自我固化。
  • 把过滤和转义当成主路径:维护违规词表加分类器,命中就不返回;建议文本来自用户,渲染时必须转义,避免把存储型 XSS 带进搜索框;对选举、健康、具体个人的敏感组合设置更严格的规则。
  • 评测看下游,不只看 CTR:除了建议点击率,还要看“采纳建议后搜索成功率”“无结果率”“改写率”,以及延迟和缓存命中率。只优化 CTR 很容易把系统推向标题党。

一句话总结:搜索建议的快,来自离线把每个前缀的答案先算好;它的准,取决于你敢不敢让排名被真实反馈修正。

相关阅读

← 返回文章志