Suggestion 模块说明
suggestion/ 目录负责搜索框自动补全能力,当前实现只关注 suggestion 本身:离线构建建议词索引,在线根据输入前缀返回建议词列表。
这份 README 以当前代码实现为准,重点说明模块现状、关键设计、索引结构、构建发布方式,以及在线检索和排序细节。
1. 当前能力边界
- 对外接口:
GET /search/suggestions - 输入参数:
q、size、language - Header:
X-Tenant-ID - 返回内容:建议词列表
suggestions[] - 不做商品结果拼接,也不走二次商品查询链路
当前模块由三部分组成:
- 离线构建:从商品索引和搜索日志构建 suggestion 文档
- 索引发布:写入版本化索引,并通过 alias 原子切换
- 在线查询:优先走 completion,必要时再走
search_as_you_type兜底召回
2. 目录与关键代码
- builder.py:离线构建、增量更新、alias 发布、meta 状态维护
- mapping.py:suggestion 索引 settings 和 mappings 生成
- service.py:在线查询服务,负责语言归一化、双路召回、去重和最终排序
- RUNBOOK.md:构建、发布、验证操作说明
- TROUBLESHOOTING.md:常见问题排查
命令入口在 main.py 中的 build-suggestions 子命令。
3. 整体架构
在线路径:
Client -> /search/suggestions -> SuggestionService -> Elasticsearch suggestion alias
离线路径:
商品索引 + 搜索日志 -> SuggestionIndexBuilder -> 版本化 suggestion index -> alias publish
设计上有几个核心点:
- suggestion 独立建索引,不依赖在线商品检索
- 每个租户单独维护 suggestion alias,避免租户间相互影响
- 全量构建写新索引,切换 alias 时零停机
- 增量更新只处理 query log 增量,减少重建成本
4. 索引组织与发布
索引命名在 builder.py 中统一定义:
- 读别名:
{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}_current - 版本索引:
{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}_v<timestamp> - 元信息索引:
{ES_INDEX_NAMESPACE}search_suggestions_meta
当前实现是“每租户一个 suggestion alias + 多个版本索引”的模式,而不是环境级共享大索引。
全量构建时的发布流程:
- 创建新的版本化索引
- 写入本次构建出的 suggestion 文档
- 校验新索引可分配、可读
- alias 原子切换到新索引
- 清理旧版本索引,只保留最近若干份
- 更新
search_suggestions_meta
元信息索引里记录:
active_aliasactive_indexlast_full_build_atlast_incremental_build_atlast_incremental_watermark
这些信息主要服务于增量更新和排障。
5. Mapping 与索引字段
mapping.py 会根据租户的 index_languages 动态生成字段。
5.1 索引设置
number_of_shards = 1number_of_replicas = 0refresh_interval = 30s
中文使用自定义 analyzer:
index_ik:ik_max_word + lowercase + asciifoldingquery_ik:ik_smart + lowercase + asciifolding
其他语言优先使用 Elasticsearch 内置 analyzer,例如 english、arabic、french、german 等;未覆盖语言回退到 standard。
5.2 核心字段
tenant_id:租户隔离lang:建议词所属语言text:原始展示文本text_norm:归一化文本,用于唯一键和去重sources:来源集合,可能包含title、qanchor、tag、query_logtitle_doc_count/qanchor_doc_count/tag_doc_count:该词被多少商品字段支撑query_count_7d/query_count_30d:近 7/30 天搜索热度rank_score:离线预计算排序分lang_confidence/lang_source/lang_conflict:语言识别与冲突信息status:当前是否有效updated_at:最近更新时间
5.3 两类检索字段
completion.<lang>sat.<lang>
completion.<lang> 用于极速前缀补全,是短 query 下的主召回通道。
sat.<lang> 使用 search_as_you_type,用于多词前缀和 completion 未补足时的兜底召回。
也就是说,当前线上不是只靠一种召回方式,而是 completion 优先、SAT 补全。
6. 候选词从哪里来
builder.py 在全量构建中会聚合两大类数据源。
6.1 商品侧
从租户商品索引中流式读取:
titleqanchorsenriched_tags
处理方式:
title.<lang>:经_prepare_title_for_suggest()裁剪后作为候选词qanchors.<lang>:按分隔符拆分后作为候选词enriched_tags:支持多语言对象或普通列表,必要时做语言识别
商品扫描不是一次性全量拉入内存,而是通过 search_after 分批读取,这一点在 _iter_products() 已实现。
6.2 搜索日志侧
从 MySQL shoplazza_search_log 中按时间窗口流式读取:
querylanguagerequest_paramscreate_time
读取方式使用 stream_results=True + fetchmany(),避免 fetchall() 带来的内存风险,这也是当前实现相对旧方案的重要改进。
搜索日志主要用于补充:
- 用户真实搜索词
- 近 7/30 天热度
- 语言归属信息
7. 文本清洗与语言策略
7.1 文本归一化
在 <em>normalize</em>text() 中,当前实现会做:
- Unicode
NFKC归一化 - 去首尾空白
- 转小写
- 多空白折叠为单空格
这份 text_norm 是 suggestion 文档的稳定键的一部分,文档 _id 形式为:
{tenant_id}|{lang}|{text_norm}
这保证了同租户、同语言、同一归一化词面只会保留一份文档。
7.2 噪声过滤
在 _looks_noise() 中,以下内容会被过滤:
- 空文本
- 长度超过 120
- 全部由符号组成的文本
7.3 语言判定优先级
日志 query 的语言归属由 <em>resolve_query</em>language() 负责,优先级是:
shoplazza_search_log.languagerequest_params.languagedetect_text_language_for_suggestions()- 租户
primary_language
同时会记录:
lang_source:语言来自哪里lang_confidence:识别置信度lang_conflict:日志语言与请求语言是否冲突
在线查询侧在 _resolve_language() 也会做一次语言归一化,确保查询只打到租户允许的 index_languages。
8. 排序与 rank 细节
当前排序分成两层:离线 rank_score,以及在线最终排序。
8.1 离线 rank_score
在 <em>compute_rank</em>score() 中,当前公式是:
rank_score =
1.8 * log1p(query_count_30d)
+ 1.2 * log1p(query_count_7d)
+ 1.0 * log1p(qanchor_doc_count)
+ 0.85 * log1p(tag_doc_count)
+ 0.6 * log1p(title_doc_count)
含义上是:
- 搜索日志热度权重大于商品静态字段
- 30 天热度权重大于 7 天热度,但 7 天热度也会强化近期趋势
qanchor比普通标题更像“可搜索表达”,所以权重更高tag次之title提供基础覆盖,但权重相对更低
这个分数会被写入:
- 文档字段
rank_score completion.<lang>.weight
因此它同时影响 completion 通道和 SAT 通道。
8.2 在线召回排序
service.py 中的在线策略如下:
- 先查
completion.<lang> - 若 query 长度大于 2 且 completion 结果不足,再查
sat.<lang> - 按
text归一化结果去重 - 最终排序后截断
completion 通道本身依赖 ES completion 的 _score;SAT 通道则用 function_score + field_value_factor(rank_score)。
最终排序由 <em>finalize_suggestion</em>list() 负责,排序 key 为:
score * 长度惩罚系数rank_score
长度惩罚定义在 <em>suggestion_length</em>factor():
length_factor = 1 / sqrt(token_len)
这意味着在分数相近时,较短、较直接的 suggestion 会更容易排在前面,避免长尾长句把前缀补全结果“顶掉”。
9. 在线查询细节
SuggestionService.search() 的行为可以概括为:
- 如果 alias 不存在,直接返回空数组,不抛 500
- 短 query 优先走 completion 快速返回
- 对于更长 query,再补一次
bool_prefix - 查询时始终带
routing=tenant_id
这里的 routing 很重要,它保证 suggestion 查询尽量只落在目标租户对应的分片路由上,减少无效 fan-out。
SAT 查询部分还有两个显式过滤条件:
lang == resolved_languagestatus == 1
这能保证召回结果只来自当前语言、当前有效文档。
10. 全量构建与增量更新
10.1 全量构建
入口在 main.py 的 build-suggestions --mode full。
行为是:
- 读取租户配置中的
index_languages和primary_language - 创建新版本索引并等待 ready
- 聚合商品数据和搜索日志,构造候选词
- 计算
rank_score - bulk 写入新索引
- refresh
- 发布 alias
- 更新 meta 信息
10.2 增量更新
入口在 main.py 的 build-suggestions --mode incremental。
当前增量只处理 query log,不回扫商品数据。它依赖 meta 中的 watermark:
last_incremental_watermark- 不存在时回退到
last_full_build_at - 再不行就使用
fallback_days
为了避免边界时间漏数,会额外减去 overlap_minutes,形成一个带重叠窗口的增量区间。
增量写入不是整文档重建,而是通过 scripted_upsert 做原地累加:
- 增加
query_count_30d - 增加
query_count_7d - 更新
lang_confidence/lang_source/lang_conflict - 重新计算
rank_score - 更新
completion和sat
对应逻辑在 <em>build_incremental</em>update_script()。
如果 alias 尚不存在,而 bootstrap_if_missing=True,增量任务会先自动做一次全量构建作为初始化。
11. 当前实现的一些取舍
11.1 优点
- 在线链路很短,没有 suggestion 后再查商品的放大成本
- 构建和发布流程清晰,支持零停机切换
- 商品侧和日志侧都采用流式处理,能控制内存占用
- completion + SAT 双路召回兼顾低延迟和补全能力
- 语言、热度、来源信息都保存在索引中,便于后续优化
11.2 现阶段边界
- 增量更新目前只增量处理 query log,商品标题、qanchor、tag 变更仍依赖全量构建刷新
rank_score目前只使用热度和商品字段覆盖度,没有接入点击、转化等行为质量信号- 文本归一化目前以
NFKC + lower + whitespace fold为主,尚未做更激进的跨语种归并策略 number_of_replicas=0更偏开发或成本优先配置,生产是否需要副本要结合集群策略评估
12. 常用命令
全量构建:
./scripts/build_suggestions.sh <tenant_id> --mode full
增量构建:
./scripts/build_suggestions.sh <tenant_id> --mode incremental
一键重建并验证:
./scripts/rebuild_suggestions.sh <tenant_id>
接口示例:
curl "http://localhost:6002/search/suggestions?q=shi&size=10&language=en" \
-H "X-Tenant-ID: 162"
更多操作细节见 RUNBOOK.md,故障排查看 TROUBLESHOOTING.md。