# 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<timestamp>`
- 元信息索引：`{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.<lang>`
2. `sat.<lang>`

`completion.<lang>` 用于极速前缀补全，是短 query 下的主召回通道。

`sat.<lang>` 使用 `search_as_you_type`，用于多词前缀和 completion 未补足时的兜底召回。

也就是说，当前线上不是只靠一种召回方式，而是 completion 优先、SAT 补全。

## 6. 候选词从哪里来

[builder.py](/data/saas-search/suggestion/builder.py) 在全量构建中会聚合两大类数据源。

### 6.1 商品侧

从租户商品索引中流式读取：

- `title`
- `qanchors`
- `enriched_tags`

处理方式：

- `title.<lang>`：经 `_prepare_title_for_suggest()` 裁剪后作为候选词
- `qanchors.<lang>`：按分隔符拆分后作为候选词
- `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.<lang>.weight`

因此它同时影响 completion 通道和 SAT 通道。

### 8.2 在线召回排序

[service.py](/data/saas-search/suggestion/service.py) 中的在线策略如下：

1. 先查 `completion.<lang>`
2. 若 query 长度大于 2 且 completion 结果不足，再查 `sat.<lang>`
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 <tenant_id> --mode full
```

增量构建：

```bash
./scripts/build_suggestions.sh <tenant_id> --mode incremental
```

一键重建并验证：

```bash
./scripts/rebuild_suggestions.sh <tenant_id>
```

接口示例：

```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)。
