# Suggestion 模块说明 `suggestion/` 目录负责搜索框自动补全能力,当前实现只关注 suggestion 本身:离线构建建议词索引,在线根据输入前缀返回建议词列表。 这份 README 以当前代码实现为准,重点说明模块现状、关键设计、索引结构、构建发布方式,以及在线检索和排序细节。 ## 1. 当前能力边界 - 对外接口:`GET /search/suggestions` - 输入参数:`q`、`size`、`language` - Header:`X-Tenant-ID` - 返回内容:建议词列表 `suggestions[]` - 不做商品结果拼接,也不走二次商品查询链路 当前模块由三部分组成: 1. 离线构建:从商品索引和搜索日志构建 suggestion 文档 2. 索引发布:写入版本化索引,并通过 alias 原子切换 3. 在线查询:优先走 completion,必要时再走 `search_as_you_type` 兜底召回 ## 2. 目录与关键代码 - [builder.py](/data/saas-search/suggestion/builder.py):离线构建、增量更新、alias 发布、meta 状态维护 - [mapping.py](/data/saas-search/suggestion/mapping.py):suggestion 索引 settings 和 mappings 生成 - [service.py](/data/saas-search/suggestion/service.py):在线查询服务,负责语言归一化、双路召回、去重和最终排序 - [RUNBOOK.md](/data/saas-search/suggestion/RUNBOOK.md):构建、发布、验证操作说明 - [TROUBLESHOOTING.md](/data/saas-search/suggestion/TROUBLESHOOTING.md):常见问题排查 命令入口在 [main.py](/data/saas-search/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](/data/saas-search/suggestion/builder.py) 中统一定义: - 读别名:`{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}_current` - 版本索引:`{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}_v` - 元信息索引:`{ES_INDEX_NAMESPACE}search_suggestions_meta` 当前实现是“每租户一个 suggestion alias + 多个版本索引”的模式,而不是环境级共享大索引。 全量构建时的发布流程: 1. 创建新的版本化索引 2. 写入本次构建出的 suggestion 文档 3. 校验新索引可分配、可读 4. alias 原子切换到新索引 5. 清理旧版本索引,只保留最近若干份 6. 更新 `search_suggestions_meta` 元信息索引里记录: - `active_alias` - `active_index` - `last_full_build_at` - `last_incremental_build_at` - `last_incremental_watermark` 这些信息主要服务于增量更新和排障。 ## 5. Mapping 与索引字段 [mapping.py](/data/saas-search/suggestion/mapping.py) 会根据租户的 `index_languages` 动态生成字段。 ### 5.1 索引设置 - `number_of_shards = 1` - `number_of_replicas = 0` - `refresh_interval = 30s` 中文使用自定义 analyzer: - `index_ik`:`ik_max_word + lowercase + asciifolding` - `query_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_log` - `title_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 两类检索字段 1. `completion.` 2. `sat.` `completion.` 用于极速前缀补全,是短 query 下的主召回通道。 `sat.` 使用 `search_as_you_type`,用于多词前缀和 completion 未补足时的兜底召回。 也就是说,当前线上不是只靠一种召回方式,而是 completion 优先、SAT 补全。 ## 6. 候选词从哪里来 [builder.py](/data/saas-search/suggestion/builder.py) 在全量构建中会聚合两大类数据源。 ### 6.1 商品侧 从租户商品索引中流式读取: - `title` - `qanchors` - `enriched_tags` 处理方式: - `title.`:经 `_prepare_title_for_suggest()` 裁剪后作为候选词 - `qanchors.`:按分隔符拆分后作为候选词 - `enriched_tags`:支持多语言对象或普通列表,必要时做语言识别 商品扫描不是一次性全量拉入内存,而是通过 `search_after` 分批读取,这一点在 [_iter_products()](/data/saas-search/suggestion/builder.py#L363) 已实现。 ### 6.2 搜索日志侧 从 MySQL `shoplazza_search_log` 中按时间窗口流式读取: - `query` - `language` - `request_params` - `create_time` 读取方式使用 `stream_results=True + fetchmany()`,避免 `fetchall()` 带来的内存风险,这也是当前实现相对旧方案的重要改进。 搜索日志主要用于补充: - 用户真实搜索词 - 近 7/30 天热度 - 语言归属信息 ## 7. 文本清洗与语言策略 ### 7.1 文本归一化 在 [_normalize_text()](/data/saas-search/suggestion/builder.py#L176) 中,当前实现会做: - Unicode `NFKC` 归一化 - 去首尾空白 - 转小写 - 多空白折叠为单空格 这份 `text_norm` 是 suggestion 文档的稳定键的一部分,文档 `_id` 形式为: `{tenant_id}|{lang}|{text_norm}` 这保证了同租户、同语言、同一归一化词面只会保留一份文档。 ### 7.2 噪声过滤 在 [_looks_noise()](/data/saas-search/suggestion/builder.py#L264) 中,以下内容会被过滤: - 空文本 - 长度超过 120 - 全部由符号组成的文本 ### 7.3 语言判定优先级 日志 query 的语言归属由 [_resolve_query_language()](/data/saas-search/suggestion/builder.py#L299) 负责,优先级是: 1. `shoplazza_search_log.language` 2. `request_params.language` 3. `detect_text_language_for_suggestions()` 4. 租户 `primary_language` 同时会记录: - `lang_source`:语言来自哪里 - `lang_confidence`:识别置信度 - `lang_conflict`:日志语言与请求语言是否冲突 在线查询侧在 [_resolve_language()](/data/saas-search/suggestion/service.py#L24) 也会做一次语言归一化,确保查询只打到租户允许的 `index_languages`。 ## 8. 排序与 rank 细节 当前排序分成两层:离线 `rank_score`,以及在线最终排序。 ### 8.1 离线 `rank_score` 在 [_compute_rank_score()](/data/saas-search/suggestion/builder.py#L338) 中,当前公式是: ```text 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..weight` 因此它同时影响 completion 通道和 SAT 通道。 ### 8.2 在线召回排序 [service.py](/data/saas-search/suggestion/service.py) 中的在线策略如下: 1. 先查 `completion.` 2. 若 query 长度大于 2 且 completion 结果不足,再查 `sat.` 3. 按 `text` 归一化结果去重 4. 最终排序后截断 completion 通道本身依赖 ES completion 的 `_score`;SAT 通道则用 `function_score + field_value_factor(rank_score)`。 最终排序由 [_finalize_suggestion_list()](/data/saas-search/suggestion/service.py#L155) 负责,排序 key 为: 1. `score * 长度惩罚系数` 2. `rank_score` 长度惩罚定义在 [_suggestion_length_factor()](/data/saas-search/suggestion/service.py#L16): ```text length_factor = 1 / sqrt(token_len) ``` 这意味着在分数相近时,较短、较直接的 suggestion 会更容易排在前面,避免长尾长句把前缀补全结果“顶掉”。 ## 9. 在线查询细节 [SuggestionService.search()](/data/saas-search/suggestion/service.py#L110) 的行为可以概括为: - 如果 alias 不存在,直接返回空数组,不抛 500 - 短 query 优先走 completion 快速返回 - 对于更长 query,再补一次 `bool_prefix` - 查询时始终带 `routing=tenant_id` 这里的 `routing` 很重要,它保证 suggestion 查询尽量只落在目标租户对应的分片路由上,减少无效 fan-out。 SAT 查询部分还有两个显式过滤条件: - `lang == resolved_language` - `status == 1` 这能保证召回结果只来自当前语言、当前有效文档。 ## 10. 全量构建与增量更新 ### 10.1 全量构建 入口在 [main.py](/data/saas-search/main.py#L104) 的 `build-suggestions --mode full`。 行为是: 1. 读取租户配置中的 `index_languages` 和 `primary_language` 2. 创建新版本索引并等待 ready 3. 聚合商品数据和搜索日志,构造候选词 4. 计算 `rank_score` 5. bulk 写入新索引 6. refresh 7. 发布 alias 8. 更新 meta 信息 ### 10.2 增量更新 入口在 [main.py](/data/saas-search/main.py#L104) 的 `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` 对应逻辑在 [_build_incremental_update_script()](/data/saas-search/suggestion/builder.py#L834)。 如果 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. 常用命令 全量构建: ```bash ./scripts/build_suggestions.sh --mode full ``` 增量构建: ```bash ./scripts/build_suggestions.sh --mode incremental ``` 一键重建并验证: ```bash ./scripts/rebuild_suggestions.sh ``` 接口示例: ```bash curl "http://localhost:6002/search/suggestions?q=shi&size=10&language=en" \ -H "X-Tenant-ID: 162" ``` 更多操作细节见 [RUNBOOK.md](/data/saas-search/suggestion/RUNBOOK.md),故障排查看 [TROUBLESHOOTING.md](/data/saas-search/suggestion/TROUBLESHOOTING.md)。