From 317c5d2c61e172bd952b54da7eb9075e5a278e17 Mon Sep 17 00:00:00 2001 From: tangwang Date: Wed, 15 Apr 2026 08:02:21 +0800 Subject: [PATCH] feat(search): 引入 exact vector rescore 为 topN 补全精确向量分,解决 rerank 阶段部分文档缺失 text/image knn 分数的问题 --- config/config.yaml | 2 ++ config/loader.py | 6 ++++++ config/schema.py | 3 +++ docs/issues/a | 0 docs/issues/issue-2026-04-12-test-env.md | 27 +++++++++++++++++++++++++++ docs/issues/issue-2026-04-14-粗排流程放入ES-TODO-env | 25 +++++++++++++++++++++++++ search/rerank_client.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- search/searcher.py | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_rerank_client.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_search_rerank_window.py | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 10 files changed, 461 insertions(+), 5 deletions(-) delete mode 100644 docs/issues/a create mode 100644 docs/issues/issue-2026-04-14-粗排流程放入ES-TODO-env diff --git a/config/config.yaml b/config/config.yaml index 5a94a65..465bf65 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -324,6 +324,8 @@ fine_rank: rerank: enabled: true rerank_window: 160 + exact_knn_rescore_enabled: true + exact_knn_rescore_window: 160 timeout_sec: 15.0 weight_es: 0.4 weight_ai: 0.6 diff --git a/config/loader.py b/config/loader.py index cbe635d..919c725 100644 --- a/config/loader.py +++ b/config/loader.py @@ -608,6 +608,12 @@ class AppConfigLoader: rerank=RerankConfig( enabled=bool(rerank_cfg.get("enabled", True)), rerank_window=int(rerank_cfg.get("rerank_window", 384)), + exact_knn_rescore_enabled=bool( + rerank_cfg.get("exact_knn_rescore_enabled", False) + ), + exact_knn_rescore_window=int( + rerank_cfg.get("exact_knn_rescore_window", 0) + ), timeout_sec=float(rerank_cfg.get("timeout_sec", 15.0)), weight_es=float(rerank_cfg.get("weight_es", 0.4)), weight_ai=float(rerank_cfg.get("weight_ai", 0.6)), diff --git a/config/schema.py b/config/schema.py index cbd4328..72d81c3 100644 --- a/config/schema.py +++ b/config/schema.py @@ -176,6 +176,9 @@ class RerankConfig: enabled: bool = True rerank_window: int = 384 + exact_knn_rescore_enabled: bool = False + #: topN exact vector scoring window; <=0 means "follow rerank_window" + exact_knn_rescore_window: int = 0 timeout_sec: float = 15.0 weight_es: float = 0.4 weight_ai: float = 0.6 diff --git a/docs/issues/a b/docs/issues/a deleted file mode 100644 index e69de29..0000000 --- a/docs/issues/a +++ /dev/null diff --git a/docs/issues/issue-2026-04-12-test-env.md b/docs/issues/issue-2026-04-12-test-env.md index 60c86e4..42cdd48 100644 --- a/docs/issues/issue-2026-04-12-test-env.md +++ b/docs/issues/issue-2026-04-12-test-env.md @@ -14,3 +14,30 @@ nohup bash scripts/start_embedding_service.sh > log.start_embedding_service.0412 看他陪的文本是用的哪套方案、哪个模型,跟他对齐(我指的是当前的测试分支) + + + + + +我在这个机器上部署了一个测试环境: +120.76.41.98 端口22 用户名和密码: +tw twtw@123 (有sudo权限) +cd /home/tw/saas-search +$ git branch + masters RETURN) +* test/small-gpu-es9 + +我希望差异只是: +1. es配置不同(测试环境要连接到那台机器的一个docer的es 19200端口)、redis配置不同 +2. reranker关闭、不要启动reranker服务 + +其余没什么不同。 + +但是启动有问题,现在翻译报错。 +这体现了当前项目移植性比较差,我希望你检查一下失败原因,然后先到本地(本机 即当前目录master分支)优化好、提升移植性之后,那边更新,保持测试分支跟master只有少量的、配置层面的不同,让后到测试机器把翻译启动起来,最后包括整个服务都要启动起来。 + + + + + + diff --git a/docs/issues/issue-2026-04-14-粗排流程放入ES-TODO-env b/docs/issues/issue-2026-04-14-粗排流程放入ES-TODO-env new file mode 100644 index 0000000..336fe02 --- /dev/null +++ b/docs/issues/issue-2026-04-14-粗排流程放入ES-TODO-env @@ -0,0 +1,25 @@ +需求: +目前160条结果(rerank_window: 160)会进入重排,重排中 文本和图片向量的相关性,都会作为融合公式的因子之一(粗排和reranker都有): +knn_score +text_knn +image_knn +text_factor +knn_factor +但是文本向量召回和图片向量召回,是使用 KNN 索引召回的方式,并不是所有结果都有这两个得分,这两项得分都有为0的。 +为了解决这个问题,有一个方法是对最终能进入重排的 160 条,看其中还有哪些分别缺失文本和图片向量召回的得分,再通过某种方式让 ES 去算,或者从 ES 把向量拉回来,自己算,或者在召回的时候请求 ES 的时候,就通过某种设定,确保前面的若干条都带有这两个分数,不知道有哪些方法,我感觉这些方法都不太好,请你思考一下 + +考虑的一个方案: +想在“第一次 ES 搜索”里,只对 topN 补向量精算,考虑 rescore 或 retriever.rescorer的方案(官方明确支持多段 rescore/支持 score_mode: multiply,甚至示例里就有 function_score/script_score 放进 rescore 的写法。) +这意味着你完全可以: +初检仍然用现在的 lexical + text knn + image knn 召回候选 +对 window_size=160 做 rescore +用 exact script_score 给 top160 补 text/image vector 分 +顺手把你现在本地 coarse 融合迁回 ES + +export ES_AUTH="saas:4hOaLaf41y2VuI8y" +export ES="http://127.0.0.1:9200" +"index":"search_products_tenant_163" + +有个细节暴露出来了:dotProduct() 这类向量函数在 script_score 评分上下文能用,但在 script_fields 取字段上下文里不认。所以如果我们要把 exact 分顺手回传给 rerank,用 script_fields 的话得自己写数组循环,不能直接调向量内建函数。 + +重排打分公式需要的base_query base_query_trans_zh knn_query image_knn_query还能不能拿到?请你考虑,尽量想想如何得到这些打分,如果实在拿不到去想替代的办法比如简化打分公式。 diff --git a/search/rerank_client.py b/search/rerank_client.py index 0b19f95..aa9b2c6 100644 --- a/search/rerank_client.py +++ b/search/rerank_client.py @@ -153,12 +153,59 @@ def _extract_named_query_score(matched_queries: Any, name: str) -> float: return 0.0 +def _resolve_named_query_score( + matched_queries: Any, + *, + preferred_names: List[str], + fallback_names: List[str], +) -> Tuple[float, Optional[str], float, Optional[str]]: + preferred_score = 0.0 + preferred_name: Optional[str] = None + for name in preferred_names: + score = _extract_named_query_score(matched_queries, name) + if score > 0.0: + preferred_score = score + preferred_name = name + break + + fallback_score = 0.0 + fallback_name: Optional[str] = None + for name in fallback_names: + score = _extract_named_query_score(matched_queries, name) + if score > 0.0: + fallback_score = score + fallback_name = name + break + + if preferred_name is None and preferred_names: + preferred_name = preferred_names[0] + preferred_score = _extract_named_query_score(matched_queries, preferred_name) + if fallback_name is None and fallback_names: + fallback_name = fallback_names[0] + fallback_score = _extract_named_query_score(matched_queries, fallback_name) + if preferred_score > 0.0: + return preferred_score, preferred_name, fallback_score, fallback_name + return fallback_score, fallback_name, preferred_score, preferred_name + + def _collect_knn_score_components( matched_queries: Any, fusion: RerankFusionConfig, ) -> Dict[str, float]: - text_knn_score = _extract_named_query_score(matched_queries, "knn_query") - image_knn_score = _extract_named_query_score(matched_queries, "image_knn_query") + text_knn_score, text_knn_source, _, _ = _resolve_named_query_score( + matched_queries, + preferred_names=["exact_text_knn_query"], + fallback_names=["knn_query"], + ) + image_knn_score, image_knn_source, _, _ = _resolve_named_query_score( + matched_queries, + preferred_names=["exact_image_knn_query"], + fallback_names=["image_knn_query"], + ) + exact_text_knn_score = _extract_named_query_score(matched_queries, "exact_text_knn_query") + exact_image_knn_score = _extract_named_query_score(matched_queries, "exact_image_knn_query") + approx_text_knn_score = _extract_named_query_score(matched_queries, "knn_query") + approx_image_knn_score = _extract_named_query_score(matched_queries, "image_knn_query") weighted_text_knn_score = text_knn_score * float(fusion.knn_text_weight) weighted_image_knn_score = image_knn_score * float(fusion.knn_image_weight) @@ -171,6 +218,14 @@ def _collect_knn_score_components( return { "text_knn_score": text_knn_score, "image_knn_score": image_knn_score, + "exact_text_knn_score": exact_text_knn_score, + "exact_image_knn_score": exact_image_knn_score, + "approx_text_knn_score": approx_text_knn_score, + "approx_image_knn_score": approx_image_knn_score, + "text_knn_source": text_knn_source, + "image_knn_source": image_knn_source, + "approx_text_knn_source": "knn_query", + "approx_image_knn_source": "image_knn_query", "weighted_text_knn_score": weighted_text_knn_score, "weighted_image_knn_score": weighted_image_knn_score, "primary_knn_score": primary_knn_score, @@ -322,6 +377,10 @@ def _build_ltr_feature_block( "text_support_score": float(text_components["support_text_score"]), "text_knn_score": text_knn_score, "image_knn_score": image_knn_score, + "exact_text_knn_score": float(knn_components["exact_text_knn_score"]), + "exact_image_knn_score": float(knn_components["exact_image_knn_score"]), + "approx_text_knn_score": float(knn_components["approx_text_knn_score"]), + "approx_image_knn_score": float(knn_components["approx_image_knn_score"]), "knn_primary_score": float(knn_components["primary_knn_score"]), "knn_support_score": float(knn_components["support_knn_score"]), "has_text_match": source_score > 0.0, @@ -433,6 +492,8 @@ def coarse_resort_hits( hit["_knn_score"] = knn_score hit["_text_knn_score"] = knn_components["text_knn_score"] hit["_image_knn_score"] = knn_components["image_knn_score"] + hit["_exact_text_knn_score"] = knn_components["exact_text_knn_score"] + hit["_exact_image_knn_score"] = knn_components["exact_image_knn_score"] hit["_coarse_score"] = coarse_score if debug: @@ -460,6 +521,12 @@ def coarse_resort_hits( ), "text_knn_score": knn_components["text_knn_score"], "image_knn_score": knn_components["image_knn_score"], + "exact_text_knn_score": knn_components["exact_text_knn_score"], + "exact_image_knn_score": knn_components["exact_image_knn_score"], + "approx_text_knn_score": knn_components["approx_text_knn_score"], + "approx_image_knn_score": knn_components["approx_image_knn_score"], + "text_knn_source": knn_components["text_knn_source"], + "image_knn_source": knn_components["image_knn_source"], "weighted_text_knn_score": knn_components["weighted_text_knn_score"], "weighted_image_knn_score": knn_components["weighted_image_knn_score"], "knn_primary_score": knn_components["primary_knn_score"], @@ -557,6 +624,8 @@ def fuse_scores_and_resort( hit["_knn_score"] = knn_score hit["_text_knn_score"] = knn_components["text_knn_score"] hit["_image_knn_score"] = knn_components["image_knn_score"] + hit["_exact_text_knn_score"] = knn_components["exact_text_knn_score"] + hit["_exact_image_knn_score"] = knn_components["exact_image_knn_score"] hit["_fused_score"] = fused hit["_style_intent_selected_sku_boost"] = style_boost @@ -589,6 +658,12 @@ def fuse_scores_and_resort( "text_support_score": text_components["support_text_score"], "text_knn_score": knn_components["text_knn_score"], "image_knn_score": knn_components["image_knn_score"], + "exact_text_knn_score": knn_components["exact_text_knn_score"], + "exact_image_knn_score": knn_components["exact_image_knn_score"], + "approx_text_knn_score": knn_components["approx_text_knn_score"], + "approx_image_knn_score": knn_components["approx_image_knn_score"], + "text_knn_source": knn_components["text_knn_source"], + "image_knn_source": knn_components["image_knn_source"], "weighted_text_knn_score": knn_components["weighted_text_knn_score"], "weighted_image_knn_score": knn_components["weighted_image_knn_score"], "knn_primary_score": knn_components["primary_knn_score"], @@ -744,6 +819,8 @@ def run_lightweight_rerank( hit["_knn_score"] = knn_score hit["_text_knn_score"] = signal_bundle["knn_components"]["text_knn_score"] hit["_image_knn_score"] = signal_bundle["knn_components"]["image_knn_score"] + hit["_exact_text_knn_score"] = signal_bundle["knn_components"]["exact_text_knn_score"] + hit["_exact_image_knn_score"] = signal_bundle["knn_components"]["exact_image_knn_score"] hit["_style_intent_selected_sku_boost"] = style_boost if debug: diff --git a/search/searcher.py b/search/searcher.py index d3537c3..03fae01 100644 --- a/search/searcher.py +++ b/search/searcher.py @@ -236,6 +236,117 @@ class Searcher: return es_query["_source"] = {"includes": self.source_fields} + def _resolve_exact_knn_rescore_window(self) -> int: + configured = int(self.config.rerank.exact_knn_rescore_window) + if configured > 0: + return configured + return int(self.config.rerank.rerank_window) + + @staticmethod + def _vector_to_list(vector: Any) -> List[float]: + if vector is None: + return [] + if hasattr(vector, "tolist"): + values = vector.tolist() + else: + values = list(vector) + return [float(v) for v in values] + + def _build_exact_knn_rescore( + self, + *, + query_vector: Any, + image_query_vector: Any, + ) -> Optional[Dict[str, Any]]: + clauses: List[Dict[str, Any]] = [] + + if query_vector is not None and self.text_embedding_field: + clauses.append( + { + "script_score": { + "_name": "exact_text_knn_query", + "query": {"exists": {"field": self.text_embedding_field}}, + "script": { + # Keep exact score on the same [0, 1]-ish scale as KNN dot_product recall. + "source": ( + f"(dotProduct(params.query_vector, '{self.text_embedding_field}') + 1.0) / 2.0" + ), + "params": {"query_vector": self._vector_to_list(query_vector)}, + }, + } + } + ) + + if image_query_vector is not None and self.image_embedding_field: + nested_path, _, _ = str(self.image_embedding_field).rpartition(".") + if nested_path: + clauses.append( + { + "nested": { + "path": nested_path, + "_name": "exact_image_knn_query", + "score_mode": "max", + "query": { + "script_score": { + "query": {"exists": {"field": self.image_embedding_field}}, + "script": { + # Keep exact score on the same [0, 1]-ish scale as KNN dot_product recall. + "source": ( + f"(dotProduct(params.query_vector, '{self.image_embedding_field}') + 1.0) / 2.0" + ), + "params": { + "query_vector": self._vector_to_list(image_query_vector), + }, + }, + } + }, + } + } + ) + + if not clauses: + return None + + return { + "window_size": self._resolve_exact_knn_rescore_window(), + "query": { + # Phase 1: only compute exact vector scores and expose them in matched_queries. + "score_mode": "total", + "query_weight": 1.0, + "rescore_query_weight": 0.0, + "rescore_query": { + "bool": { + "should": clauses, + "minimum_should_match": 1, + } + }, + }, + } + + def _attach_exact_knn_rescore( + self, + es_query: Dict[str, Any], + *, + in_rank_window: bool, + query_vector: Any, + image_query_vector: Any, + ) -> None: + if not in_rank_window or not self.config.rerank.exact_knn_rescore_enabled: + return + rescore = self._build_exact_knn_rescore( + query_vector=query_vector, + image_query_vector=image_query_vector, + ) + if not rescore: + return + existing = es_query.get("rescore") + if existing is None: + es_query["rescore"] = rescore + elif isinstance(existing, list): + es_query["rescore"] = [*existing, rescore] + else: + es_query["rescore"] = [existing, rescore] + def _resolve_rerank_source_filter( self, doc_template: str, @@ -573,6 +684,12 @@ class Searcher: min_score=min_score, parsed_query=parsed_query, ) + self._attach_exact_knn_rescore( + es_query, + in_rank_window=in_rank_window, + query_vector=parsed_query.query_vector if enable_embedding else None, + image_query_vector=image_query_vector, + ) # Add facets for faceted search if facets: @@ -1430,6 +1547,12 @@ class Searcher: "es_fetch_size": es_fetch_size, "in_rank_window": in_rank_window, "include_named_queries_score": bool(in_rank_window), + "exact_knn_rescore_enabled": bool(rc.exact_knn_rescore_enabled and in_rank_window), + "exact_knn_rescore_window": ( + self._resolve_exact_knn_rescore_window() + if rc.exact_knn_rescore_enabled and in_rank_window + else None + ), }, "es_response": { "took_ms": es_response.get('took', 0), diff --git a/tests/test_rerank_client.py b/tests/test_rerank_client.py index 658601b..77a1d85 100644 --- a/tests/test_rerank_client.py +++ b/tests/test_rerank_client.py @@ -172,6 +172,57 @@ def test_fuse_scores_and_resort_uses_max_of_text_and_image_knn_scores(): assert isclose(debug[0]["image_knn_score"], 0.7, rel_tol=1e-9) +def test_fuse_scores_and_resort_prefers_exact_knn_scores_over_ann_scores(): + hits = [ + { + "_id": "exact-mm-hit", + "_score": 1.0, + "matched_queries": { + "base_query": 1.5, + "knn_query": 0.2, + "image_knn_query": 0.7, + "exact_text_knn_query": 0.9, + "exact_image_knn_query": 0.1, + }, + } + ] + + debug = fuse_scores_and_resort(hits, [0.8], debug=True) + + assert isclose(hits[0]["_knn_score"], 0.9, rel_tol=1e-9) + assert isclose(debug[0]["text_knn_score"], 0.9, rel_tol=1e-9) + assert isclose(debug[0]["image_knn_score"], 0.1, rel_tol=1e-9) + assert isclose(debug[0]["exact_text_knn_score"], 0.9, rel_tol=1e-9) + assert isclose(debug[0]["exact_image_knn_score"], 0.1, rel_tol=1e-9) + assert isclose(debug[0]["approx_text_knn_score"], 0.2, rel_tol=1e-9) + assert isclose(debug[0]["approx_image_knn_score"], 0.7, rel_tol=1e-9) + assert debug[0]["text_knn_source"] == "exact_text_knn_query" + assert debug[0]["image_knn_source"] == "exact_image_knn_query" + + +def test_fuse_scores_and_resort_falls_back_to_ann_when_exact_knn_missing(): + hits = [ + { + "_id": "ann-only-hit", + "_score": 1.0, + "matched_queries": { + "base_query": 1.5, + "knn_query": 0.4, + "image_knn_query": 0.5, + }, + } + ] + + debug = fuse_scores_and_resort(hits, [0.8], debug=True) + + assert isclose(debug[0]["text_knn_score"], 0.4, rel_tol=1e-9) + assert isclose(debug[0]["image_knn_score"], 0.5, rel_tol=1e-9) + assert isclose(debug[0]["approx_text_knn_score"], 0.4, rel_tol=1e-9) + assert isclose(debug[0]["approx_image_knn_score"], 0.5, rel_tol=1e-9) + assert debug[0]["text_knn_source"] == "knn_query" + assert debug[0]["image_knn_source"] == "image_knn_query" + + def test_fuse_scores_and_resort_applies_knn_dismax_weights_and_tie_breaker(): hits = [ { diff --git a/tests/test_search_rerank_window.py b/tests/test_search_rerank_window.py index c0b776b..b8396ef 100644 --- a/tests/test_search_rerank_window.py +++ b/tests/test_search_rerank_window.py @@ -197,13 +197,24 @@ class _FakeESClient: } -def _build_search_config(*, rerank_enabled: bool = True, rerank_window: int = 384): +def _build_search_config( + *, + rerank_enabled: bool = True, + rerank_window: int = 384, + exact_knn_rescore_enabled: bool = False, + exact_knn_rescore_window: int = 0, +): return SearchConfig( field_boosts={"title.en": 3.0}, indexes=[IndexConfig(name="default", label="default", fields=["title.en"])], query_config=QueryConfig(enable_text_embedding=False, enable_query_rewrite=False), function_score=FunctionScoreConfig(), - rerank=RerankConfig(enabled=rerank_enabled, rerank_window=rerank_window), + rerank=RerankConfig( + enabled=rerank_enabled, + rerank_window=rerank_window, + exact_knn_rescore_enabled=exact_knn_rescore_enabled, + exact_knn_rescore_window=exact_knn_rescore_window, + ), spu_config=SPUConfig(enabled=False), es_index_name="test_products", es_settings={}, @@ -301,7 +312,11 @@ def test_config_loader_rerank_enabled_defaults_true(tmp_path: Path): }, "spu_config": {"enabled": False}, "function_score": {"score_mode": "sum", "boost_mode": "multiply", "functions": []}, - "rerank": {"rerank_window": 384}, + "rerank": { + "rerank_window": 384, + "exact_knn_rescore_enabled": True, + "exact_knn_rescore_window": 160, + }, } config_path = tmp_path / "config.yaml" config_path.write_text(yaml.safe_dump(config_data), encoding="utf-8") @@ -310,6 +325,8 @@ def test_config_loader_rerank_enabled_defaults_true(tmp_path: Path): loaded = loader.load_config(validate=False) assert loaded.rerank.enabled is True + assert loaded.rerank.exact_knn_rescore_enabled is True + assert loaded.rerank.exact_knn_rescore_window == 160 def test_config_loader_parses_named_rerank_instances(tmp_path: Path): @@ -1028,6 +1045,131 @@ def test_searcher_debug_info_uses_initial_es_max_score_for_normalization(monkeyp assert result.debug_info["per_result"][1]["es_score_normalized"] == 2.0 / 3.0 +def test_searcher_attaches_exact_knn_rescore_for_rank_window(monkeypatch): + class _VectorQueryParser: + def parse(self, query: str, tenant_id: str, generate_vector: bool, context: Any, target_languages: Any = None): + return _FakeParsedQuery( + original_query=query, + query_normalized=query, + rewritten_query=query, + translations={}, + query_vector=np.array([0.1, 0.2, 0.3], dtype=np.float32), + image_query_vector=np.array([0.4, 0.5, 0.6], dtype=np.float32), + ) + + es_client = _FakeESClient(total_hits=5) + base = _build_search_config( + rerank_enabled=True, + rerank_window=5, + exact_knn_rescore_enabled=True, + exact_knn_rescore_window=3, + ) + config = SearchConfig( + field_boosts=base.field_boosts, + indexes=base.indexes, + query_config=QueryConfig( + enable_text_embedding=True, + enable_query_rewrite=False, + text_embedding_field="title_embedding", + image_embedding_field="image_embedding.vector", + ), + function_score=base.function_score, + coarse_rank=base.coarse_rank, + fine_rank=FineRankConfig(enabled=False, input_window=5, output_window=5), + rerank=base.rerank, + spu_config=base.spu_config, + es_index_name=base.es_index_name, + es_settings=base.es_settings, + ) + searcher = _build_searcher(config, es_client) + searcher.query_parser = _VectorQueryParser() + context = create_request_context(reqid="exact-rescore", uid="u-exact") + + monkeypatch.setattr( + "search.searcher.get_tenant_config_loader", + lambda: SimpleNamespace(get_tenant_config=lambda tenant_id: {"index_languages": ["en"]}), + ) + + searcher.search( + query="dress", + tenant_id="162", + from_=0, + size=2, + context=context, + enable_rerank=False, + debug=True, + ) + + body = es_client.calls[0]["body"] + assert body["rescore"]["window_size"] == 3 + assert body["rescore"]["query"]["score_mode"] == "total" + assert body["rescore"]["query"]["rescore_query_weight"] == 0.0 + should = body["rescore"]["query"]["rescore_query"]["bool"]["should"] + names = [] + for clause in should: + if "script_score" in clause: + names.append(clause["script_score"]["_name"]) + elif "nested" in clause: + names.append(clause["nested"]["_name"]) + assert names == ["exact_text_knn_query", "exact_image_knn_query"] + + +def test_searcher_skips_exact_knn_rescore_outside_rank_window(monkeypatch): + class _VectorQueryParser: + def parse(self, query: str, tenant_id: str, generate_vector: bool, context: Any, target_languages: Any = None): + return _FakeParsedQuery( + original_query=query, + query_normalized=query, + rewritten_query=query, + translations={}, + query_vector=np.array([0.1, 0.2, 0.3], dtype=np.float32), + ) + + es_client = _FakeESClient(total_hits=20) + base = _build_search_config( + rerank_enabled=True, + rerank_window=5, + exact_knn_rescore_enabled=True, + exact_knn_rescore_window=4, + ) + config = SearchConfig( + field_boosts=base.field_boosts, + indexes=base.indexes, + query_config=QueryConfig( + enable_text_embedding=True, + enable_query_rewrite=False, + text_embedding_field="title_embedding", + ), + function_score=base.function_score, + coarse_rank=base.coarse_rank, + fine_rank=FineRankConfig(enabled=False, input_window=5, output_window=5), + rerank=base.rerank, + spu_config=base.spu_config, + es_index_name=base.es_index_name, + es_settings=base.es_settings, + ) + searcher = _build_searcher(config, es_client) + searcher.query_parser = _VectorQueryParser() + context = create_request_context(reqid="exact-rescore-off", uid="u-exact-off") + + monkeypatch.setattr( + "search.searcher.get_tenant_config_loader", + lambda: SimpleNamespace(get_tenant_config=lambda tenant_id: {"index_languages": ["en"]}), + ) + + searcher.search( + query="dress", + tenant_id="162", + from_=5, + size=2, + context=context, + enable_rerank=False, + ) + + body = es_client.calls[0]["body"] + assert "rescore" not in body + + def test_searcher_rerank_rank_change_falls_back_to_coarse_rank_when_fine_disabled(monkeypatch): es_client = _FakeESClient(total_hits=5) config = _build_search_config(rerank_enabled=True, rerank_window=5) -- libgit2 0.21.2