Python后端中间件专题19“相关”究竟怎么算——boost、search_after 与聚合客服搜索“VPN 登录”第一条却是正文长篇提到 VPN 的普通工单标题直接写“VPN 登录故障”的工单排在后面。产品经理说“相关性不对”但后端不能只回答“ES 算的”。我们要能指出哪些字段参加评分、翻到第二页使用哪组排序值、筛选后的统计到底统计了谁。第 18 课已经把租户和状态塞进不计分的filter。本课只改读模型的三件事标题更重要、结果用游标继续取、页面显示当前查询范围内的状态/优先级计数。工单事实和写事务一个字也不用改变。上一课练习答案答案 EX-18-01 [code]把下面保存为query_guard_probe.py从project/运行。这里直接调用生产查询构造器如果有人把空关键词分支里的租户条件丢了两次断言不会一起放过。fromticketflow.search.queryimportSearchFilters,build_search_body with_wordsbuild_search_body(tenant_idtenant-a,query VPN 登录 ,filtersSearchFilters(statuses(open,),priorityhigh),size10,)without_wordsbuild_search_body(tenant_idtenant-a,query ,filtersSearchFilters(),size10,)tenant_filter{term:{tenant_id:tenant-a}}assertwith_words[query][bool][filter][0]tenant_filterassertwithout_words[query][bool][filter][0]tenant_filterassertwith_words[query][bool][must][0][multi_match][query]VPN 登录assertwithout_words[query][bool][must][{match_all:{}}]print(wordsmulti_match:VPN 登录)print(emptymatch_all; tenanttenant-a)从project/运行命令python query_guard_probe.py预期输出wordsmulti_match:VPN 登录 emptymatch_all; tenanttenant-a生产版本在本课会对 title/tags 加权所以这里只断言“查询类型与词项”不把字段权重写死在上课答案里。无论权重如何调租户条件都必须稳定。答案 EX-18-02 [prose]多个文档的updated_at相同若只按它排序ES 可以在不同请求之间以不同内部顺序返回它们追加映射为keyword的唯一ticket_id作为 tie-breaker使同一份静态索引、同一查询条件下有可重复的全序。不要改回_idElasticsearch 8 并不把内部_id暴露成普通可排序字段。但排序键不能冻结索引。翻页期间有新工单写入、旧工单更新分数和时间都可能改变search_after在两次请求里并不自动保存同一快照仍可能重/漏。生产级深分页应固定同一索引生成并使用 Point in TimePIT或其他快照机制还要管理 PIT 生命周期。当前 TicketFlow 教学模块提供的是有界search_after和显式稳定排序不假装已经实现跨更新的快照一致性。相关性是可以解释和验证的决策第 18 课查询字段是title/body现在变成{multi_match:{query:VPN 登录,fields:[title^3,tags^2,body],type:best_fields,operator:and}}^3表示标题在该查询中更重^2让标签次之正文为基准。这并非“标题命中必然第一”的保证分词、词频、字段长度、稀有度和过滤范围仍影响实际_score。真正调参要准备人工标注的代表性查询和期望前几名记录改动前后结果不能只凭一张工单调出“感觉不错”的权重。tags在 Mapping 里是keyword。把它纳入 multi_match 的含义是匹配完整标签词项不是让一个长标签像正文那样分词如果产品要标签模糊匹配应该另设分析字段并验证不能悄悄修改租户/状态等精确字段。为什么from100000不适合翻到很后面传统from size让 ES 为每个分片收集并排序前面大量被跳过的命中。深分页成本随 offset 放大而且更新发生时页边界会漂移。search_after则取上一页最后一条的排序三元组_score desc updated_at desc ticket_id asc 1.75 2026-09-21T08:30:0000:00 ticket-7下一页将这个三元组原样带回查询与排序必须保持一致。TicketFlow 把 JSON 数组做 URL-safe Base64 编码使客户端不必拼 ES DSLfromticketflow.search.queryimportSearchFilters,build_search_body,decode_cursor,encode_cursor cursorencode_cursor([1.75,2026-09-21T08:30:0000:00,ticket-7])assertdecode_cursor(cursor)[1.75,2026-09-21T08:30:0000:00,ticket-7]next_pagebuild_search_body(tenant_idtenant-a,queryVPN,filtersSearchFilters(),size10,cursorcursor,)assertnext_page[search_after]decode_cursor(cursor)print(search_after3 sort values)先保存上述完整片段再从project/运行命令python cursor_probe.py。预期输出search_after3 sort valuesBase64 只是编码不是签名或加密。客户端能改它服务端仍要验证游标形状、强制租户过滤、限制 size。当前decode_cursor只做基础结构校验高风险公网接口还应收紧字段类型与长度、把查询指纹/索引代数绑定到游标必要时签名。尤其 alias 切换后旧游标不能理所当然沿用。聚合到底统计了谁本课查询体同时包含aggs:{status_counts:{terms:{field:status,size:10}},priority_counts:{terms:{field:priority,size:10}}}聚合继承同一个query的词项和过滤范围因此绝不会统计其他租户。但是如果用户已经过滤statusopenstatus_counts也只看到 open。若产品希望选中 open 后依然显示“其他状态各有多少”需要把某些 UI 过滤移动到post_filter、同时保留不可移除的租户边界。那是另一种产品语义不应凭直觉改 DSL。本课先选择“当前结果集统计”并把这一点写在 UI 说明里。TicketFlow 不再停在“请求体里有aggs”。ElasticsearchHTTP.search()会把 ES 的status_counts、priority_counts验证并转换为SearchFacetsFastAPI 响应再输出稳定的key/count列表。例如{total:9,items:[],next_cursor:null,facets:{statuses:[{key:open,count:7},{key:pending,count:2}],priorities:[{key:high,count:4}]}}适配器不会把缺少doc_count的畸形桶悄悄当作 0它会报告SearchUnavailable避免页面展示一组看似可信的假统计。API 测试同时断言聚合桶确实穿过适配器和响应模型而不只是验证 DSL 长得像。验收与可见边界从课程根运行本课 revision verifierpython tools/verify_executable_revisions.py--lesson 19--python python此命令在全新 Python 3.11 venv 安装本课独立 revision并运行 baseline 与 09–19 的全部历史合同不会借用最终project/。它仍不能证明 ES 8 接受排序并真正分页。真实服务检查点test_real_elasticsearch_supports_ticket_id_search_after_and_facets必须单独通过project/中的python scripts/run_remote_selector.py --checkpoint 19 --selector tests/integration/test_search_elasticsearch.py::test_real_elasticsearch_supports_ticket_id_search_after_and_facets运行未获授权或未运行仍是 PENDING。遇到“第二页重复/漏掉”先记录相同查询参数、三元组、索引 generation 和翻页期间更新遇到“状态统计只有 open”先问产品要的是当前结果集统计还是所有状态的可选 facet遇到“标题仍排后面”拿同一组样本比较_explain/评分不是盲目把^3加到^100。本课练习练习 EX-19-01 [code]写cursor_guard_probe.py把[2.5, 2026-09-21T09:00:0000:00, ticket-9]编码再解码传进build_search_body()并断言search_after原样保留另传一个无效游标断言抛InvalidCursor。打印cursor round-trip ok和invalid cursor rejected。第 20 课给出完整答案。练习 EX-19-02 [prose]同一个 alias 正在从 v1 切到 v2用户带着 v1 页面末尾的游标请求下一页。说明为什么即使游标解码成功结果也未必连续分别给出产品上可接受的“重新开始搜索”和技术上更强的 PIT/索引 generation 绑定方案。接下来要追哪条链排序和游标都建立在“索引里已经有正确版本”之上。第 20 课会让 version 3 先到、version 2 后到再看 refresh 前后搜索可见性创建工单成功和搜索立刻命中从来不是同一个事务承诺。本课完整核心模块search/query.py这是lesson-19的完整查询构造器包含本课新增的 boost、游标和聚合。注意完整模块同步的是教学 revision而不是后来可能继续演进的最终源码。from__future__importannotationsimportbase64fromdataclassesimportdataclassimportjsonclassInvalidCursor(ValueError):The opaque search-after cursor failed structural validation.dataclass(frozenTrue,slotsTrue)classSearchFilters:statuses:tuple[str,...]()priority:str|NoneNonetags:tuple[str,...]()defencode_cursor(sort_values:list[object])-str:rawjson.dumps(sort_values,ensure_asciiFalse,separators(,,:)).encode()returnbase64.urlsafe_b64encode(raw).decode().rstrip()defdecode_cursor(cursor:str)-list[object]:try:paddedcursor*(-len(cursor)%4)valuejson.loads(base64.urlsafe_b64decode(padded).decode())except(ValueError,UnicodeError,json.JSONDecodeError)asexc:raiseInvalidCursor(cursor is not valid)fromexcifnotisinstance(value,list)orlen(value)!3:raiseInvalidCursor(cursor is not valid)returnvaluedefbuild_search_body(*,tenant_id:str,query:str,filters:SearchFilters,size:int,cursor:str|NoneNone,)-dict[str,object]:Build a bounded search request with a mandatory tenant boundary.ifnot1size100:raiseValueError(size must be between 1 and 100)exact_filters:list[dict[str,object]][{term:{tenant_id:tenant_id}}]iffilters.statuses:exact_filters.append({terms:{status:list(filters.statuses)}})iffilters.priorityisnotNone:exact_filters.append({term:{priority:filters.priority}})iffilters.tags:exact_filters.append({terms:{tags:list(filters.tags)}})normalized_queryquery.strip()must:list[dict[str,object]]([{multi_match:{query:normalized_query,fields:[title^3,tags^2,body],type:best_fields,operator:and,}}]ifnormalized_queryelse[{match_all:{}}])body:dict[str,object]{query:{bool:{filter:exact_filters,must:must}},sort:[{_score:desc},{updated_at:desc},{ticket_id:asc},],highlight:{fields:{title:{},body:{}}},size:size,track_total_hits:True,# Aggregations inherit the same query/filter scope as hits. In particular,# a facet can never count documents from a different tenant.aggs:{status_counts:{terms:{field:status,size:10}},priority_counts:{terms:{field:priority,size:10}},},}ifcursorisnotNone:body[search_after]decode_cursor(cursor)returnbody