# Suggestion 设计文档 ## 文档导航 - `README.md`(本文):完整方案设计(架构、索引、构建、查询、验证) - `RUNBOOK.md`:日常运行手册(如何构建、如何回归、如何发布) - `TROUBLESHOOTING.md`:故障排查手册(空结果、tenant 丢失、ES 401、版本未生效等) 本文档定义 `search_suggestions` 独立索引方案,用于支持多语言自动补全(suggestion)与结果直达。 ## 1. 背景与目标 当前搜索系统已具备多语言商品索引(`title.{lang}`、`qanchors.{lang}`)与主搜索能力。为了实现输入中实时下拉 suggestion,需要新增一套面向“词”的能力。 核心目标: - 在不耦合主搜索链路的前提下,提供低延迟 suggestion(实时输入)。 - 支持多语言,按请求语言路由到对应 suggestion 语种。 - 支持“结果直达”:每条 suggestion 可附带候选商品列表(通过二次查询 `search_products` 完成)。 - 支持后续词级排序演进(行为信号、运营控制、去噪治理)。 非目标(当前阶段): - 不做个性化推荐(用户级 personalization)。 - 不引入复杂在线学习排序服务。 ## 2. 总体架构 采用双索引架构(支持多环境 namespace 前缀): - 商品索引:`{ES_INDEX_NAMESPACE}search_products_tenant_{tenant_id}` - 建议词索引:`{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}` 在线查询主路径: 1. 仅查询 `{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}` 得到 suggestion 列表。 2. 对每条 suggestion 进行“结果直达”的二次查询(`msearch`)到 `{ES_INDEX_NAMESPACE}search_products_tenant_{tenant_id}`: - 使用 suggestion 文本对 `title.{lang}` / `qanchors.{lang}` 执行 `term` / `match_phrase_prefix` 组合查询。 3. 回填每条 suggestion 的商品卡片列表(例如每条 3~5 个)。 ## 3. API 设计 建议保留并增强现有接口:`GET /search/suggestions` ### 3.1 请求参数 - `q` (string, required): 用户输入前缀 - `size` (int, optional, default=10, max=20): 返回 suggestion 数量 - `language` (string, required): 请求语言(如 `zh`, `en`, `ar`, `ru`) - `with_results` (bool, optional, default=true): 是否附带每条 suggestion 的直达商品 - `result_size` (int, optional, default=3, max=10): 每条 suggestion 附带商品条数 - `debug` (bool, optional, default=false): 是否返回调试信息 Header: - `X-Tenant-ID` (required) ### 3.2 响应结构 ```json { "query": "iph", "language": "en", "suggestions": [ { "text": "iphone 15", "lang": "en", "score": 12.37, "sources": ["query_log", "qanchor"], "products": [ { "spu_id": "12345", "title": "iPhone 15 Pro Max", "price": 999.0, "image_url": "https://..." } ] } ], "took_ms": 14, "debug_info": {} } ``` ## 4. 索引设计:`search_suggestions_tenant_{tenant_id}` 文档粒度:`tenant_id + lang + text_norm` 唯一一条文档。 ### 4.1 字段定义(建议) - `tenant_id` (`keyword`) - `lang` (`keyword`) - `text` (`keyword`):展示文本 - `text_norm` (`keyword`):归一化文本(去重键) - `sources` (`keyword[]`):来源集合,取值:`title` / `qanchor` / `query_log` - `title_doc_count` (`integer`):来自 title 的命中文档数 - `qanchor_doc_count` (`integer`):来自 qanchor 的命中文档数 - `query_count_7d` (`integer`):7 天搜索词计数 - `query_count_30d` (`integer`):30 天搜索词计数 - `rank_score` (`float`):离线计算总分 - `status` (`byte`):1=online, 0=offline - `updated_at` (`date`) 用于召回: - `completion` (`object`): - `completion.{lang}`: `completion` 类型(按语言设置 analyzer) - `sat` (`object`): - `sat.{lang}`: `search_as_you_type`(增强多词前缀效果) 可选字段(用于加速直达): - `top_spu_ids` (`keyword[]`):预计算商品候选 id ### 4.2 Mapping 样例(简化) ```json { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "tenant_id": { "type": "keyword" }, "lang": { "type": "keyword" }, "text": { "type": "keyword" }, "text_norm": { "type": "keyword" }, "sources": { "type": "keyword" }, "title_doc_count": { "type": "integer" }, "qanchor_doc_count": { "type": "integer" }, "query_count_7d": { "type": "integer" }, "query_count_30d": { "type": "integer" }, "rank_score": { "type": "float" }, "status": { "type": "byte" }, "updated_at": { "type": "date" }, "completion": { "properties": { "zh": { "type": "completion", "analyzer": "index_ansj", "search_analyzer": "query_ansj" }, "en": { "type": "completion", "analyzer": "english" }, "ar": { "type": "completion", "analyzer": "arabic" }, "ru": { "type": "completion", "analyzer": "russian" } } }, "sat": { "properties": { "zh": { "type": "search_as_you_type", "analyzer": "index_ansj" }, "en": { "type": "search_as_you_type", "analyzer": "english" }, "ar": { "type": "search_as_you_type", "analyzer": "arabic" }, "ru": { "type": "search_as_you_type", "analyzer": "russian" } } }, "top_spu_ids": { "type": "keyword" } } } } ``` 说明:实际支持语种需与 `search_products` 已支持语种保持一致。 ## 5. 全量建索引逻辑(核心) 全量程序职责:扫描商品 `title/qanchors` 与搜索日志 `query`,聚合后写入 `search_suggestions`。 输入: - `{ES_INDEX_NAMESPACE}search_products_tenant_{tenant_id}` 文档 - MySQL 表:`shoplazza_search_log` 输出: - `{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}` 全量文档 ### 5.1 流程 1. 创建/重建 `{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}`。 2. 遍历 `{ES_INDEX_NAMESPACE}search_products_tenant_{tenant_id}`(`scroll` 或 `search_after`): - 提取每个商品的 `title.{lang}`、`qanchors.{lang}`。 - 归一化文本(NFKC、trim、lower、空白折叠)。 - 产出候选词并累加: - `title_doc_count += 1` - `qanchor_doc_count += 1` - `sources` 加来源。 3. 读取日志: - SQL 拉取 `tenant_id` 下时间窗数据(如 30 天)。 - 对每条 `query` 解析语言归属(优先 `shoplazza_search_log.language`,其次 `request_params.language`,见第 6 节)。 - 累加 `query_count_7d` / `query_count_30d`,`sources` 加 `query_log`。 4. 清洗与过滤: - 去空、去纯符号、长度阈值过滤。 - 可选黑名单过滤(运营配置)。 5. 计算 `rank_score`(见第 7 节)。 6. 组装文档: - 写 `completion.{lang}` + `sat.{lang}`。 - `_id = md5(tenant_id|lang|text_norm)`。 7. 批量写入(bulk upsert)。 ### 5.2 伪代码 ```python for tenant_id in tenants: agg = {} # key: (lang, text_norm) for doc in scan_es_products(tenant_id): for lang in index_languages(tenant_id): add_from_title(agg, doc.title.get(lang), lang, doc.spu_id) add_from_qanchor(agg, doc.qanchors.get(lang), lang, doc.spu_id) for row in fetch_search_logs(tenant_id, days=30): lang, conf = resolve_query_lang( query=row.query, log_language=row.language, request_params_json=row.request_params, tenant_id=tenant_id ) if not lang: continue add_from_query_log(agg, row.query, lang, row.create_time) docs = [] for (lang, text_norm), item in agg.items(): if not pass_filters(item): continue item.rank_score = compute_rank_score(item) docs.append(to_suggestion_doc(tenant_id, lang, item)) bulk_upsert(index=f"{ES_INDEX_NAMESPACE}search_suggestions_tenant_{tenant_id}", docs=docs) ``` ## 6. 日志语言解析策略(已新增 language 字段) 现状:`shoplazza_search_log` 已新增 `language` 字段,且 `request_params`(JSON)中也包含 `language`。 因此全量程序不再以“纯离线识别”为主,而是采用“日志显式语言优先”的三级策略。 ### 6.1 语言解析优先级 1. **一级:`shoplazza_search_log.language`(最高优先级)** - 若值存在且合法,直接作为 query 归属语言。 2. **二级:`request_params.language`(JSON 兜底)** - 当表字段为空/非法时,解析 `request_params` JSON 中的 `language`。 3. **三级:离线识别(最后兜底)** - 仅在前两者都缺失时启用: - 脚本直判(CJK/Arabic/Cyrillic) - 轻量语言识别器(拉丁语) ### 6.2 一致性校验(推荐) 当 `shoplazza_search_log.language` 与 `request_params.language` 同时存在但不一致时: - 默认采用 `shoplazza_search_log.language` - 记录 `lang_conflict=true` 用于审计 - 输出监控指标(冲突率) ### 6.3 置信度与约束 对于一级/二级来源: - `lang_confidence=1.0` - `lang_source=log_field` 或 `lang_source=request_params` 对于三级离线识别: - `confidence >= 0.8`:写入 top1 - `0.5 <= confidence < 0.8`:写入 top1(必要时兼容 top2 降权) - `< 0.5`:写入租户 `primary_language`(降权) 统一约束: - 最终写入语言必须属于租户 `index_languages` 建议额外存储: - `lang_confidence`(float) - `lang_source`(`log_field`/`request_params`/`script`/`model`/`default`) - `lang_conflict`(bool) 便于后续质量审计与数据回溯。 ## 7. 排序分数设计(离线) 建议采用可解释线性组合: ```text rank_score = w1 * log1p(query_count_30d) + w2 * log1p(query_count_7d) + w3 * log1p(qanchor_doc_count) + w4 * log1p(title_doc_count) + w5 * business_bonus ``` 推荐初始权重(可配置): - `w1=1.8`, `w2=1.2`, `w3=1.0`, `w4=0.6`, `w5=0.3` 说明: - 搜索日志信号优先级最高(最接近真实用户意图)。 - `qanchor` 高于 `title`(更偏 query 风格)。 - `business_bonus` 可接入销量、库存可售率等轻量业务信号。 ## 8. 在线查询逻辑(suggestion) 主路径只查 `search_suggestions`。 ### 8.1 Suggestion 查询 DSL(示例) ```json { "size": 10, "query": { "function_score": { "query": { "bool": { "filter": [ { "term": { "lang": "en" } }, { "term": { "status": 1 } } ], "should": [ { "multi_match": { "query": "iph", "type": "bool_prefix", "fields": [ "sat.en", "sat.en._2gram", "sat.en._3gram" ] } } ], "minimum_should_match": 1 } }, "field_value_factor": { "field": "rank_score", "factor": 1.0, "modifier": "log1p", "missing": 0 }, "boost_mode": "sum", "score_mode": "sum" } }, "_source": [ "text", "lang", "rank_score", "sources", "top_spu_ids" ] } ``` 可选:completion 方式(极低延迟)也可作为同接口内另一条召回通道,再与上面结果融合去重。 ## 9. 结果直达(二次查询) `with_results=true` 时,对每条 suggestion 的 `text` 做二次查询到 `search_products_tenant_{tenant_id}`。 推荐使用 `msearch`,每条 suggestion 一个子查询: - `term`(精确)命中 `qanchors.{lang}.keyword`(若存在 keyword 子字段) - `match_phrase_prefix` 命中 `title.{lang}` - 可加权:`qanchors` 命中权重高于 `title` - 每条 suggestion 返回 `result_size` 条商品 若未来希望进一步降在线复杂度,可改为离线写入 `top_spu_ids` 并在在线用 `mget` 回填。 ## 10. 数据治理与运营控制 建议加入以下机制: - 黑名单词:人工屏蔽垃圾词、敏感词 - 白名单词:活动词、品牌词强制保留 - 最小阈值:低频词不过线(例如 `query_count_30d < 2` 且无 qanchor/title 支撑) - 去重规则:`text_norm` 维度强去重 - 更新策略:每日全量 + 每小时增量(后续) ## 11. 实施里程碑 M1(快速上线): - 建 `search_suggestions` 索引 - 全量程序:`title + qanchors + query_log` - `/search/suggestions` 仅查 suggestion,不带直达 M2(增强): - 增加二次查询直达商品(`msearch`) - 引入语言置信度审计报表 - 加黑白名单与去噪配置 M3(优化): - completion + bool_prefix 双通道融合 - 增量构建任务(小时级) - 排序参数在线配置化 ## 12. 关键风险与规避 - 日志语言字段质量问题导致错写:通过 `log_field > request_params > model` 三级策略与冲突审计规避 - 高频噪声词上浮:黑名单 + 最小阈值 + 分数截断 - 直达二次查询成本上升:控制 `size/result_size`,优先 `msearch` - 多语言字段不一致:统一语言枚举与映射生成逻辑,避免手写散落 --- ## 13. 实验与验证建议 以租户 `tenant_id=171` 为例,推荐如下验证流程(其它租户 / 环境同理,可通过 ES_INDEX_NAMESPACE 区分 prod / uat / test): ### 13.1 构建索引 ```bash ./scripts/build_suggestions.sh 171 --days 30 --recreate ``` 期望 CLI 输出类似(prod 环境,ES_INDEX_NAMESPACE 为空): ```json { "tenant_id": "171", "index_name": "search_suggestions_tenant_171", "total_candidates": 61, "indexed_docs": 61, "bulk_result": { "success": 61, "failed": 0, "errors": [] } } ``` 含义: - `total_candidates`:聚合到的词候选总数(按 `(lang,text_norm)` 去重) - `indexed_docs`:实际写入 ES 的文档数(通常与 `total_candidates` 相同) - `bulk_result`:bulk 写入统计 ### 13.2 检查索引结构 ```bash # prod / 本地环境:ES_INDEX_NAMESPACE 为空 curl "http://localhost:9200/search_suggestions_tenant_171/_mapping?pretty" curl "http://localhost:9200/search_suggestions_tenant_171/_count?pretty" curl "http://localhost:9200/search_suggestions_tenant_171/_search?size=5&pretty" -d '{ "query": { "match_all": {} } }' # UAT 环境:假设 ES_INDEX_NAMESPACE=uat_ curl "http://localhost:9200/uat_search_suggestions_tenant_171/_mapping?pretty" curl "http://localhost:9200/uat_search_suggestions_tenant_171/_count?pretty" curl "http://localhost:9200/uat_search_suggestions_tenant_171/_search?size=5&pretty" -d '{ "query": { "match_all": {} } }' ``` 重点确认: - 是否存在 `lang/text/text_norm/sources/rank_score/completion/sat` 等字段。 - 文档中 `lang` 是否只落在租户配置的 `index_languages` 范围内。 - 常见 query(如你期望的热词)是否有对应文档,`query_count_*` 是否大致正确。 ### 13.3 通过 API 验证 suggestion 行为 启动后端: ```bash python main.py serve --es-host http://localhost:9200 --port 6002 ``` 示例调用(中文): ```bash curl "http://localhost:6002/search/suggestions?q=玩具&size=5&language=zh&with_results=true" \ -H "X-Tenant-ID: 171" ``` 示例调用(英文): ```bash curl "http://localhost:6002/search/suggestions?q=iph&size=5&language=en&with_results=true" \ -H "X-Tenant-ID: 171" ``` 预期: - `resolved_language` 与传入 `language` 一致或回落到租户主语言。 - 返回若干 `suggestions[]`,每条包含: - `text/lang/score/rank_score/sources` - `products[]` 为直达商品(数量由 `result_size` 控制)。 如需进一步排查,可对比: - 某个 suggestion 的 `text` 与 `shoplazza_search_log.query` 的出现频次。 - 该 suggestion 的 `products` 是否与主搜索接口 `POST /search/` 对同 query 的 topN 结果大体一致。 ### 13.4 语言归属与多语言检查 挑选典型场景: - 纯中文 query(如商品中文标题)。 - 纯英文 query(如品牌/型号)。 - 混合或无明显语言的 query。 验证点: - 文档 `lang` 与期望语言是否匹配。 - `lang_source` 是否按优先级反映来源: - `log_field` > `request_params` > `script/model/default` - 如存在 `lang_conflict=true` 的案例,采样检查日志中 `language` 与 `request_params.language` 是否存在冲突。 ## 14. 自动化测试建议 已提供基础单元测试(见 `tests/test_suggestions.py`): - 语言解析逻辑: - `test_resolve_query_language_prefers_log_field` - `test_resolve_query_language_uses_request_params_when_log_missing` - `test_resolve_query_language_fallback_to_primary` - 在线查询逻辑: - `test_suggestion_service_basic_flow`:使用 `FakeESClient` 验证 suggestion + 结果直达商品整体流程。 推荐在本地环境中执行: ```bash pytest tests/test_suggestions.py -q ``` 后续可根据业务需要补充: - 排序正确性测试(构造不同 `query_count_*`、`title/qanchor_doc_count`)。 - 多语言覆盖测试(zh/en/ar/ru 等,结合租户 `index_languages`)。 - 简单性能回归(单次查询时延、QPS 与 P95/P99 录制)。 本设计优先保证可落地与可演进:先以独立 suggestion 索引跑通主能力,再逐步增强排序与在线性能。