[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6QYY8Q10936QC01G87PRFQG":3,"skill-same:01M6QYY8Q10936QC01G87PRFQG":72},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":44,"linkOnly":45,"markdownContent":46,"markdownBody":47,"frontMatter":48,"refName":49,"commitSha":50,"skillDirectory":51,"forksCount":52,"repositoryHtmlUrl":53,"repositoryHomepageUrl":54,"ownerHtmlUrl":55,"ownerBlogUrl":6,"ownerDescription":56,"updatedAt":57,"install":58,"resources":71},"01M6QYY8Q10936QC01G87PRFQG","search-pipeline-reranking",null,"Use this skill when designing multi-stage retrieval, configuring ES reranking (rescore\u002Fretriever\u002FLTR\u002Fcross-encoder), query understanding, result diversification, or personalization. Covers ES 8.x rescore, the Retriever abstraction (RRF\u002FkNN\u002Ftext_similarity_reranker), Learning to Rank, and Kotlin service patterns for production search systems.","Dokkabei97\u002Fdokkabei-harness","Dokkabei97","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F69792585?v=4","01M6QYPVTYK6CGF609W541QNSD","JangMK","01M6QYPVYXBP1GRYHZ7ZABCZC6",1,"Apache-2.0","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Farchive\u002Fcomponents\u002Fsearch\u002Fskills\u002Fsearch-pipeline-reranking\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43],"pipeline","search pipeline reranking","reranking","search","reranker","when","learning","result","for","multi","knn","similarity","text","stage","personalization","query","patterns","es","use","kotlin","2026-11-10 10:12:30",false,"---\nname: search-pipeline-reranking\ndescription: Use this skill when designing multi-stage retrieval, configuring ES reranking (rescore\u002Fretriever\u002FLTR\u002Fcross-encoder), query understanding, result diversification, or personalization. Covers ES 8.x rescore, the Retriever abstraction (RRF\u002FkNN\u002Ftext_similarity_reranker), Learning to Rank, and Kotlin service patterns for production search systems.\n---\n\n# Search Pipeline & Reranking\n\n검색 파이프라인, 재순위화(reranking), 쿼리 이해(query understanding), 결과 다양성, 개인화를 포괄하는 고급 검색 아키텍처 가이드입니다.\n\n## When to Activate\n\n- ES 8.x search pipeline 설정 또는 커스텀 프로세서 개발 시\n- 재순위화(rescore, LTR, cross-encoder) 전략 설계 시\n- 다단계 검색(multi-stage retrieval) 아키텍처 설계 시\n- 쿼리 분류, 확장, 스펠 체크 등 query understanding 구현 시\n- field_collapse, pinned queries 등 결과 다양성\u002F머천다이징 시\n- 사용자 프로필 기반 개인화 검색 설계 시\n- Retriever abstraction (ES 8.14+) 활용 시\n\n## 1. 검색 요청\u002F응답 후처리 (ES 8.x)\n\n> ⚠️ **주의 — Search Pipeline 은 OpenSearch 전용이다.** `PUT _search\u002Fpipeline`,\n> `request_processors`\u002F`response_processors`, `filter_query`·`rename_field` 프로세서,\n> `index.search.default_pipeline`, `?search_pipeline=` 쿼리 파라미터, 그리고\n> elasticsearch-java 의 `searchPipeline(...)` 은 **Elasticsearch 8.x 에 존재하지 않는다.**\n> (ES 의 `index.default_pipeline` 은 *ingest* 파이프라인 지정용으로 색인 시 동작하며,\n> 검색 요청\u002F응답을 변환하지 않는다.) ES 에서 같은 목적을 달성하는 정석 수단:\n\n| 목적 | OpenSearch(참고용, ES 미지원) | Elasticsearch 8.x 대안 |\n|------|------------------------------|------------------------|\n| 멀티테넌트 격리 필터 자동 주입 | search pipeline `filter_query` | **filtered alias** \u002F 애플리케이션 `bool.filter` \u002F DLS(상용) |\n| 삭제 문서 제외 | search pipeline `filter_query` | filtered alias 또는 soft-delete 필드 필터 |\n| 응답 필드명 변경 | search pipeline `rename_field` | 서버측 미지원 — 애플리케이션 매핑, 또는 `fields` + runtime field |\n| 동적 boost·재랭킹 | search pipeline `script` | **Rescore(§2)** \u002F `function_score` \u002F **Retriever(§3)** |\n\n### 멀티테넌트 격리 — filtered alias\n\nalias 에 filter 를 걸면, 그 alias 로 검색하는 모든 요청에 필터가 자동 적용된다.\n애플리케이션이 매 쿼리에 조건을 붙이는 것을 잊어도 격리가 보장되는 게 핵심 이점이다.\n\n```json\n\u002F\u002F 테넌트 격리 + 삭제 문서 제외를 결합한 filtered alias\nPOST _aliases\n{\n  \"actions\": [\n    {\n      \"add\": {\n        \"index\": \"products\",\n        \"alias\": \"products_secure_42\",\n        \"filter\": {\n          \"bool\": {\n            \"filter\": [\n              { \"term\": { \"tenant_id\": \"42\" } },\n              { \"term\": { \"is_deleted\": false } }\n            ]\n          }\n        }\n      }\n    }\n  ]\n}\n```\n\n```json\n\u002F\u002F 검색은 alias 로만 — 필터가 항상 적용된다\nGET products_secure_42\u002F_search\n{\n  \"query\": { \"match\": { \"title\": \"무선 이어폰\" } }\n}\n```\n\n### 동적 컨텍스트 필터 — 애플리케이션 레벨 (Kotlin)\n\n요청별 테넌트처럼 alias 로 관리하기 번거로운 동적 조건은 서비스에서 `bool.filter` 로 주입한다.\n`filter` 절은 스코어에 영향을 주지 않아(캐시 가능) 격리·삭제 제외에 적합하다.\n\n```kotlin\n@Service\nclass TenantSearchService(\n    private val client: ElasticsearchClient\n) {\n    fun search(keyword: String, tenantId: String): List\u003CProduct> {\n        val response = client.search({ s ->\n            s.index(\"products\")\n                .query { q ->\n                    q.bool { b ->\n                        b.must { m -> m.match { mm -> mm.field(\"title\").query(keyword) } }\n                         .filter { f -> f.term { t -> t.field(\"tenant_id\").value(tenantId) } }\n                         .filter { f -> f.term { t -> t.field(\"is_deleted\").value(false) } }\n                    }\n                }\n                .size(20)\n        }, Product::class.java)\n\n        return response.hits().hits().mapNotNull { it.source() }\n    }\n}\n```\n\n검색 결과 재랭킹·후처리는 **§2 Rescore Query**, 다단계·하이브리드 검색은 **§3 Retriever** 를 참조.\n\n## 2. Rescore Query (Built-in Reranking)\n\n### Rescore 동작 원리\n\nRescore는 first-pass 검색 결과의 상위 N개 문서에 대해 추가 스코어링을 적용합니다. 전체 문서에 대해 무거운 쿼리를 실행하는 것보다 훨씬 효율적입니다.\n\n```\n[전체 문서] → first-pass (BM25) → [상위 window_size개] → rescore query → [최종 순위]\n```\n\n### Rescore 파라미터\n\n| Parameter | 설명 | 기본값 | 권장값 |\n|-----------|------|--------|--------|\n| `window_size` | rescore 대상 문서 수 | 10 | 50~200 |\n| `query_weight` | first-pass 스코어 가중치 | 1.0 | 0.7~1.0 |\n| `rescore_query_weight` | rescore 스코어 가중치 | 1.0 | 1.0~2.0 |\n| `score_mode` | 스코어 결합 방식 | `total` | `total` \u002F `multiply` |\n\n**score_mode 옵션**:\n\n| Mode | 계산 | 사용 시나리오 |\n|------|------|-------------|\n| `total` | `query_weight * first_pass + rescore_query_weight * rescore` | 기본, 대부분의 케이스 |\n| `multiply` | `first_pass * rescore` | rescore가 0~1 범위 factor일 때 |\n| `avg` | `(first_pass + rescore) \u002F 2` | 두 스코어를 동등하게 |\n| `max` | `max(first_pass, rescore)` | 더 높은 스코어 채택 |\n| `min` | `min(first_pass, rescore)` | 보수적 스코어링 |\n\n### Phrase Proximity Rescore\n\n```json\n\u002F\u002F first-pass: 일반 매칭 → rescore: 구문 근접도로 재순위화\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"match\": {\n      \"title\": {\n        \"query\": \"삼성 무선 이어폰\",\n        \"operator\": \"or\"\n      }\n    }\n  },\n  \"rescore\": {\n    \"window_size\": 100,\n    \"query\": {\n      \"rescore_query\": {\n        \"match_phrase\": {\n          \"title\": {\n            \"query\": \"삼성 무선 이어폰\",\n            \"slop\": 2\n          }\n        }\n      },\n      \"query_weight\": 0.7,\n      \"rescore_query_weight\": 1.5,\n      \"score_mode\": \"total\"\n    }\n  }\n}\n```\n\n### Chained Rescore (Phrase + Recency Decay)\n\n```json\n\u002F\u002F 다단계 rescore: 1단계 구문 근접도 → 2단계 최신성 가산\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"multi_match\": {\n      \"query\": \"노트북 가방\",\n      \"fields\": [\"title^3\", \"description\"],\n      \"type\": \"best_fields\"\n    }\n  },\n  \"rescore\": [\n    {\n      \"window_size\": 200,\n      \"query\": {\n        \"rescore_query\": {\n          \"match_phrase\": {\n            \"title\": {\n              \"query\": \"노트북 가방\",\n              \"slop\": 1\n            }\n          }\n        },\n        \"query_weight\": 0.7,\n        \"rescore_query_weight\": 2.0,\n        \"score_mode\": \"total\"\n      }\n    },\n    {\n      \"window_size\": 100,\n      \"query\": {\n        \"rescore_query\": {\n          \"function_score\": {\n            \"query\": { \"match_all\": {} },\n            \"functions\": [\n              {\n                \"gauss\": {\n                  \"created_at\": {\n                    \"origin\": \"now\",\n                    \"scale\": \"14d\",\n                    \"offset\": \"3d\",\n                    \"decay\": 0.5\n                  }\n                }\n              }\n            ],\n            \"boost_mode\": \"replace\"\n          }\n        },\n        \"query_weight\": 1.0,\n        \"rescore_query_weight\": 0.5,\n        \"score_mode\": \"total\"\n      }\n    }\n  ]\n}\n```\n\n### Kotlin Rescore 구현 패턴\n\n```kotlin\n@Service\nclass RescoreSearchService(\n    private val client: ElasticsearchClient\n) {\n    fun searchWithRescore(\n        keyword: String,\n        category: String? = null,\n        windowSize: Int = 100\n    ): SearchResult {\n        val response = client.search({ s ->\n            s.index(\"products\")\n                .query { q ->\n                    q.bool { b ->\n                        b.must { m ->\n                            m.multiMatch { mm ->\n                                mm.query(keyword)\n                                    .fields(\"title^3\", \"title.search^2\", \"description\")\n                                    .type(TextQueryType.BestFields)\n                            }\n                        }\n                        category?.let { cat ->\n                            b.filter { f ->\n                                f.term { t -> t.field(\"category\").value(cat) }\n                            }\n                        }\n                        b\n                    }\n                }\n                \u002F\u002F Phrase proximity rescore\n                .rescore { r ->\n                    r.windowSize(windowSize)\n                        .query { rq ->\n                            rq.rescoreQuery { q ->\n                                q.matchPhrase { mp ->\n                                    mp.field(\"title\").query(keyword).slop(2)\n                                }\n                            }\n                            .queryWeight(0.7)\n                            .rescoreQueryWeight(1.5)\n                            .scoreMode(ScoreMode.Total)\n                        }\n                }\n                .size(20)\n        }, Product::class.java)\n\n        return SearchResult(\n            items = response.hits().hits().mapNotNull { it.source() },\n            total = response.hits().total()?.value() ?: 0,\n            maxScore = response.hits().maxScore()\n        )\n    }\n}\n```\n\n### window_size vs Latency 트레이드오프\n\n| window_size | 레이턴시 영향 | 적합 시나리오 | 주의사항 |\n|-------------|-------------|-------------|---------|\n| 50~100 | **Minimal** (+1~3ms) | 대부분의 검색, 기본 권장값 | 상위 결과 품질 개선에 충분 |\n| 100~200 | **Low** (+3~8ms) | 정밀 구문 매칭 필요 시 | 첫 페이지 결과에 영향 |\n| 200~500 | **Moderate** (+8~25ms) | LTR 모델 적용, 복잡한 rescore | 페이징 시 일관성 주의 |\n| >500 | **High** (+25ms~) | 전체 결과 재정렬 필요 시 | 반드시 벤치마크로 검증 |\n\n**핵심 원칙**: `window_size`는 반환할 결과 수(size)보다 크거나 같아야 합니다. 일반적으로 `size * 5 ~ size * 10` 범위가 적절합니다.\n\n## 3. ES 8.x Retriever Abstraction\n\nES 8.14+에서 도입된 Retriever는 다단계 검색을 선언적으로 구성하는 추상화 계층입니다.\n\n### Retriever 유형\n\n| Retriever | 설명 | 최소 ES 버전 | 용도 |\n|-----------|------|-------------|------|\n| `standard` | 일반 query DSL 래핑 | 8.14.0 | 기존 쿼리를 retriever로 표현 |\n| `knn` | k-nearest neighbors 검색 | 8.14.0 | 벡터 유사도 검색 |\n| `rrf` | Reciprocal Rank Fusion | 8.14.0 | 여러 retriever 결과 융합 |\n| `text_similarity_reranker` | Inference API 기반 시맨틱 재순위화 | 8.15.0 | cross-encoder, LLM reranking |\n| `rule` | query rules 기반 결과 조작 | 8.16.0 | 머천다이징, pinned results |\n\n### Hybrid RRF: Standard + kNN Retriever\n\n```json\n\u002F\u002F BM25 텍스트 검색과 벡터 검색을 RRF로 융합\nPOST \u002Fproducts\u002F_search\n{\n  \"retriever\": {\n    \"rrf\": {\n      \"retrievers\": [\n        {\n          \"standard\": {\n            \"query\": {\n              \"multi_match\": {\n                \"query\": \"무선 블루투스 이어폰\",\n                \"fields\": [\"title^3\", \"description\"],\n                \"type\": \"best_fields\"\n              }\n            }\n          }\n        },\n        {\n          \"knn\": {\n            \"field\": \"title_embedding\",\n            \"query_vector_builder\": {\n              \"text_embedding\": {\n                \"model_id\": \"my-text-embedding-model\",\n                \"model_text\": \"무선 블루투스 이어폰\"\n              }\n            },\n            \"k\": 100,\n            \"num_candidates\": 200\n          }\n        }\n      ],\n      \"rank_window_size\": 200,\n      \"rank_constant\": 60\n    }\n  },\n  \"_source\": [\"title\", \"price\", \"category\", \"brand\"],\n  \"size\": 20\n}\n```\n\n**RRF 스코어 계산**: `RRF(d) = Σ 1 \u002F (rank_constant + rank_i(d))`\n- `rank_constant`가 클수록 하위 순위 문서의 기여도 감소\n- 기본값 60은 대부분의 시나리오에서 적절\n\n### Semantic Reranking via text_similarity_reranker\n\n```json\n\u002F\u002F 1단계: BM25로 후보 추출 → 2단계: cross-encoder로 시맨틱 재순위화\nPOST \u002Fproducts\u002F_search\n{\n  \"retriever\": {\n    \"text_similarity_reranker\": {\n      \"retriever\": {\n        \"standard\": {\n          \"query\": {\n            \"match\": {\n              \"title\": \"소음 차단되는 이어폰\"\n            }\n          }\n        }\n      },\n      \"field\": \"title\",\n      \"inference_id\": \"my-cross-encoder-model\",\n      \"inference_text\": \"소음 차단되는 이어폰\",\n      \"rank_window_size\": 100,\n      \"min_score\": 0.5\n    }\n  },\n  \"size\": 20\n}\n```\n\n**Inference API 사전 설정 필요**:\n\n```json\nPUT _inference\u002Frerank\u002Fmy-cross-encoder-model\n{\n  \"service\": \"cohere\",\n  \"service_settings\": {\n    \"model_id\": \"rerank-multilingual-v3.0\",\n    \"api_key\": \"{{COHERE_API_KEY}}\"\n  }\n}\n```\n\n### Retriever 최소 버전 요구사항\n\n| 기능 | 최소 버전 | 비고 |\n|------|----------|------|\n| `standard`, `knn`, `rrf` retriever | 8.14.0 | GA |\n| `text_similarity_reranker` | 8.15.0 | Inference API 필요 |\n| `rule` retriever | 8.16.0 | Query rules 사전 정의 필요 |\n| Retriever 내 aggregations 지원 | 8.16.0 | compound retriever에서 aggs 가능 |\n| Nested retriever composition | 8.14.0 | retriever 내부에 retriever 중첩 |\n\n## 4. Learning to Rank (LTR)\n\n### LTR 개요\n\nLearning to Rank는 머신러닝 모델을 사용하여 검색 결과의 순위를 최적화하는 방법입니다. ES LTR 플러그인(`elasticsearch-ltr`)을 통해 학습된 모델을 rescore 단계에서 적용합니다.\n\n```\n[검색 로그] → feature 추출 → 학습 데이터 → 오프라인 학습 → 모델 배포 → rescore 적용\n```\n\n### Feature 유형\n\n| Feature Type | 설명 | 예시 | 특성 |\n|-------------|------|------|------|\n| **BM25 score** | 텍스트 매칭 스코어 | `match` query on title | 텍스트 관련성 |\n| **Recency** | 문서 최신성 | `function_score.gauss` on created_at | 시간 감쇠 |\n| **Popularity** | 인기도 지표 | `field_value_factor` on sales_count | 클릭\u002F구매 수 |\n| **Field length** | 필드 길이 | `script_score` on title length | 간결성 선호 |\n| **Category match** | 카테고리 일치도 | `term` query on category | 의도 매칭 |\n| **CTR** | Click-Through Rate | 문서별 사전 계산된 CTR | 과거 사용자 행동 |\n| **Price ratio** | 가격 대비 경쟁력 | script로 카테고리 평균 대비 계산 | 가격 경쟁력 |\n| **Review score** | 리뷰 평점 | `field_value_factor` on avg_rating | 품질 지표 |\n\n### Feature Set 정의\n\n```json\n\u002F\u002F LTR 플러그인에 feature set 등록\nPOST _ltr\u002F_featureset\u002Fproduct_features\n{\n  \"featureset\": {\n    \"name\": \"product_features\",\n    \"features\": [\n      {\n        \"name\": \"title_bm25\",\n        \"params\": [\"keywords\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"match\": {\n            \"title\": \"{{keywords}}\"\n          }\n        }\n      },\n      {\n        \"name\": \"description_bm25\",\n        \"params\": [\"keywords\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"match\": {\n            \"description\": \"{{keywords}}\"\n          }\n        }\n      },\n      {\n        \"name\": \"recency_score\",\n        \"params\": [],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"function_score\": {\n            \"query\": { \"match_all\": {} },\n            \"functions\": [\n              {\n                \"gauss\": {\n                  \"created_at\": {\n                    \"origin\": \"now\",\n                    \"scale\": \"30d\",\n                    \"decay\": 0.5\n                  }\n                }\n              }\n            ],\n            \"boost_mode\": \"replace\"\n          }\n        }\n      },\n      {\n        \"name\": \"popularity_score\",\n        \"params\": [],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"function_score\": {\n            \"query\": { \"match_all\": {} },\n            \"functions\": [\n              {\n                \"field_value_factor\": {\n                  \"field\": \"sales_count\",\n                  \"modifier\": \"log1p\",\n                  \"missing\": 0\n                }\n              }\n            ],\n            \"boost_mode\": \"replace\"\n          }\n        }\n      },\n      {\n        \"name\": \"category_match\",\n        \"params\": [\"category\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"term\": {\n            \"category\": \"{{category}}\"\n          }\n        }\n      },\n      {\n        \"name\": \"title_phrase_match\",\n        \"params\": [\"keywords\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"match_phrase\": {\n            \"title\": {\n              \"query\": \"{{keywords}}\",\n              \"slop\": 2\n            }\n          }\n        }\n      }\n    ]\n  }\n}\n```\n\n### Feature Logging\n\n```json\n\u002F\u002F 기존 검색 결과에 대해 feature 값 로깅\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"match\": {\n            \"title\": \"무선 이어폰\"\n          }\n        }\n      ],\n      \"should\": [\n        {\n          \"sltr\": {\n            \"_name\": \"logged_features\",\n            \"featureset\": \"product_features\",\n            \"params\": {\n              \"keywords\": \"무선 이어폰\",\n              \"category\": \"electronics\"\n            }\n          }\n        }\n      ]\n    }\n  },\n  \"ext\": {\n    \"ltr_log\": {\n      \"log_specs\": {\n        \"name\": \"log_entry\",\n        \"named_query\": \"logged_features\"\n      }\n    }\n  },\n  \"size\": 100\n}\n```\n\n**로깅 응답 예시**:\n```json\n{\n  \"hits\": {\n    \"hits\": [\n      {\n        \"_id\": \"product_001\",\n        \"_score\": 12.5,\n        \"fields\": {\n          \"_ltrlog\": [\n            {\n              \"log_entry\": [\n                { \"name\": \"title_bm25\", \"value\": 8.2 },\n                { \"name\": \"description_bm25\", \"value\": 3.1 },\n                { \"name\": \"recency_score\", \"value\": 0.85 },\n                { \"name\": \"popularity_score\", \"value\": 6.7 },\n                { \"name\": \"category_match\", \"value\": 1.0 },\n                { \"name\": \"title_phrase_match\", \"value\": 5.3 }\n              ]\n            }\n          ]\n        }\n      }\n    ]\n  }\n}\n```\n\n### Named Queries로 절 매칭 디버깅·관측 (LTR feature logging과 용도 구분)\n\n위 Feature Logging의 `named_query`는 **`sltr` feature set 로깅 대상을 지정하는 LTR 학습 파이프라인 전용** 메커니즘이다. 이와 별개로, 일반 `bool` 절에 `_name`을 부착해 응답 `hit.matched_queries`로 **어떤 절이 매칭을 일으켰는지** 관측하는 디버깅·운영 용도가 있다. 둘은 이름이 비슷하나 목적이 다르다 — 전자는 모델 학습용 feature 값 추출, 후자는 매칭 경로 진단·ROI 데이터화.\n\n**절별 `_name` 부착으로 매칭 경로 집계**:\n```json\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"bool\": {\n      \"should\": [\n        { \"match\":        { \"title.exact\":   { \"query\": \"무선 이어폰\", \"_name\": \"exact\" } } },\n        { \"match\":        { \"title.synonym\": { \"query\": \"무선 이어폰\", \"_name\": \"synonym\" } } },\n        { \"fuzzy\":        { \"title\":         { \"value\": \"이어폰\", \"fuzziness\": \"AUTO\", \"_name\": \"typo\" } } },\n        { \"match_phrase\": { \"title\":         { \"query\": \"무선 이어폰\", \"boost\": 2, \"_name\": \"boost\" } } }\n      ]\n    }\n  },\n  \"include_named_queries_score\": true\n}\n```\n\n**응답** (ES 8.8.0+에서 `include_named_queries_score:true`이면 `matched_queries`가 List → `{name: score}` Map으로 변경):\n```json\n{\n  \"hits\": {\n    \"hits\": [\n      {\n        \"_id\": \"product_001\",\n        \"_score\": 18.4,\n        \"matched_queries\": { \"exact\": 12.1, \"synonym\": 0.0, \"boost\": 6.3 }\n      }\n    ]\n  }\n}\n```\n- 절별 매칭 빈도를 집계하면 동의어\u002F오타 절이 실제로 얼마나 매칭에 기여하는지 **ROI 데이터화**(예: `synonym` 절이 거의 매칭 안 되면 동의어 사전 점검).\n- ES 8.8.0 미만에서는 `matched_queries`가 매칭된 이름의 List(점수 없음)만 반환 — 절 기여 점수 관측은 8.8.0+ 필요.\n\n**함정**:\n- `matched_queries`는 **hit마다 절 재실행 오버헤드**가 있다 → 프로덕션 상시 사용 금지, `size` 제한·표본 추출로만.\n- `filter`\u002F`must_not`\u002F`constant_score`\u002F`function_score` 내부의 `_name`은 **score에 비기여**(점수 0 또는 final score와 불일치) → \"matched인데 점수 0\"을 버그로 오해하지 말 것.\n- `nested` 쿼리 안의 named query는 루트 `matched_queries`가 아니라 **`inner_hits.matched_queries`로 봐야** 정확하다(issue#46231).\n\n> 절 매칭 디버깅 증상 진입은 search-diagnostics 스킬 참조.\n\n### 모델 배포\n\n```json\n\u002F\u002F 오프라인 학습된 XGBoost 모델을 ES에 업로드\nPOST _ltr\u002F_featureset\u002Fproduct_features\u002F_createmodel\n{\n  \"model\": {\n    \"name\": \"product_ranking_v1\",\n    \"model\": {\n      \"type\": \"model\u002Fxgboost+json\",\n      \"definition\": \"{\\\"learner\\\":{\\\"feature_names\\\":[\\\"title_bm25\\\",\\\"description_bm25\\\",\\\"recency_score\\\",\\\"popularity_score\\\",\\\"category_match\\\",\\\"title_phrase_match\\\"],\\\"feature_types\\\":[\\\"float\\\",\\\"float\\\",\\\"float\\\",\\\"float\\\",\\\"float\\\",\\\"float\\\"],\\\"gradient_booster\\\":{\\\"name\\\":\\\"gbtree\\\",\\\"model\\\":{\\\"trees\\\":[{\\\"tree_param\\\":{\\\"num_nodes\\\":\\\"7\\\"},\\\"id\\\":0,\\\"tree_weights\\\":[1.0]}]}},\\\"learner_model_param\\\":{\\\"base_score\\\":\\\"0.5\\\",\\\"num_class\\\":\\\"0\\\",\\\"num_feature\\\":\\\"6\\\"},\\\"objective\\\":{\\\"name\\\":\\\"rank:ndcg\\\",\\\"rank_objective\\\":{\\\"delta_ndcg\\\":\\\"true\\\"}}}}\"\n    }\n  }\n}\n```\n\n### LTR Rescore 적용\n\n```json\n\u002F\u002F 학습된 모델을 rescore 단계에서 적용\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"multi_match\": {\n      \"query\": \"무선 이어폰\",\n      \"fields\": [\"title^3\", \"description\"],\n      \"type\": \"best_fields\"\n    }\n  },\n  \"rescore\": {\n    \"window_size\": 200,\n    \"query\": {\n      \"rescore_query\": {\n        \"sltr\": {\n          \"params\": {\n            \"keywords\": \"무선 이어폰\",\n            \"category\": \"electronics\"\n          },\n          \"model\": \"product_ranking_v1\"\n        }\n      },\n      \"query_weight\": 0,\n      \"rescore_query_weight\": 1,\n      \"score_mode\": \"total\"\n    }\n  }\n}\n```\n\n### LTR Training Workflow\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                    LTR Training Pipeline                        │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  Step 1. Feature Set 생성                                       │\n│  POST _ltr\u002F_featureset\u002Fproduct_features                         │\n│  → feature 템플릿 정의 (BM25, recency, popularity 등)            │\n│                                                                 │\n│  Step 2. Feature Logging                                        │\n│  POST \u002Fproducts\u002F_search (with sltr + ltr_log)                   │\n│  → 검색 결과별 feature 값 수집                                    │\n│                                                                 │\n│  Step 3. 학습 데이터 Export                                      │\n│  feature 로그 + relevance judgment → LibSVM\u002FCSV 형식 변환        │\n│  → qid:1 1:8.2 2:3.1 3:0.85 4:6.7 5:1.0 6:5.3 # label=4      │\n│                                                                 │\n│  Step 4. 오프라인 학습                                           │\n│  XGBoost\u002FLambdaMART rank:ndcg 목적함수로 학습                    │\n│  → 교차 검증, NDCG@10 평가, 하이퍼파라미터 튜닝                    │\n│                                                                 │\n│  Step 5. 모델 업로드                                             │\n│  POST _ltr\u002F_featureset\u002F_createmodel                              │\n│  → xgboost+json 형식으로 ES에 배포                               │\n│                                                                 │\n│  Step 6. Rescore로 배포                                          │\n│  rescore.query.rescore_query.sltr = 학습된 모델                  │\n│  → A\u002FB 테스트로 기존 대비 NDCG 개선 확인                          │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Kotlin LtrSearchService\n\n```kotlin\n@Service\nclass LtrSearchService(\n    private val client: ElasticsearchClient\n) {\n    \u002F**\n     * LTR 모델을 rescore에 적용한 검색.\n     * elasticsearch-java에서 sltr 쿼리를 직접 지원하지 않으므로\n     * withJson escape hatch를 사용합니다.\n     *\u002F\n    fun searchWithLtr(\n        keyword: String,\n        category: String? = null,\n        modelName: String = \"product_ranking_v1\",\n        windowSize: Int = 200\n    ): SearchResult {\n        val ltrParams = buildMap\u003CString, Any> {\n            put(\"keywords\", keyword)\n            category?.let { put(\"category\", it) }\n        }\n\n        val rescoreJson = \"\"\"\n        {\n          \"window_size\": $windowSize,\n          \"query\": {\n            \"rescore_query\": {\n              \"sltr\": {\n                \"params\": ${objectMapper.writeValueAsString(ltrParams)},\n                \"model\": \"$modelName\"\n              }\n            },\n            \"query_weight\": 0,\n            \"rescore_query_weight\": 1,\n            \"score_mode\": \"total\"\n          }\n        }\n        \"\"\".trimIndent()\n\n        \u002F\u002F first-pass 쿼리 + LTR rescore (withJson으로 rescore 부분 주입)\n        val queryJson = \"\"\"\n        {\n          \"query\": {\n            \"bool\": {\n              \"must\": [\n                {\n                  \"multi_match\": {\n                    \"query\": ${objectMapper.writeValueAsString(keyword)},\n                    \"fields\": [\"title^3\", \"title.search^2\", \"description\"],\n                    \"type\": \"best_fields\"\n                  }\n                }\n              ]\n              ${category?.let {\n                \"\"\", \"filter\": [{\"term\": {\"category\": ${objectMapper.writeValueAsString(it)}}}]\"\"\"\n              } ?: \"\"}\n            }\n          },\n          \"rescore\": $rescoreJson,\n          \"size\": 20\n        }\n        \"\"\".trimIndent()\n\n        val request = SearchRequest.of { s ->\n            s.index(\"products\").withJson(StringReader(queryJson))\n        }\n\n        val response = client.search(request, Product::class.java)\n\n        return SearchResult(\n            items = response.hits().hits().mapNotNull { it.source() },\n            total = response.hits().total()?.value() ?: 0,\n            maxScore = response.hits().maxScore()\n        )\n    }\n\n    companion object {\n        private val objectMapper = ObjectMapper()\n    }\n}\n```\n\n## 5. Query Understanding\n\n### Query Classification\n\n| Query Type | 특성 | 예시 | Routing 전략 |\n|-----------|------|------|-------------|\n| **Navigational** | 특정 상품\u002F브랜드 탐색 | \"갤럭시 버즈3\", \"애플 에어팟\" | exact match boost + brand filter |\n| **Informational** | 정보 탐색, 비교 | \"무선 이어폰 추천\", \"노트북 비교\" | broad match + aggregation + 리뷰 |\n| **Transactional** | 구매 의도 명확 | \"이어폰 최저가\", \"무선 이어폰 할인\" | price sort + deal filter + availability |\n| **Category** | 카테고리 브라우징 | \"이어폰\", \"신발\" | category landing + facets |\n| **Long-tail** | 구체적 속성 조합 | \"삼성 무선 이어폰 노이즈캔슬링 화이트\" | 모든 조건 필터 + relaxation 대비 |\n\n### Query Expansion via significant_terms\n\n```json\n\u002F\u002F \"이어폰\" 검색 사용자가 자주 함께 사용하는 용어 추출\nPOST \u002Fsearch_logs\u002F_search\n{\n  \"query\": {\n    \"match\": {\n      \"query_text\": \"이어폰\"\n    }\n  },\n  \"aggregations\": {\n    \"significant_related_terms\": {\n      \"significant_terms\": {\n        \"field\": \"clicked_product_tags\",\n        \"size\": 10,\n        \"min_doc_count\": 5,\n        \"mutual_information\": {\n          \"include_negatives\": false,\n          \"background_is_superset\": true\n        }\n      }\n    }\n  },\n  \"size\": 0\n}\n\u002F\u002F 결과: [\"블루투스\", \"노이즈캔슬링\", \"무선\", \"갤럭시버즈\", \"에어팟\"]\n\u002F\u002F → 쿼리 확장 또는 관련 검색어 추천에 활용\n```\n\n### Spell Correction (Phrase Suggester)\n\n```json\n\u002F\u002F 한국어 오타 교정: phrase suggester\nPOST \u002Fproducts\u002F_search\n{\n  \"suggest\": {\n    \"spell_check\": {\n      \"text\": \"삼섯 무선 이어폰\",\n      \"phrase\": {\n        \"field\": \"title.trigram\",\n        \"size\": 3,\n        \"gram_size\": 3,\n        \"confidence\": 1.0,\n        \"direct_generator\": [\n          {\n            \"field\": \"title.trigram\",\n            \"suggest_mode\": \"always\",\n            \"min_word_length\": 2\n          }\n        ],\n        \"collate\": {\n          \"query\": {\n            \"source\": {\n              \"match\": {\n                \"title\": \"{{suggestion}}\"\n              }\n            }\n          },\n          \"prune\": true\n        },\n        \"highlight\": {\n          \"pre_tag\": \"\u003Cem>\",\n          \"post_tag\": \"\u003C\u002Fem>\"\n        }\n      }\n    }\n  }\n}\n```\n\n**Trigram 분석기 설정** (spell correction 전제 조건):\n\n```json\nPUT \u002Fproducts\n{\n  \"settings\": {\n    \"analysis\": {\n      \"filter\": {\n        \"trigram_filter\": {\n          \"type\": \"ngram\",\n          \"min_gram\": 3,\n          \"max_gram\": 3\n        }\n      },\n      \"analyzer\": {\n        \"trigram_analyzer\": {\n          \"type\": \"custom\",\n          \"tokenizer\": \"standard\",\n          \"filter\": [\"lowercase\", \"trigram_filter\"]\n        }\n      }\n    }\n  },\n  \"mappings\": {\n    \"properties\": {\n      \"title\": {\n        \"type\": \"text\",\n        \"fields\": {\n          \"trigram\": {\n            \"type\": \"text\",\n            \"analyzer\": \"trigram_analyzer\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n### Query Relaxation Cascade (Kotlin)\n\n```kotlin\n@Service\nclass QueryRelaxationService(\n    private val client: ElasticsearchClient\n) {\n    \u002F**\n     * 쿼리 완화 캐스케이드: 결과가 부족하면 점진적으로 필터를 완화합니다.\n     *\n     * Level 0: 전체 필터 적용 (brand + category + attributes)\n     * Level 1: attribute 필터 제거\n     * Level 2: brand 필터 제거\n     * Level 3: category 필터 제거\n     * Level 4: fuzzy 매칭으로 전환\n     *\u002F\n    suspend fun searchWithRelaxation(\n        keyword: String,\n        brand: String? = null,\n        category: String? = null,\n        attributes: Map\u003CString, String> = emptyMap(),\n        minResults: Int = 5\n    ): RelaxedSearchResult = withContext(Dispatchers.IO) {\n\n        \u002F\u002F Level 0: 전체 필터\n        val level0 = executeSearch(keyword, brand, category, attributes, fuzzy = false)\n        if (level0.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level0.items, relaxationLevel = 0)\n        }\n\n        \u002F\u002F Level 1: attribute 필터 제거\n        val level1 = executeSearch(keyword, brand, category, emptyMap(), fuzzy = false)\n        if (level1.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level1.items, relaxationLevel = 1)\n        }\n\n        \u002F\u002F Level 2: brand 필터 제거\n        val level2 = executeSearch(keyword, null, category, emptyMap(), fuzzy = false)\n        if (level2.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level2.items, relaxationLevel = 2)\n        }\n\n        \u002F\u002F Level 3: category 필터 제거\n        val level3 = executeSearch(keyword, null, null, emptyMap(), fuzzy = false)\n        if (level3.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level3.items, relaxationLevel = 3)\n        }\n\n        \u002F\u002F Level 4: fuzzy 매칭\n        val level4 = executeSearch(keyword, null, null, emptyMap(), fuzzy = true)\n        return@withContext RelaxedSearchResult(level4.items, relaxationLevel = 4)\n    }\n\n    private fun executeSearch(\n        keyword: String,\n        brand: String?,\n        category: String?,\n        attributes: Map\u003CString, String>,\n        fuzzy: Boolean\n    ): RawSearchResult {\n        val response = client.search({ s ->\n            s.index(\"products\")\n                .query { q ->\n                    q.bool { b ->\n                        \u002F\u002F 텍스트 매칭\n                        b.must { m ->\n                            if (fuzzy) {\n                                m.match { mt ->\n                                    mt.field(\"title\").query(keyword).fuzziness(\"AUTO\")\n                                }\n                            } else {\n                                m.multiMatch { mm ->\n                                    mm.query(keyword)\n                                        .fields(\"title^3\", \"description\")\n                                        .type(TextQueryType.BestFields)\n                                }\n                            }\n                        }\n                        \u002F\u002F 조건부 필터\n                        brand?.let { b0 ->\n                            b.filter { f -> f.term { t -> t.field(\"brand\").value(b0) } }\n                        }\n                        category?.let { c ->\n                            b.filter { f -> f.term { t -> t.field(\"category\").value(c) } }\n                        }\n                        attributes.forEach { (key, value) ->\n                            b.filter { f ->\n                                f.term { t -> t.field(\"attributes.$key\").value(value) }\n                            }\n                        }\n                        b\n                    }\n                }\n                .size(20)\n        }, Product::class.java)\n\n        val hits = response.hits().hits()\n        return RawSearchResult(\n            items = hits.mapNotNull { it.source() },\n            totalHits = response.hits().total()?.value() ?: 0\n        )\n    }\n}\n\ndata class RelaxedSearchResult(\n    val items: List\u003CProduct>,\n    val relaxationLevel: Int  \u002F\u002F 0=exact, 1=no-attr, 2=no-brand, 3=no-category, 4=fuzzy\n)\n\nprivate data class RawSearchResult(\n    val items: List\u003CProduct>,\n    val totalHits: Long\n)\n```\n\n### Intent Detection Routing\n\n```kotlin\n@Component\nclass QueryIntentDetector {\n\n    private val navigationalPatterns = listOf(\n        Regex(\"^(삼성|애플|소니|LG|나이키|아디다스)\\\\s+.+\"),  \u002F\u002F 브랜드 + 상품\n        Regex(\".+\\\\s+(공식|정품|오리지널)$\"),                  \u002F\u002F 공식\u002F정품 의도\n        Regex(\"^[A-Za-z0-9\\\\-]+$\"),                          \u002F\u002F 모델명 (영문숫자)\n    )\n\n    private val transactionalPatterns = listOf(\n        Regex(\".+(최저가|할인|세일|쿠폰|특가)$\"),\n        Regex(\"^(싸게|저렴하게)\\\\s+.+\"),\n        Regex(\".+\\\\s+(구매|주문|배송)$\"),\n    )\n\n    private val informationalPatterns = listOf(\n        Regex(\".+(추천|비교|리뷰|후기|차이)$\"),\n        Regex(\"^(어떤|무슨|뭐가|뭘)\\\\s+.+\"),\n        Regex(\".+(좋은지|나은지|어때)$\"),\n    )\n\n    fun detectIntent(query: String): QueryIntent {\n        val trimmed = query.trim()\n\n        return when {\n            navigationalPatterns.any { it.matches(trimmed) } -> QueryIntent.NAVIGATIONAL\n            transactionalPatterns.any { it.matches(trimmed) } -> QueryIntent.TRANSACTIONAL\n            informationalPatterns.any { it.matches(trimmed) } -> QueryIntent.INFORMATIONAL\n            trimmed.split(\"\\\\s+\".toRegex()).size \u003C= 1 -> QueryIntent.CATEGORY\n            else -> QueryIntent.INFORMATIONAL\n        }\n    }\n\n    fun routeSearch(query: String): SearchStrategy {\n        return when (detectIntent(query)) {\n            QueryIntent.NAVIGATIONAL -> SearchStrategy(\n                boostExactMatch = true,\n                enableBrandFilter = true,\n                sortBy = SortBy.RELEVANCE,\n                rescoreWindow = 50\n            )\n            QueryIntent.TRANSACTIONAL -> SearchStrategy(\n                boostExactMatch = false,\n                enableBrandFilter = false,\n                sortBy = SortBy.PRICE_ASC,\n                rescoreWindow = 0,  \u002F\u002F rescore 불필요\n                filterAvailable = true\n            )\n            QueryIntent.INFORMATIONAL -> SearchStrategy(\n                boostExactMatch = false,\n                enableBrandFilter = false,\n                sortBy = SortBy.RELEVANCE,\n                rescoreWindow = 100,\n                includeAggregations = true\n            )\n            QueryIntent.CATEGORY -> SearchStrategy(\n                boostExactMatch = false,\n                enableBrandFilter = false,\n                sortBy = SortBy.POPULARITY,\n                rescoreWindow = 200,\n                includeFacets = true\n            )\n        }\n    }\n}\n\nenum class QueryIntent { NAVIGATIONAL, TRANSACTIONAL, INFORMATIONAL, CATEGORY }\n\ndata class SearchStrategy(\n    val boostExactMatch: Boolean = false,\n    val enableBrandFilter: Boolean = false,\n    val sortBy: SortBy = SortBy.RELEVANCE,\n    val rescoreWindow: Int = 100,\n    val filterAvailable: Boolean = false,\n    val includeAggregations: Boolean = false,\n    val includeFacets: Boolean = false\n)\n\nenum class SortBy { RELEVANCE, PRICE_ASC, PRICE_DESC, POPULARITY, RECENCY }\n```\n\n## 6. Result Diversification\n\n### field_collapse로 브랜드 다양성 확보\n\n```json\n\u002F\u002F 브랜드당 1개만 메인 노출, 나머지는 inner_hits로 접근\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"match\": { \"title\": \"무선 이어폰\" }\n  },\n  \"collapse\": {\n    \"field\": \"brand\",\n    \"inner_hits\": {\n      \"name\": \"brand_variants\",\n      \"size\": 3,\n      \"sort\": [{ \"_score\": \"desc\" }],\n      \"_source\": [\"title\", \"price\", \"brand\"]\n    },\n    \"max_concurrent_group_searches\": 4\n  },\n  \"sort\": [{ \"_score\": \"desc\" }],\n  \"size\": 10\n}\n```\n\n**응답 구조**:\n```json\n{\n  \"hits\": {\n    \"hits\": [\n      {\n        \"_source\": { \"title\": \"삼성 갤럭시 버즈3\", \"brand\": \"삼성\" },\n        \"inner_hits\": {\n          \"brand_variants\": {\n            \"hits\": {\n              \"hits\": [\n                { \"_source\": { \"title\": \"삼성 갤럭시 버즈3 프로\", \"brand\": \"삼성\" } },\n                { \"_source\": { \"title\": \"삼성 갤럭시 버즈FE\", \"brand\": \"삼성\" } }\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"_source\": { \"title\": \"애플 에어팟 프로 2\", \"brand\": \"애플\" }\n      }\n    ]\n  }\n}\n```\n\n### Pinned Queries로 머천다이징\n\n```json\n\u002F\u002F 특정 상품을 최상위에 고정 (프로모션, 광고, 큐레이션)\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"pinned\": {\n      \"ids\": [\"promo_001\", \"promo_002\", \"promo_003\"],\n      \"organic\": {\n        \"multi_match\": {\n          \"query\": \"무선 이어폰\",\n          \"fields\": [\"title^3\", \"description\"]\n        }\n      }\n    }\n  },\n  \"size\": 20\n}\n```\n\n**pinned query 동작**:\n- `ids`에 지정된 문서가 항상 최상위에 위치\n- 나머지는 `organic` 쿼리 결과 순서대로 배치\n- 지정된 ID의 문서가 존재하지 않으면 무시됨\n- 최대 100개까지 pinning 가능 (성능 고려)\n\n### MMR (Maximal Marginal Relevance) Post-Processing\n\nES에서 직접 MMR을 지원하지 않으므로 애플리케이션 레벨에서 구현합니다.\n\n```kotlin\n\u002F**\n * MMR (Maximal Marginal Relevance) 기반 결과 다양화.\n * MMR = λ * Sim(d, q) - (1-λ) * max(Sim(d, d_selected))\n *\n * @param lambda 관련성 vs 다양성 가중치 (1.0 = 관련성만, 0.0 = 다양성만)\n *\u002F\nclass MmrDiversifier(\n    private val lambda: Double = 0.7\n) {\n    fun diversify(\n        candidates: List\u003CScoredDocument>,\n        topK: Int = 20\n    ): List\u003CScoredDocument> {\n        if (candidates.size \u003C= topK) return candidates\n\n        val selected = mutableListOf\u003CScoredDocument>()\n        val remaining = candidates.toMutableList()\n\n        \u002F\u002F 첫 번째는 최고 스코어 문서\n        val first = remaining.maxByOrNull { it.score } ?: return emptyList()\n        selected.add(first)\n        remaining.remove(first)\n\n        \u002F\u002F 나머지는 MMR 기준으로 선택\n        while (selected.size \u003C topK && remaining.isNotEmpty()) {\n            val best = remaining.maxByOrNull { candidate ->\n                val relevance = candidate.score \u002F (candidates.maxOf { it.score })\n                val maxSimilarity = selected.maxOf { sel ->\n                    computeSimilarity(candidate, sel)\n                }\n                lambda * relevance - (1 - lambda) * maxSimilarity\n            } ?: break\n\n            selected.add(best)\n            remaining.remove(best)\n        }\n\n        return selected\n    }\n\n    \u002F**\n     * 카테고리 + 브랜드 기반 유사도 계산 (0.0 ~ 1.0).\n     * 벡터 기반 유사도가 필요하면 코사인 유사도로 교체.\n     *\u002F\n    private fun computeSimilarity(a: ScoredDocument, b: ScoredDocument): Double {\n        var similarity = 0.0\n        if (a.category == b.category) similarity += 0.5\n        if (a.brand == b.brand) similarity += 0.3\n        if (a.priceRange == b.priceRange) similarity += 0.2\n        return similarity\n    }\n}\n\ndata class ScoredDocument(\n    val id: String,\n    val score: Double,\n    val category: String,\n    val brand: String,\n    val priceRange: String  \u002F\u002F \"low\", \"mid\", \"high\"\n)\n```\n\n## 7. Personalization in Search\n\n### function_score with User Profile Boosting\n\n```json\n\u002F\u002F 사용자 선호 기반 개인화 점수 가산\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"function_score\": {\n      \"query\": {\n        \"multi_match\": {\n          \"query\": \"이어폰\",\n          \"fields\": [\"title^3\", \"description\"]\n        }\n      },\n      \"functions\": [\n        {\n          \"filter\": {\n            \"term\": { \"category\": \"electronics\" }\n          },\n          \"weight\": 1.5,\n          \"_comment\": \"사용자가 선호하는 카테고리 boost\"\n        },\n        {\n          \"filter\": {\n            \"terms\": { \"brand\": [\"삼성\", \"애플\"] }\n          },\n          \"weight\": 1.3,\n          \"_comment\": \"최근 30일 내 클릭\u002F구매한 브랜드 boost\"\n        },\n        {\n          \"filter\": {\n            \"range\": {\n              \"price\": {\n                \"gte\": 100000,\n                \"lte\": 300000\n              }\n            }\n          },\n          \"weight\": 1.2,\n          \"_comment\": \"사용자의 주요 가격대 boost\"\n        },\n        {\n          \"filter\": {\n            \"term\": { \"is_free_shipping\": true }\n          },\n          \"weight\": 1.1,\n          \"_comment\": \"무료배송 선호 사용자 boost\"\n        }\n      ],\n      \"score_mode\": \"sum\",\n      \"boost_mode\": \"multiply\",\n      \"max_boost\": 3.0\n    }\n  },\n  \"size\": 20\n}\n```\n\n### Session-Based Personalization Signals\n\n| Signal | 수집 방법 | 적용 방식 | 반영 시점 |\n|--------|----------|----------|----------|\n| **최근 본 카테고리** | 세션 내 클릭 로그 | category boost (weight 1.2~1.5) | 실시간 |\n| **최근 본 브랜드** | 세션 내 클릭 로그 | brand boost (weight 1.1~1.3) | 실시간 |\n| **가격대 선호** | 클릭\u002F구매 가격 분포 | price range filter boost | 세션 내 |\n| **카트 내 상품** | 장바구니 데이터 | 보완재\u002F대체재 boost | 실시간 |\n| **검색 히스토리** | 세션 내 이전 쿼리 | query expansion 재료 | 세션 내 |\n\n```kotlin\n@Service\nclass PersonalizedSearchService(\n    private val client: ElasticsearchClient,\n    private val userProfileService: UserProfileService\n) {\n    suspend fun personalizedSearch(\n        keyword: String,\n        userId: String\n    ): SearchResult = withContext(Dispatchers.IO) {\n        val profile = userProfileService.getProfile(userId)\n\n        val functions = mutableListOf\u003CString>()\n\n        \u002F\u002F 선호 카테고리 boost\n        profile.preferredCategories.takeIf { it.isNotEmpty() }?.let { cats ->\n            functions.add(\"\"\"\n                {\n                  \"filter\": { \"terms\": { \"category\": ${objectMapper.writeValueAsString(cats)} } },\n                  \"weight\": 1.4\n                }\n            \"\"\".trimIndent())\n        }\n\n        \u002F\u002F 선호 브랜드 boost\n        profile.preferredBrands.takeIf { it.isNotEmpty() }?.let { brands ->\n            functions.add(\"\"\"\n                {\n                  \"filter\": { \"terms\": { \"brand\": ${objectMapper.writeValueAsString(brands)} } },\n                  \"weight\": 1.3\n                }\n            \"\"\".trimIndent())\n        }\n\n        \u002F\u002F 가격대 boost\n        profile.priceRange?.let { range ->\n            functions.add(\"\"\"\n                {\n                  \"filter\": { \"range\": { \"price\": { \"gte\": ${range.first}, \"lte\": ${range.last} } } },\n                  \"weight\": 1.2\n                }\n            \"\"\".trimIndent())\n        }\n\n        val queryJson = \"\"\"\n        {\n          \"query\": {\n            \"function_score\": {\n              \"query\": {\n                \"multi_match\": {\n                  \"query\": ${objectMapper.writeValueAsString(keyword)},\n                  \"fields\": [\"title^3\", \"description\"],\n                  \"type\": \"best_fields\"\n                }\n              },\n              \"functions\": [${functions.joinToString(\",\")}],\n              \"score_mode\": \"sum\",\n              \"boost_mode\": \"multiply\",\n              \"max_boost\": 3.0\n            }\n          },\n          \"size\": 20\n        }\n        \"\"\".trimIndent()\n\n        val request = SearchRequest.of { s ->\n            s.index(\"products\").withJson(StringReader(queryJson))\n        }\n        val response = client.search(request, Product::class.java)\n\n        SearchResult(\n            items = response.hits().hits().mapNotNull { it.source() },\n            total = response.hits().total()?.value() ?: 0,\n            maxScore = response.hits().maxScore()\n        )\n    }\n\n    companion object {\n        private val objectMapper = ObjectMapper()\n    }\n}\n```\n\n### Collaborative Filtering as Document-Level Features\n\n| 방식 | 설명 | 구현 | 장단점 |\n|------|------|------|--------|\n| **Pre-computed CF score** | 사용자-상품 유사도를 사전 계산하여 문서 필드에 저장 | 배치로 `cf_score_segment_{A,B,C}` 인덱싱 | 검색 시 빠름, 갱신 주기에 종속 |\n| **User segment boost** | 사용자 세그먼트별 인기 상품에 가중치 | `function_score` filter by segment | 구현 간단, 개인화 수준 낮음 |\n| **Real-time embedding** | 사용자\u002F상품 임베딩 벡터 유사도 | kNN + RRF retriever 활용 | 정밀하지만 인프라 복잡 |\n\n### Privacy 고려사항\n\n| 원칙 | 설명 | 구현 방법 |\n|------|------|----------|\n| **데이터 최소화** | 개인화에 필요한 최소 데이터만 수집 | 카테고리\u002F브랜드 수준 집계, 개별 상품 기록 X |\n| **익명화** | 사용자 식별 불가능하게 처리 | 해시된 세션 ID, 세그먼트 기반 집계 |\n| **TTL** | 개인화 데이터 보존 기간 제한 | 세션 데이터 24h, 프로필 90일 TTL |\n| **Opt-out** | 사용자가 개인화를 비활성화할 수 있어야 함 | API 파라미터로 `personalized=false` 지원 |\n| **쿼리 로그 분리** | 검색 로그에 PII 미포함 | 사용자 ID 대신 익명 세션 ID만 기록 |\n\n## 8. Multi-Stage Retrieval Architecture\n\n### 아키텍처 다이어그램\n\n```\n┌─────────────────────────────────────────────────────────────────────────┐\n│                    Multi-Stage Retrieval Pipeline                        │\n├─────────────────────────────────────────────────────────────────────────┤\n│                                                                         │\n│  ┌─────────────────┐                                                    │\n│  │   User Query     │                                                    │\n│  └────────┬────────┘                                                    │\n│           ▼                                                             │\n│  ┌─────────────────┐  \u003C 10ms                                            │\n│  │  L0: Query       │  spell check, intent detection,                   │\n│  │  Understanding   │  query expansion, 한영 변환                        │\n│  └────────┬────────┘                                                    │\n│           ▼                                                             │\n│  ┌─────────────────┐  \u003C 50ms         ┌──────────────────┐              │\n│  │  L1: Candidate   │  BM25 + kNN ──→│ ~1000 candidates │              │\n│  │  Generation      │  broad recall   └────────┬─────────┘              │\n│  └────────┬────────┘                           │                        │\n│           ▼                                    ▼                        │\n│  ┌─────────────────┐  \u003C 20ms         ┌──────────────────┐              │\n│  │  L2: Lightweight │  rescore        │ ~200 candidates  │              │\n│  │  Scoring         │  phrase boost   └────────┬─────────┘              │\n│  └────────┬────────┘                           │                        │\n│           ▼                                    ▼                        │\n│  ┌─────────────────┐  \u003C 100ms        ┌──────────────────┐              │\n│  │  L3: Heavy       │  LTR model \u002F   │ ~50 candidates   │              │\n│  │  Reranking       │  cross-encoder  └────────┬─────────┘              │\n│  └────────┬────────┘                           │                        │\n│           ▼                                    ▼                        │\n│  ┌─────────────────┐  \u003C 10ms         ┌──────────────────┐              │\n│  │  L4: Business    │  pinning,       │ ~20 final results│              │\n│  │  Rules           │  diversity,     └──────────────────┘              │\n│  └────────┬────────┘  personalization                                   │\n│           ▼                                                             │\n│  ┌─────────────────┐                                                    │\n│  │   Final Results  │  Total latency \u003C 200ms                            │\n│  └─────────────────┘                                                    │\n│                                                                         │\n└─────────────────────────────────────────────────────────────────────────┘\n```\n\n### 단계별 레이턴시 버짓\n\n| Stage | 목표 레이턴시 | 문서 수 | 역할 | 주요 기술 |\n|-------|-------------|---------|------|----------|\n| **L0** | \u003C 10ms | - | 쿼리 전처리 | spell check, intent, 한영 변환 |\n| **L1** | \u003C 50ms | 전체 → ~1000 | 후보 생성 | BM25, kNN, RRF |\n| **L2** | \u003C 20ms | ~1000 → ~200 | 경량 재순위화 | rescore (phrase, recency) |\n| **L3** | \u003C 100ms | ~200 → ~50 | 정밀 재순위화 | LTR, cross-encoder |\n| **L4** | \u003C 10ms | ~50 → ~20 | 비즈니스 규칙 | pinning, diversity, personalization |\n| **Total** | **\u003C 200ms** | - | 전체 파이프라인 | - |\n\n### 단계 추가 판단 기준\n\n| 현재 상태 | 추가할 단계 | 판단 기준 |\n|----------|-----------|----------|\n| L1만 운영 | L0 (Query Understanding) | zero-result rate > 5%, 오타 비율 높음 |\n| L0 + L1 | L2 (Rescore) | exact match가 상위에 안 나옴, NDCG 정체 |\n| L0 + L1 + L2 | L3 (LTR) | 충분한 클릭 로그 확보, NDCG 오프라인 평가 준비 |\n| L0~L3 | L4 (Business Rules) | 머천다이징\u002F프로모션 요구, 결과 다양성 민원 |\n\n### Kotlin MultiStageSearchService\n\n```kotlin\n@Service\nclass MultiStageSearchService(\n    private val queryUnderstanding: QueryUnderstandingService,\n    private val candidateGenerator: CandidateGenerationService,\n    private val lightweightScorer: LightweightScoringService,\n    private val heavyReranker: HeavyRerankingService,\n    private val businessRules: BusinessRulesService,\n    private val meterRegistry: MeterRegistry\n) {\n    private val logger = LoggerFactory.getLogger(javaClass)\n\n    \u002F**\n     * 다단계 검색 파이프라인 실행.\n     * 각 단계의 레이턴시를 측정하고, 실패 시 이전 단계 결과로 fallback.\n     *\u002F\n    suspend fun search(request: SearchRequest): SearchResponse {\n        val timer = Timer.start(meterRegistry)\n\n        \u002F\u002F ── L0: Query Understanding ──\n        val l0Result = measureStage(\"l0_query_understanding\") {\n            try {\n                queryUnderstanding.process(request.query)\n            } catch (e: Exception) {\n                logger.warn(\"L0 failed, using raw query\", e)\n                QueryUnderstandingResult(\n                    originalQuery = request.query,\n                    processedQuery = request.query,\n                    intent = QueryIntent.INFORMATIONAL,\n                    corrections = emptyList()\n                )\n            }\n        }\n        logger.debug(\"L0 result: intent={}, corrected={}\", l0Result.intent, l0Result.processedQuery)\n\n        \u002F\u002F ── L1: Candidate Generation ──\n        val l1Result = measureStage(\"l1_candidate_generation\") {\n            candidateGenerator.generate(\n                query = l0Result.processedQuery,\n                intent = l0Result.intent,\n                filters = request.filters,\n                maxCandidates = 1000\n            )\n        }\n        logger.debug(\"L1 candidates: {} docs\", l1Result.size)\n\n        if (l1Result.isEmpty()) {\n            timer.stop(meterRegistry.timer(\"search.pipeline.total\", \"status\", \"no_results\"))\n            return SearchResponse(\n                items = emptyList(),\n                total = 0,\n                metadata = SearchMetadata(\n                    relaxationLevel = null,\n                    corrections = l0Result.corrections\n                )\n            )\n        }\n\n        \u002F\u002F ── L2: Lightweight Scoring ──\n        val l2Result = measureStage(\"l2_lightweight_scoring\") {\n            try {\n                lightweightScorer.score(\n                    candidates = l1Result,\n                    query = l0Result.processedQuery,\n                    windowSize = 200\n                )\n            } catch (e: Exception) {\n                logger.warn(\"L2 failed, using L1 results\", e)\n                l1Result.take(200)\n            }\n        }\n        logger.debug(\"L2 scored: {} docs\", l2Result.size)\n\n        \u002F\u002F ── L3: Heavy Reranking ──\n        val l3Result = measureStage(\"l3_heavy_reranking\") {\n            try {\n                heavyReranker.rerank(\n                    candidates = l2Result,\n                    query = l0Result.processedQuery,\n                    intent = l0Result.intent,\n                    topK = 50\n                )\n            } catch (e: Exception) {\n                logger.warn(\"L3 failed, using L2 results\", e)\n                l2Result.take(50)\n            }\n        }\n        logger.debug(\"L3 reranked: {} docs\", l3Result.size)\n\n        \u002F\u002F ── L4: Business Rules ──\n        val l4Result = measureStage(\"l4_business_rules\") {\n            try {\n                businessRules.apply(\n                    candidates = l3Result,\n                    userId = request.userId,\n                    query = l0Result.processedQuery,\n                    intent = l0Result.intent,\n                    topK = request.size\n                )\n            } catch (e: Exception) {\n                logger.warn(\"L4 failed, using L3 results\", e)\n                l3Result.take(request.size)\n            }\n        }\n\n        timer.stop(meterRegistry.timer(\"search.pipeline.total\", \"status\", \"success\"))\n\n        return SearchResponse(\n            items = l4Result,\n            total = l1Result.size.toLong(),\n            metadata = SearchMetadata(\n                corrections = l0Result.corrections,\n                intent = l0Result.intent,\n                stages = StageMetrics(\n                    l1Candidates = l1Result.size,\n                    l2Scored = l2Result.size,\n                    l3Reranked = l3Result.size,\n                    l4Final = l4Result.size\n                )\n            )\n        )\n    }\n\n    private suspend fun \u003CT> measureStage(stageName: String, block: suspend () -> T): T {\n        val start = System.nanoTime()\n        try {\n            val result = block()\n            val durationMs = (System.nanoTime() - start) \u002F 1_000_000.0\n            meterRegistry.timer(\"search.stage.duration\", \"stage\", stageName, \"status\", \"success\")\n                .record(durationMs.toLong(), TimeUnit.MILLISECONDS)\n            return result\n        } catch (e: Exception) {\n            val durationMs = (System.nanoTime() - start) \u002F 1_000_000.0\n            meterRegistry.timer(\"search.stage.duration\", \"stage\", stageName, \"status\", \"error\")\n                .record(durationMs.toLong(), TimeUnit.MILLISECONDS)\n            throw e\n        }\n    }\n}\n\ndata class SearchRequest(\n    val query: String,\n    val filters: Map\u003CString, Any> = emptyMap(),\n    val userId: String? = null,\n    val size: Int = 20\n)\n\ndata class SearchResponse(\n    val items: List\u003CProduct>,\n    val total: Long,\n    val metadata: SearchMetadata\n)\n\ndata class SearchMetadata(\n    val corrections: List\u003CString> = emptyList(),\n    val intent: QueryIntent? = null,\n    val relaxationLevel: Int? = null,\n    val stages: StageMetrics? = null\n)\n\ndata class StageMetrics(\n    val l1Candidates: Int,\n    val l2Scored: Int,\n    val l3Reranked: Int,\n    val l4Final: Int\n)\n```\n\n## 9. Anti-Patterns\n\n| Anti-Pattern | 문제 | 올바른 접근 | Severity |\n|-------------|------|-----------|----------|\n| rescore `window_size` > 500 without justification | 레이턴시 급증, first-pass 이점 상실 | 벤치마크 기반으로 적정 window_size 결정 (보통 100~200) | **High** |\n| LTR 모델 배포 without offline evaluation | 프로덕션에서 검색 품질 저하 감지 못함 | 배포 전 NDCG@10 오프라인 평가 필수, 기존 대비 개선 증명 | **Critical** |\n| Reranking on unfiltered full result set | O(N) 스코어링으로 레이턴시 폭증 | first-pass로 후보 축소 후 상위 N개만 reranking | **High** |\n| Personalization에 PII를 query logs에 저장 | 개인정보 유출 위험, 법적 이슈 | 익명 세션 ID만 기록, PII는 별도 암호화 저장소에서 관리 | **Critical** |\n| 모든 단계를 single ES query에 구현 | 디버깅 불가, 단계별 최적화 불가 | 각 단계를 독립 컴포넌트로 분리, 단계별 메트릭 수집 | **Medium** |\n| 새 ranking 단계 추가 시 A\u002FB test 없이 배포 | 개선\u002F악화 판단 불가, 롤백 근거 없음 | 트래픽 일부에만 적용 후 NDCG, CTR 비교 | **High** |\n| Query relaxation without logging | 어떤 단계에서 결과를 찾았는지 추적 불가 | relaxation level을 응답 메타데이터에 포함, 로그로 수집 | **Medium** |\n| Cross-encoder reranking without timeout | 외부 inference 서비스 장애 시 전체 검색 중단 | 타임아웃 + fallback (L2 결과로 대체) 필수 | **High** |\n| Feature set 변경 후 모델 재학습 없이 사용 | feature 순서\u002F의미 불일치로 예측 오류 | feature set 변경 시 반드시 모델 재학습 + 재배포 | **Critical** |\n| 개인화 boost weight를 하드코딩 | 환경별 최적값 다름, 튜닝 어려움 | 설정 파일 또는 remote config로 외부화 | **Medium** |\n\n---\n\n**Remember**: 다단계 검색은 점진적으로 구축하세요. L1(BM25 검색)에서 시작하고, 오프라인 평가(NDCG)로 병목을 확인한 후 필요한 단계만 추가하세요. 모든 단계를 한 번에 도입하는 것은 복잡성만 증가시킵니다. rescore는 가장 비용 대비 효과가 큰 재순위화 방법입니다.\n","# Search Pipeline & Reranking\n\n검색 파이프라인, 재순위화(reranking), 쿼리 이해(query understanding), 결과 다양성, 개인화를 포괄하는 고급 검색 아키텍처 가이드입니다.\n\n## When to Activate\n\n- ES 8.x search pipeline 설정 또는 커스텀 프로세서 개발 시\n- 재순위화(rescore, LTR, cross-encoder) 전략 설계 시\n- 다단계 검색(multi-stage retrieval) 아키텍처 설계 시\n- 쿼리 분류, 확장, 스펠 체크 등 query understanding 구현 시\n- field_collapse, pinned queries 등 결과 다양성\u002F머천다이징 시\n- 사용자 프로필 기반 개인화 검색 설계 시\n- Retriever abstraction (ES 8.14+) 활용 시\n\n## 1. 검색 요청\u002F응답 후처리 (ES 8.x)\n\n> ⚠️ **주의 — Search Pipeline 은 OpenSearch 전용이다.** `PUT _search\u002Fpipeline`,\n> `request_processors`\u002F`response_processors`, `filter_query`·`rename_field` 프로세서,\n> `index.search.default_pipeline`, `?search_pipeline=` 쿼리 파라미터, 그리고\n> elasticsearch-java 의 `searchPipeline(...)` 은 **Elasticsearch 8.x 에 존재하지 않는다.**\n> (ES 의 `index.default_pipeline` 은 *ingest* 파이프라인 지정용으로 색인 시 동작하며,\n> 검색 요청\u002F응답을 변환하지 않는다.) ES 에서 같은 목적을 달성하는 정석 수단:\n\n| 목적 | OpenSearch(참고용, ES 미지원) | Elasticsearch 8.x 대안 |\n|------|------------------------------|------------------------|\n| 멀티테넌트 격리 필터 자동 주입 | search pipeline `filter_query` | **filtered alias** \u002F 애플리케이션 `bool.filter` \u002F DLS(상용) |\n| 삭제 문서 제외 | search pipeline `filter_query` | filtered alias 또는 soft-delete 필드 필터 |\n| 응답 필드명 변경 | search pipeline `rename_field` | 서버측 미지원 — 애플리케이션 매핑, 또는 `fields` + runtime field |\n| 동적 boost·재랭킹 | search pipeline `script` | **Rescore(§2)** \u002F `function_score` \u002F **Retriever(§3)** |\n\n### 멀티테넌트 격리 — filtered alias\n\nalias 에 filter 를 걸면, 그 alias 로 검색하는 모든 요청에 필터가 자동 적용된다.\n애플리케이션이 매 쿼리에 조건을 붙이는 것을 잊어도 격리가 보장되는 게 핵심 이점이다.\n\n```json\n\u002F\u002F 테넌트 격리 + 삭제 문서 제외를 결합한 filtered alias\nPOST _aliases\n{\n  \"actions\": [\n    {\n      \"add\": {\n        \"index\": \"products\",\n        \"alias\": \"products_secure_42\",\n        \"filter\": {\n          \"bool\": {\n            \"filter\": [\n              { \"term\": { \"tenant_id\": \"42\" } },\n              { \"term\": { \"is_deleted\": false } }\n            ]\n          }\n        }\n      }\n    }\n  ]\n}\n```\n\n```json\n\u002F\u002F 검색은 alias 로만 — 필터가 항상 적용된다\nGET products_secure_42\u002F_search\n{\n  \"query\": { \"match\": { \"title\": \"무선 이어폰\" } }\n}\n```\n\n### 동적 컨텍스트 필터 — 애플리케이션 레벨 (Kotlin)\n\n요청별 테넌트처럼 alias 로 관리하기 번거로운 동적 조건은 서비스에서 `bool.filter` 로 주입한다.\n`filter` 절은 스코어에 영향을 주지 않아(캐시 가능) 격리·삭제 제외에 적합하다.\n\n```kotlin\n@Service\nclass TenantSearchService(\n    private val client: ElasticsearchClient\n) {\n    fun search(keyword: String, tenantId: String): List\u003CProduct> {\n        val response = client.search({ s ->\n            s.index(\"products\")\n                .query { q ->\n                    q.bool { b ->\n                        b.must { m -> m.match { mm -> mm.field(\"title\").query(keyword) } }\n                         .filter { f -> f.term { t -> t.field(\"tenant_id\").value(tenantId) } }\n                         .filter { f -> f.term { t -> t.field(\"is_deleted\").value(false) } }\n                    }\n                }\n                .size(20)\n        }, Product::class.java)\n\n        return response.hits().hits().mapNotNull { it.source() }\n    }\n}\n```\n\n검색 결과 재랭킹·후처리는 **§2 Rescore Query**, 다단계·하이브리드 검색은 **§3 Retriever** 를 참조.\n\n## 2. Rescore Query (Built-in Reranking)\n\n### Rescore 동작 원리\n\nRescore는 first-pass 검색 결과의 상위 N개 문서에 대해 추가 스코어링을 적용합니다. 전체 문서에 대해 무거운 쿼리를 실행하는 것보다 훨씬 효율적입니다.\n\n```\n[전체 문서] → first-pass (BM25) → [상위 window_size개] → rescore query → [최종 순위]\n```\n\n### Rescore 파라미터\n\n| Parameter | 설명 | 기본값 | 권장값 |\n|-----------|------|--------|--------|\n| `window_size` | rescore 대상 문서 수 | 10 | 50~200 |\n| `query_weight` | first-pass 스코어 가중치 | 1.0 | 0.7~1.0 |\n| `rescore_query_weight` | rescore 스코어 가중치 | 1.0 | 1.0~2.0 |\n| `score_mode` | 스코어 결합 방식 | `total` | `total` \u002F `multiply` |\n\n**score_mode 옵션**:\n\n| Mode | 계산 | 사용 시나리오 |\n|------|------|-------------|\n| `total` | `query_weight * first_pass + rescore_query_weight * rescore` | 기본, 대부분의 케이스 |\n| `multiply` | `first_pass * rescore` | rescore가 0~1 범위 factor일 때 |\n| `avg` | `(first_pass + rescore) \u002F 2` | 두 스코어를 동등하게 |\n| `max` | `max(first_pass, rescore)` | 더 높은 스코어 채택 |\n| `min` | `min(first_pass, rescore)` | 보수적 스코어링 |\n\n### Phrase Proximity Rescore\n\n```json\n\u002F\u002F first-pass: 일반 매칭 → rescore: 구문 근접도로 재순위화\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"match\": {\n      \"title\": {\n        \"query\": \"삼성 무선 이어폰\",\n        \"operator\": \"or\"\n      }\n    }\n  },\n  \"rescore\": {\n    \"window_size\": 100,\n    \"query\": {\n      \"rescore_query\": {\n        \"match_phrase\": {\n          \"title\": {\n            \"query\": \"삼성 무선 이어폰\",\n            \"slop\": 2\n          }\n        }\n      },\n      \"query_weight\": 0.7,\n      \"rescore_query_weight\": 1.5,\n      \"score_mode\": \"total\"\n    }\n  }\n}\n```\n\n### Chained Rescore (Phrase + Recency Decay)\n\n```json\n\u002F\u002F 다단계 rescore: 1단계 구문 근접도 → 2단계 최신성 가산\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"multi_match\": {\n      \"query\": \"노트북 가방\",\n      \"fields\": [\"title^3\", \"description\"],\n      \"type\": \"best_fields\"\n    }\n  },\n  \"rescore\": [\n    {\n      \"window_size\": 200,\n      \"query\": {\n        \"rescore_query\": {\n          \"match_phrase\": {\n            \"title\": {\n              \"query\": \"노트북 가방\",\n              \"slop\": 1\n            }\n          }\n        },\n        \"query_weight\": 0.7,\n        \"rescore_query_weight\": 2.0,\n        \"score_mode\": \"total\"\n      }\n    },\n    {\n      \"window_size\": 100,\n      \"query\": {\n        \"rescore_query\": {\n          \"function_score\": {\n            \"query\": { \"match_all\": {} },\n            \"functions\": [\n              {\n                \"gauss\": {\n                  \"created_at\": {\n                    \"origin\": \"now\",\n                    \"scale\": \"14d\",\n                    \"offset\": \"3d\",\n                    \"decay\": 0.5\n                  }\n                }\n              }\n            ],\n            \"boost_mode\": \"replace\"\n          }\n        },\n        \"query_weight\": 1.0,\n        \"rescore_query_weight\": 0.5,\n        \"score_mode\": \"total\"\n      }\n    }\n  ]\n}\n```\n\n### Kotlin Rescore 구현 패턴\n\n```kotlin\n@Service\nclass RescoreSearchService(\n    private val client: ElasticsearchClient\n) {\n    fun searchWithRescore(\n        keyword: String,\n        category: String? = null,\n        windowSize: Int = 100\n    ): SearchResult {\n        val response = client.search({ s ->\n            s.index(\"products\")\n                .query { q ->\n                    q.bool { b ->\n                        b.must { m ->\n                            m.multiMatch { mm ->\n                                mm.query(keyword)\n                                    .fields(\"title^3\", \"title.search^2\", \"description\")\n                                    .type(TextQueryType.BestFields)\n                            }\n                        }\n                        category?.let { cat ->\n                            b.filter { f ->\n                                f.term { t -> t.field(\"category\").value(cat) }\n                            }\n                        }\n                        b\n                    }\n                }\n                \u002F\u002F Phrase proximity rescore\n                .rescore { r ->\n                    r.windowSize(windowSize)\n                        .query { rq ->\n                            rq.rescoreQuery { q ->\n                                q.matchPhrase { mp ->\n                                    mp.field(\"title\").query(keyword).slop(2)\n                                }\n                            }\n                            .queryWeight(0.7)\n                            .rescoreQueryWeight(1.5)\n                            .scoreMode(ScoreMode.Total)\n                        }\n                }\n                .size(20)\n        }, Product::class.java)\n\n        return SearchResult(\n            items = response.hits().hits().mapNotNull { it.source() },\n            total = response.hits().total()?.value() ?: 0,\n            maxScore = response.hits().maxScore()\n        )\n    }\n}\n```\n\n### window_size vs Latency 트레이드오프\n\n| window_size | 레이턴시 영향 | 적합 시나리오 | 주의사항 |\n|-------------|-------------|-------------|---------|\n| 50~100 | **Minimal** (+1~3ms) | 대부분의 검색, 기본 권장값 | 상위 결과 품질 개선에 충분 |\n| 100~200 | **Low** (+3~8ms) | 정밀 구문 매칭 필요 시 | 첫 페이지 결과에 영향 |\n| 200~500 | **Moderate** (+8~25ms) | LTR 모델 적용, 복잡한 rescore | 페이징 시 일관성 주의 |\n| >500 | **High** (+25ms~) | 전체 결과 재정렬 필요 시 | 반드시 벤치마크로 검증 |\n\n**핵심 원칙**: `window_size`는 반환할 결과 수(size)보다 크거나 같아야 합니다. 일반적으로 `size * 5 ~ size * 10` 범위가 적절합니다.\n\n## 3. ES 8.x Retriever Abstraction\n\nES 8.14+에서 도입된 Retriever는 다단계 검색을 선언적으로 구성하는 추상화 계층입니다.\n\n### Retriever 유형\n\n| Retriever | 설명 | 최소 ES 버전 | 용도 |\n|-----------|------|-------------|------|\n| `standard` | 일반 query DSL 래핑 | 8.14.0 | 기존 쿼리를 retriever로 표현 |\n| `knn` | k-nearest neighbors 검색 | 8.14.0 | 벡터 유사도 검색 |\n| `rrf` | Reciprocal Rank Fusion | 8.14.0 | 여러 retriever 결과 융합 |\n| `text_similarity_reranker` | Inference API 기반 시맨틱 재순위화 | 8.15.0 | cross-encoder, LLM reranking |\n| `rule` | query rules 기반 결과 조작 | 8.16.0 | 머천다이징, pinned results |\n\n### Hybrid RRF: Standard + kNN Retriever\n\n```json\n\u002F\u002F BM25 텍스트 검색과 벡터 검색을 RRF로 융합\nPOST \u002Fproducts\u002F_search\n{\n  \"retriever\": {\n    \"rrf\": {\n      \"retrievers\": [\n        {\n          \"standard\": {\n            \"query\": {\n              \"multi_match\": {\n                \"query\": \"무선 블루투스 이어폰\",\n                \"fields\": [\"title^3\", \"description\"],\n                \"type\": \"best_fields\"\n              }\n            }\n          }\n        },\n        {\n          \"knn\": {\n            \"field\": \"title_embedding\",\n            \"query_vector_builder\": {\n              \"text_embedding\": {\n                \"model_id\": \"my-text-embedding-model\",\n                \"model_text\": \"무선 블루투스 이어폰\"\n              }\n            },\n            \"k\": 100,\n            \"num_candidates\": 200\n          }\n        }\n      ],\n      \"rank_window_size\": 200,\n      \"rank_constant\": 60\n    }\n  },\n  \"_source\": [\"title\", \"price\", \"category\", \"brand\"],\n  \"size\": 20\n}\n```\n\n**RRF 스코어 계산**: `RRF(d) = Σ 1 \u002F (rank_constant + rank_i(d))`\n- `rank_constant`가 클수록 하위 순위 문서의 기여도 감소\n- 기본값 60은 대부분의 시나리오에서 적절\n\n### Semantic Reranking via text_similarity_reranker\n\n```json\n\u002F\u002F 1단계: BM25로 후보 추출 → 2단계: cross-encoder로 시맨틱 재순위화\nPOST \u002Fproducts\u002F_search\n{\n  \"retriever\": {\n    \"text_similarity_reranker\": {\n      \"retriever\": {\n        \"standard\": {\n          \"query\": {\n            \"match\": {\n              \"title\": \"소음 차단되는 이어폰\"\n            }\n          }\n        }\n      },\n      \"field\": \"title\",\n      \"inference_id\": \"my-cross-encoder-model\",\n      \"inference_text\": \"소음 차단되는 이어폰\",\n      \"rank_window_size\": 100,\n      \"min_score\": 0.5\n    }\n  },\n  \"size\": 20\n}\n```\n\n**Inference API 사전 설정 필요**:\n\n```json\nPUT _inference\u002Frerank\u002Fmy-cross-encoder-model\n{\n  \"service\": \"cohere\",\n  \"service_settings\": {\n    \"model_id\": \"rerank-multilingual-v3.0\",\n    \"api_key\": \"{{COHERE_API_KEY}}\"\n  }\n}\n```\n\n### Retriever 최소 버전 요구사항\n\n| 기능 | 최소 버전 | 비고 |\n|------|----------|------|\n| `standard`, `knn`, `rrf` retriever | 8.14.0 | GA |\n| `text_similarity_reranker` | 8.15.0 | Inference API 필요 |\n| `rule` retriever | 8.16.0 | Query rules 사전 정의 필요 |\n| Retriever 내 aggregations 지원 | 8.16.0 | compound retriever에서 aggs 가능 |\n| Nested retriever composition | 8.14.0 | retriever 내부에 retriever 중첩 |\n\n## 4. Learning to Rank (LTR)\n\n### LTR 개요\n\nLearning to Rank는 머신러닝 모델을 사용하여 검색 결과의 순위를 최적화하는 방법입니다. ES LTR 플러그인(`elasticsearch-ltr`)을 통해 학습된 모델을 rescore 단계에서 적용합니다.\n\n```\n[검색 로그] → feature 추출 → 학습 데이터 → 오프라인 학습 → 모델 배포 → rescore 적용\n```\n\n### Feature 유형\n\n| Feature Type | 설명 | 예시 | 특성 |\n|-------------|------|------|------|\n| **BM25 score** | 텍스트 매칭 스코어 | `match` query on title | 텍스트 관련성 |\n| **Recency** | 문서 최신성 | `function_score.gauss` on created_at | 시간 감쇠 |\n| **Popularity** | 인기도 지표 | `field_value_factor` on sales_count | 클릭\u002F구매 수 |\n| **Field length** | 필드 길이 | `script_score` on title length | 간결성 선호 |\n| **Category match** | 카테고리 일치도 | `term` query on category | 의도 매칭 |\n| **CTR** | Click-Through Rate | 문서별 사전 계산된 CTR | 과거 사용자 행동 |\n| **Price ratio** | 가격 대비 경쟁력 | script로 카테고리 평균 대비 계산 | 가격 경쟁력 |\n| **Review score** | 리뷰 평점 | `field_value_factor` on avg_rating | 품질 지표 |\n\n### Feature Set 정의\n\n```json\n\u002F\u002F LTR 플러그인에 feature set 등록\nPOST _ltr\u002F_featureset\u002Fproduct_features\n{\n  \"featureset\": {\n    \"name\": \"product_features\",\n    \"features\": [\n      {\n        \"name\": \"title_bm25\",\n        \"params\": [\"keywords\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"match\": {\n            \"title\": \"{{keywords}}\"\n          }\n        }\n      },\n      {\n        \"name\": \"description_bm25\",\n        \"params\": [\"keywords\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"match\": {\n            \"description\": \"{{keywords}}\"\n          }\n        }\n      },\n      {\n        \"name\": \"recency_score\",\n        \"params\": [],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"function_score\": {\n            \"query\": { \"match_all\": {} },\n            \"functions\": [\n              {\n                \"gauss\": {\n                  \"created_at\": {\n                    \"origin\": \"now\",\n                    \"scale\": \"30d\",\n                    \"decay\": 0.5\n                  }\n                }\n              }\n            ],\n            \"boost_mode\": \"replace\"\n          }\n        }\n      },\n      {\n        \"name\": \"popularity_score\",\n        \"params\": [],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"function_score\": {\n            \"query\": { \"match_all\": {} },\n            \"functions\": [\n              {\n                \"field_value_factor\": {\n                  \"field\": \"sales_count\",\n                  \"modifier\": \"log1p\",\n                  \"missing\": 0\n                }\n              }\n            ],\n            \"boost_mode\": \"replace\"\n          }\n        }\n      },\n      {\n        \"name\": \"category_match\",\n        \"params\": [\"category\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"term\": {\n            \"category\": \"{{category}}\"\n          }\n        }\n      },\n      {\n        \"name\": \"title_phrase_match\",\n        \"params\": [\"keywords\"],\n        \"template_language\": \"mustache\",\n        \"template\": {\n          \"match_phrase\": {\n            \"title\": {\n              \"query\": \"{{keywords}}\",\n              \"slop\": 2\n            }\n          }\n        }\n      }\n    ]\n  }\n}\n```\n\n### Feature Logging\n\n```json\n\u002F\u002F 기존 검색 결과에 대해 feature 값 로깅\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"bool\": {\n      \"must\": [\n        {\n          \"match\": {\n            \"title\": \"무선 이어폰\"\n          }\n        }\n      ],\n      \"should\": [\n        {\n          \"sltr\": {\n            \"_name\": \"logged_features\",\n            \"featureset\": \"product_features\",\n            \"params\": {\n              \"keywords\": \"무선 이어폰\",\n              \"category\": \"electronics\"\n            }\n          }\n        }\n      ]\n    }\n  },\n  \"ext\": {\n    \"ltr_log\": {\n      \"log_specs\": {\n        \"name\": \"log_entry\",\n        \"named_query\": \"logged_features\"\n      }\n    }\n  },\n  \"size\": 100\n}\n```\n\n**로깅 응답 예시**:\n```json\n{\n  \"hits\": {\n    \"hits\": [\n      {\n        \"_id\": \"product_001\",\n        \"_score\": 12.5,\n        \"fields\": {\n          \"_ltrlog\": [\n            {\n              \"log_entry\": [\n                { \"name\": \"title_bm25\", \"value\": 8.2 },\n                { \"name\": \"description_bm25\", \"value\": 3.1 },\n                { \"name\": \"recency_score\", \"value\": 0.85 },\n                { \"name\": \"popularity_score\", \"value\": 6.7 },\n                { \"name\": \"category_match\", \"value\": 1.0 },\n                { \"name\": \"title_phrase_match\", \"value\": 5.3 }\n              ]\n            }\n          ]\n        }\n      }\n    ]\n  }\n}\n```\n\n### Named Queries로 절 매칭 디버깅·관측 (LTR feature logging과 용도 구분)\n\n위 Feature Logging의 `named_query`는 **`sltr` feature set 로깅 대상을 지정하는 LTR 학습 파이프라인 전용** 메커니즘이다. 이와 별개로, 일반 `bool` 절에 `_name`을 부착해 응답 `hit.matched_queries`로 **어떤 절이 매칭을 일으켰는지** 관측하는 디버깅·운영 용도가 있다. 둘은 이름이 비슷하나 목적이 다르다 — 전자는 모델 학습용 feature 값 추출, 후자는 매칭 경로 진단·ROI 데이터화.\n\n**절별 `_name` 부착으로 매칭 경로 집계**:\n```json\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"bool\": {\n      \"should\": [\n        { \"match\":        { \"title.exact\":   { \"query\": \"무선 이어폰\", \"_name\": \"exact\" } } },\n        { \"match\":        { \"title.synonym\": { \"query\": \"무선 이어폰\", \"_name\": \"synonym\" } } },\n        { \"fuzzy\":        { \"title\":         { \"value\": \"이어폰\", \"fuzziness\": \"AUTO\", \"_name\": \"typo\" } } },\n        { \"match_phrase\": { \"title\":         { \"query\": \"무선 이어폰\", \"boost\": 2, \"_name\": \"boost\" } } }\n      ]\n    }\n  },\n  \"include_named_queries_score\": true\n}\n```\n\n**응답** (ES 8.8.0+에서 `include_named_queries_score:true`이면 `matched_queries`가 List → `{name: score}` Map으로 변경):\n```json\n{\n  \"hits\": {\n    \"hits\": [\n      {\n        \"_id\": \"product_001\",\n        \"_score\": 18.4,\n        \"matched_queries\": { \"exact\": 12.1, \"synonym\": 0.0, \"boost\": 6.3 }\n      }\n    ]\n  }\n}\n```\n- 절별 매칭 빈도를 집계하면 동의어\u002F오타 절이 실제로 얼마나 매칭에 기여하는지 **ROI 데이터화**(예: `synonym` 절이 거의 매칭 안 되면 동의어 사전 점검).\n- ES 8.8.0 미만에서는 `matched_queries`가 매칭된 이름의 List(점수 없음)만 반환 — 절 기여 점수 관측은 8.8.0+ 필요.\n\n**함정**:\n- `matched_queries`는 **hit마다 절 재실행 오버헤드**가 있다 → 프로덕션 상시 사용 금지, `size` 제한·표본 추출로만.\n- `filter`\u002F`must_not`\u002F`constant_score`\u002F`function_score` 내부의 `_name`은 **score에 비기여**(점수 0 또는 final score와 불일치) → \"matched인데 점수 0\"을 버그로 오해하지 말 것.\n- `nested` 쿼리 안의 named query는 루트 `matched_queries`가 아니라 **`inner_hits.matched_queries`로 봐야** 정확하다(issue#46231).\n\n> 절 매칭 디버깅 증상 진입은 search-diagnostics 스킬 참조.\n\n### 모델 배포\n\n```json\n\u002F\u002F 오프라인 학습된 XGBoost 모델을 ES에 업로드\nPOST _ltr\u002F_featureset\u002Fproduct_features\u002F_createmodel\n{\n  \"model\": {\n    \"name\": \"product_ranking_v1\",\n    \"model\": {\n      \"type\": \"model\u002Fxgboost+json\",\n      \"definition\": \"{\\\"learner\\\":{\\\"feature_names\\\":[\\\"title_bm25\\\",\\\"description_bm25\\\",\\\"recency_score\\\",\\\"popularity_score\\\",\\\"category_match\\\",\\\"title_phrase_match\\\"],\\\"feature_types\\\":[\\\"float\\\",\\\"float\\\",\\\"float\\\",\\\"float\\\",\\\"float\\\",\\\"float\\\"],\\\"gradient_booster\\\":{\\\"name\\\":\\\"gbtree\\\",\\\"model\\\":{\\\"trees\\\":[{\\\"tree_param\\\":{\\\"num_nodes\\\":\\\"7\\\"},\\\"id\\\":0,\\\"tree_weights\\\":[1.0]}]}},\\\"learner_model_param\\\":{\\\"base_score\\\":\\\"0.5\\\",\\\"num_class\\\":\\\"0\\\",\\\"num_feature\\\":\\\"6\\\"},\\\"objective\\\":{\\\"name\\\":\\\"rank:ndcg\\\",\\\"rank_objective\\\":{\\\"delta_ndcg\\\":\\\"true\\\"}}}}\"\n    }\n  }\n}\n```\n\n### LTR Rescore 적용\n\n```json\n\u002F\u002F 학습된 모델을 rescore 단계에서 적용\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"multi_match\": {\n      \"query\": \"무선 이어폰\",\n      \"fields\": [\"title^3\", \"description\"],\n      \"type\": \"best_fields\"\n    }\n  },\n  \"rescore\": {\n    \"window_size\": 200,\n    \"query\": {\n      \"rescore_query\": {\n        \"sltr\": {\n          \"params\": {\n            \"keywords\": \"무선 이어폰\",\n            \"category\": \"electronics\"\n          },\n          \"model\": \"product_ranking_v1\"\n        }\n      },\n      \"query_weight\": 0,\n      \"rescore_query_weight\": 1,\n      \"score_mode\": \"total\"\n    }\n  }\n}\n```\n\n### LTR Training Workflow\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                    LTR Training Pipeline                        │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  Step 1. Feature Set 생성                                       │\n│  POST _ltr\u002F_featureset\u002Fproduct_features                         │\n│  → feature 템플릿 정의 (BM25, recency, popularity 등)            │\n│                                                                 │\n│  Step 2. Feature Logging                                        │\n│  POST \u002Fproducts\u002F_search (with sltr + ltr_log)                   │\n│  → 검색 결과별 feature 값 수집                                    │\n│                                                                 │\n│  Step 3. 학습 데이터 Export                                      │\n│  feature 로그 + relevance judgment → LibSVM\u002FCSV 형식 변환        │\n│  → qid:1 1:8.2 2:3.1 3:0.85 4:6.7 5:1.0 6:5.3 # label=4      │\n│                                                                 │\n│  Step 4. 오프라인 학습                                           │\n│  XGBoost\u002FLambdaMART rank:ndcg 목적함수로 학습                    │\n│  → 교차 검증, NDCG@10 평가, 하이퍼파라미터 튜닝                    │\n│                                                                 │\n│  Step 5. 모델 업로드                                             │\n│  POST _ltr\u002F_featureset\u002F_createmodel                              │\n│  → xgboost+json 형식으로 ES에 배포                               │\n│                                                                 │\n│  Step 6. Rescore로 배포                                          │\n│  rescore.query.rescore_query.sltr = 학습된 모델                  │\n│  → A\u002FB 테스트로 기존 대비 NDCG 개선 확인                          │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Kotlin LtrSearchService\n\n```kotlin\n@Service\nclass LtrSearchService(\n    private val client: ElasticsearchClient\n) {\n    \u002F**\n     * LTR 모델을 rescore에 적용한 검색.\n     * elasticsearch-java에서 sltr 쿼리를 직접 지원하지 않으므로\n     * withJson escape hatch를 사용합니다.\n     *\u002F\n    fun searchWithLtr(\n        keyword: String,\n        category: String? = null,\n        modelName: String = \"product_ranking_v1\",\n        windowSize: Int = 200\n    ): SearchResult {\n        val ltrParams = buildMap\u003CString, Any> {\n            put(\"keywords\", keyword)\n            category?.let { put(\"category\", it) }\n        }\n\n        val rescoreJson = \"\"\"\n        {\n          \"window_size\": $windowSize,\n          \"query\": {\n            \"rescore_query\": {\n              \"sltr\": {\n                \"params\": ${objectMapper.writeValueAsString(ltrParams)},\n                \"model\": \"$modelName\"\n              }\n            },\n            \"query_weight\": 0,\n            \"rescore_query_weight\": 1,\n            \"score_mode\": \"total\"\n          }\n        }\n        \"\"\".trimIndent()\n\n        \u002F\u002F first-pass 쿼리 + LTR rescore (withJson으로 rescore 부분 주입)\n        val queryJson = \"\"\"\n        {\n          \"query\": {\n            \"bool\": {\n              \"must\": [\n                {\n                  \"multi_match\": {\n                    \"query\": ${objectMapper.writeValueAsString(keyword)},\n                    \"fields\": [\"title^3\", \"title.search^2\", \"description\"],\n                    \"type\": \"best_fields\"\n                  }\n                }\n              ]\n              ${category?.let {\n                \"\"\", \"filter\": [{\"term\": {\"category\": ${objectMapper.writeValueAsString(it)}}}]\"\"\"\n              } ?: \"\"}\n            }\n          },\n          \"rescore\": $rescoreJson,\n          \"size\": 20\n        }\n        \"\"\".trimIndent()\n\n        val request = SearchRequest.of { s ->\n            s.index(\"products\").withJson(StringReader(queryJson))\n        }\n\n        val response = client.search(request, Product::class.java)\n\n        return SearchResult(\n            items = response.hits().hits().mapNotNull { it.source() },\n            total = response.hits().total()?.value() ?: 0,\n            maxScore = response.hits().maxScore()\n        )\n    }\n\n    companion object {\n        private val objectMapper = ObjectMapper()\n    }\n}\n```\n\n## 5. Query Understanding\n\n### Query Classification\n\n| Query Type | 특성 | 예시 | Routing 전략 |\n|-----------|------|------|-------------|\n| **Navigational** | 특정 상품\u002F브랜드 탐색 | \"갤럭시 버즈3\", \"애플 에어팟\" | exact match boost + brand filter |\n| **Informational** | 정보 탐색, 비교 | \"무선 이어폰 추천\", \"노트북 비교\" | broad match + aggregation + 리뷰 |\n| **Transactional** | 구매 의도 명확 | \"이어폰 최저가\", \"무선 이어폰 할인\" | price sort + deal filter + availability |\n| **Category** | 카테고리 브라우징 | \"이어폰\", \"신발\" | category landing + facets |\n| **Long-tail** | 구체적 속성 조합 | \"삼성 무선 이어폰 노이즈캔슬링 화이트\" | 모든 조건 필터 + relaxation 대비 |\n\n### Query Expansion via significant_terms\n\n```json\n\u002F\u002F \"이어폰\" 검색 사용자가 자주 함께 사용하는 용어 추출\nPOST \u002Fsearch_logs\u002F_search\n{\n  \"query\": {\n    \"match\": {\n      \"query_text\": \"이어폰\"\n    }\n  },\n  \"aggregations\": {\n    \"significant_related_terms\": {\n      \"significant_terms\": {\n        \"field\": \"clicked_product_tags\",\n        \"size\": 10,\n        \"min_doc_count\": 5,\n        \"mutual_information\": {\n          \"include_negatives\": false,\n          \"background_is_superset\": true\n        }\n      }\n    }\n  },\n  \"size\": 0\n}\n\u002F\u002F 결과: [\"블루투스\", \"노이즈캔슬링\", \"무선\", \"갤럭시버즈\", \"에어팟\"]\n\u002F\u002F → 쿼리 확장 또는 관련 검색어 추천에 활용\n```\n\n### Spell Correction (Phrase Suggester)\n\n```json\n\u002F\u002F 한국어 오타 교정: phrase suggester\nPOST \u002Fproducts\u002F_search\n{\n  \"suggest\": {\n    \"spell_check\": {\n      \"text\": \"삼섯 무선 이어폰\",\n      \"phrase\": {\n        \"field\": \"title.trigram\",\n        \"size\": 3,\n        \"gram_size\": 3,\n        \"confidence\": 1.0,\n        \"direct_generator\": [\n          {\n            \"field\": \"title.trigram\",\n            \"suggest_mode\": \"always\",\n            \"min_word_length\": 2\n          }\n        ],\n        \"collate\": {\n          \"query\": {\n            \"source\": {\n              \"match\": {\n                \"title\": \"{{suggestion}}\"\n              }\n            }\n          },\n          \"prune\": true\n        },\n        \"highlight\": {\n          \"pre_tag\": \"\u003Cem>\",\n          \"post_tag\": \"\u003C\u002Fem>\"\n        }\n      }\n    }\n  }\n}\n```\n\n**Trigram 분석기 설정** (spell correction 전제 조건):\n\n```json\nPUT \u002Fproducts\n{\n  \"settings\": {\n    \"analysis\": {\n      \"filter\": {\n        \"trigram_filter\": {\n          \"type\": \"ngram\",\n          \"min_gram\": 3,\n          \"max_gram\": 3\n        }\n      },\n      \"analyzer\": {\n        \"trigram_analyzer\": {\n          \"type\": \"custom\",\n          \"tokenizer\": \"standard\",\n          \"filter\": [\"lowercase\", \"trigram_filter\"]\n        }\n      }\n    }\n  },\n  \"mappings\": {\n    \"properties\": {\n      \"title\": {\n        \"type\": \"text\",\n        \"fields\": {\n          \"trigram\": {\n            \"type\": \"text\",\n            \"analyzer\": \"trigram_analyzer\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n### Query Relaxation Cascade (Kotlin)\n\n```kotlin\n@Service\nclass QueryRelaxationService(\n    private val client: ElasticsearchClient\n) {\n    \u002F**\n     * 쿼리 완화 캐스케이드: 결과가 부족하면 점진적으로 필터를 완화합니다.\n     *\n     * Level 0: 전체 필터 적용 (brand + category + attributes)\n     * Level 1: attribute 필터 제거\n     * Level 2: brand 필터 제거\n     * Level 3: category 필터 제거\n     * Level 4: fuzzy 매칭으로 전환\n     *\u002F\n    suspend fun searchWithRelaxation(\n        keyword: String,\n        brand: String? = null,\n        category: String? = null,\n        attributes: Map\u003CString, String> = emptyMap(),\n        minResults: Int = 5\n    ): RelaxedSearchResult = withContext(Dispatchers.IO) {\n\n        \u002F\u002F Level 0: 전체 필터\n        val level0 = executeSearch(keyword, brand, category, attributes, fuzzy = false)\n        if (level0.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level0.items, relaxationLevel = 0)\n        }\n\n        \u002F\u002F Level 1: attribute 필터 제거\n        val level1 = executeSearch(keyword, brand, category, emptyMap(), fuzzy = false)\n        if (level1.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level1.items, relaxationLevel = 1)\n        }\n\n        \u002F\u002F Level 2: brand 필터 제거\n        val level2 = executeSearch(keyword, null, category, emptyMap(), fuzzy = false)\n        if (level2.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level2.items, relaxationLevel = 2)\n        }\n\n        \u002F\u002F Level 3: category 필터 제거\n        val level3 = executeSearch(keyword, null, null, emptyMap(), fuzzy = false)\n        if (level3.totalHits >= minResults) {\n            return@withContext RelaxedSearchResult(level3.items, relaxationLevel = 3)\n        }\n\n        \u002F\u002F Level 4: fuzzy 매칭\n        val level4 = executeSearch(keyword, null, null, emptyMap(), fuzzy = true)\n        return@withContext RelaxedSearchResult(level4.items, relaxationLevel = 4)\n    }\n\n    private fun executeSearch(\n        keyword: String,\n        brand: String?,\n        category: String?,\n        attributes: Map\u003CString, String>,\n        fuzzy: Boolean\n    ): RawSearchResult {\n        val response = client.search({ s ->\n            s.index(\"products\")\n                .query { q ->\n                    q.bool { b ->\n                        \u002F\u002F 텍스트 매칭\n                        b.must { m ->\n                            if (fuzzy) {\n                                m.match { mt ->\n                                    mt.field(\"title\").query(keyword).fuzziness(\"AUTO\")\n                                }\n                            } else {\n                                m.multiMatch { mm ->\n                                    mm.query(keyword)\n                                        .fields(\"title^3\", \"description\")\n                                        .type(TextQueryType.BestFields)\n                                }\n                            }\n                        }\n                        \u002F\u002F 조건부 필터\n                        brand?.let { b0 ->\n                            b.filter { f -> f.term { t -> t.field(\"brand\").value(b0) } }\n                        }\n                        category?.let { c ->\n                            b.filter { f -> f.term { t -> t.field(\"category\").value(c) } }\n                        }\n                        attributes.forEach { (key, value) ->\n                            b.filter { f ->\n                                f.term { t -> t.field(\"attributes.$key\").value(value) }\n                            }\n                        }\n                        b\n                    }\n                }\n                .size(20)\n        }, Product::class.java)\n\n        val hits = response.hits().hits()\n        return RawSearchResult(\n            items = hits.mapNotNull { it.source() },\n            totalHits = response.hits().total()?.value() ?: 0\n        )\n    }\n}\n\ndata class RelaxedSearchResult(\n    val items: List\u003CProduct>,\n    val relaxationLevel: Int  \u002F\u002F 0=exact, 1=no-attr, 2=no-brand, 3=no-category, 4=fuzzy\n)\n\nprivate data class RawSearchResult(\n    val items: List\u003CProduct>,\n    val totalHits: Long\n)\n```\n\n### Intent Detection Routing\n\n```kotlin\n@Component\nclass QueryIntentDetector {\n\n    private val navigationalPatterns = listOf(\n        Regex(\"^(삼성|애플|소니|LG|나이키|아디다스)\\\\s+.+\"),  \u002F\u002F 브랜드 + 상품\n        Regex(\".+\\\\s+(공식|정품|오리지널)$\"),                  \u002F\u002F 공식\u002F정품 의도\n        Regex(\"^[A-Za-z0-9\\\\-]+$\"),                          \u002F\u002F 모델명 (영문숫자)\n    )\n\n    private val transactionalPatterns = listOf(\n        Regex(\".+(최저가|할인|세일|쿠폰|특가)$\"),\n        Regex(\"^(싸게|저렴하게)\\\\s+.+\"),\n        Regex(\".+\\\\s+(구매|주문|배송)$\"),\n    )\n\n    private val informationalPatterns = listOf(\n        Regex(\".+(추천|비교|리뷰|후기|차이)$\"),\n        Regex(\"^(어떤|무슨|뭐가|뭘)\\\\s+.+\"),\n        Regex(\".+(좋은지|나은지|어때)$\"),\n    )\n\n    fun detectIntent(query: String): QueryIntent {\n        val trimmed = query.trim()\n\n        return when {\n            navigationalPatterns.any { it.matches(trimmed) } -> QueryIntent.NAVIGATIONAL\n            transactionalPatterns.any { it.matches(trimmed) } -> QueryIntent.TRANSACTIONAL\n            informationalPatterns.any { it.matches(trimmed) } -> QueryIntent.INFORMATIONAL\n            trimmed.split(\"\\\\s+\".toRegex()).size \u003C= 1 -> QueryIntent.CATEGORY\n            else -> QueryIntent.INFORMATIONAL\n        }\n    }\n\n    fun routeSearch(query: String): SearchStrategy {\n        return when (detectIntent(query)) {\n            QueryIntent.NAVIGATIONAL -> SearchStrategy(\n                boostExactMatch = true,\n                enableBrandFilter = true,\n                sortBy = SortBy.RELEVANCE,\n                rescoreWindow = 50\n            )\n            QueryIntent.TRANSACTIONAL -> SearchStrategy(\n                boostExactMatch = false,\n                enableBrandFilter = false,\n                sortBy = SortBy.PRICE_ASC,\n                rescoreWindow = 0,  \u002F\u002F rescore 불필요\n                filterAvailable = true\n            )\n            QueryIntent.INFORMATIONAL -> SearchStrategy(\n                boostExactMatch = false,\n                enableBrandFilter = false,\n                sortBy = SortBy.RELEVANCE,\n                rescoreWindow = 100,\n                includeAggregations = true\n            )\n            QueryIntent.CATEGORY -> SearchStrategy(\n                boostExactMatch = false,\n                enableBrandFilter = false,\n                sortBy = SortBy.POPULARITY,\n                rescoreWindow = 200,\n                includeFacets = true\n            )\n        }\n    }\n}\n\nenum class QueryIntent { NAVIGATIONAL, TRANSACTIONAL, INFORMATIONAL, CATEGORY }\n\ndata class SearchStrategy(\n    val boostExactMatch: Boolean = false,\n    val enableBrandFilter: Boolean = false,\n    val sortBy: SortBy = SortBy.RELEVANCE,\n    val rescoreWindow: Int = 100,\n    val filterAvailable: Boolean = false,\n    val includeAggregations: Boolean = false,\n    val includeFacets: Boolean = false\n)\n\nenum class SortBy { RELEVANCE, PRICE_ASC, PRICE_DESC, POPULARITY, RECENCY }\n```\n\n## 6. Result Diversification\n\n### field_collapse로 브랜드 다양성 확보\n\n```json\n\u002F\u002F 브랜드당 1개만 메인 노출, 나머지는 inner_hits로 접근\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"match\": { \"title\": \"무선 이어폰\" }\n  },\n  \"collapse\": {\n    \"field\": \"brand\",\n    \"inner_hits\": {\n      \"name\": \"brand_variants\",\n      \"size\": 3,\n      \"sort\": [{ \"_score\": \"desc\" }],\n      \"_source\": [\"title\", \"price\", \"brand\"]\n    },\n    \"max_concurrent_group_searches\": 4\n  },\n  \"sort\": [{ \"_score\": \"desc\" }],\n  \"size\": 10\n}\n```\n\n**응답 구조**:\n```json\n{\n  \"hits\": {\n    \"hits\": [\n      {\n        \"_source\": { \"title\": \"삼성 갤럭시 버즈3\", \"brand\": \"삼성\" },\n        \"inner_hits\": {\n          \"brand_variants\": {\n            \"hits\": {\n              \"hits\": [\n                { \"_source\": { \"title\": \"삼성 갤럭시 버즈3 프로\", \"brand\": \"삼성\" } },\n                { \"_source\": { \"title\": \"삼성 갤럭시 버즈FE\", \"brand\": \"삼성\" } }\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"_source\": { \"title\": \"애플 에어팟 프로 2\", \"brand\": \"애플\" }\n      }\n    ]\n  }\n}\n```\n\n### Pinned Queries로 머천다이징\n\n```json\n\u002F\u002F 특정 상품을 최상위에 고정 (프로모션, 광고, 큐레이션)\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"pinned\": {\n      \"ids\": [\"promo_001\", \"promo_002\", \"promo_003\"],\n      \"organic\": {\n        \"multi_match\": {\n          \"query\": \"무선 이어폰\",\n          \"fields\": [\"title^3\", \"description\"]\n        }\n      }\n    }\n  },\n  \"size\": 20\n}\n```\n\n**pinned query 동작**:\n- `ids`에 지정된 문서가 항상 최상위에 위치\n- 나머지는 `organic` 쿼리 결과 순서대로 배치\n- 지정된 ID의 문서가 존재하지 않으면 무시됨\n- 최대 100개까지 pinning 가능 (성능 고려)\n\n### MMR (Maximal Marginal Relevance) Post-Processing\n\nES에서 직접 MMR을 지원하지 않으므로 애플리케이션 레벨에서 구현합니다.\n\n```kotlin\n\u002F**\n * MMR (Maximal Marginal Relevance) 기반 결과 다양화.\n * MMR = λ * Sim(d, q) - (1-λ) * max(Sim(d, d_selected))\n *\n * @param lambda 관련성 vs 다양성 가중치 (1.0 = 관련성만, 0.0 = 다양성만)\n *\u002F\nclass MmrDiversifier(\n    private val lambda: Double = 0.7\n) {\n    fun diversify(\n        candidates: List\u003CScoredDocument>,\n        topK: Int = 20\n    ): List\u003CScoredDocument> {\n        if (candidates.size \u003C= topK) return candidates\n\n        val selected = mutableListOf\u003CScoredDocument>()\n        val remaining = candidates.toMutableList()\n\n        \u002F\u002F 첫 번째는 최고 스코어 문서\n        val first = remaining.maxByOrNull { it.score } ?: return emptyList()\n        selected.add(first)\n        remaining.remove(first)\n\n        \u002F\u002F 나머지는 MMR 기준으로 선택\n        while (selected.size \u003C topK && remaining.isNotEmpty()) {\n            val best = remaining.maxByOrNull { candidate ->\n                val relevance = candidate.score \u002F (candidates.maxOf { it.score })\n                val maxSimilarity = selected.maxOf { sel ->\n                    computeSimilarity(candidate, sel)\n                }\n                lambda * relevance - (1 - lambda) * maxSimilarity\n            } ?: break\n\n            selected.add(best)\n            remaining.remove(best)\n        }\n\n        return selected\n    }\n\n    \u002F**\n     * 카테고리 + 브랜드 기반 유사도 계산 (0.0 ~ 1.0).\n     * 벡터 기반 유사도가 필요하면 코사인 유사도로 교체.\n     *\u002F\n    private fun computeSimilarity(a: ScoredDocument, b: ScoredDocument): Double {\n        var similarity = 0.0\n        if (a.category == b.category) similarity += 0.5\n        if (a.brand == b.brand) similarity += 0.3\n        if (a.priceRange == b.priceRange) similarity += 0.2\n        return similarity\n    }\n}\n\ndata class ScoredDocument(\n    val id: String,\n    val score: Double,\n    val category: String,\n    val brand: String,\n    val priceRange: String  \u002F\u002F \"low\", \"mid\", \"high\"\n)\n```\n\n## 7. Personalization in Search\n\n### function_score with User Profile Boosting\n\n```json\n\u002F\u002F 사용자 선호 기반 개인화 점수 가산\nPOST \u002Fproducts\u002F_search\n{\n  \"query\": {\n    \"function_score\": {\n      \"query\": {\n        \"multi_match\": {\n          \"query\": \"이어폰\",\n          \"fields\": [\"title^3\", \"description\"]\n        }\n      },\n      \"functions\": [\n        {\n          \"filter\": {\n            \"term\": { \"category\": \"electronics\" }\n          },\n          \"weight\": 1.5,\n          \"_comment\": \"사용자가 선호하는 카테고리 boost\"\n        },\n        {\n          \"filter\": {\n            \"terms\": { \"brand\": [\"삼성\", \"애플\"] }\n          },\n          \"weight\": 1.3,\n          \"_comment\": \"최근 30일 내 클릭\u002F구매한 브랜드 boost\"\n        },\n        {\n          \"filter\": {\n            \"range\": {\n              \"price\": {\n                \"gte\": 100000,\n                \"lte\": 300000\n              }\n            }\n          },\n          \"weight\": 1.2,\n          \"_comment\": \"사용자의 주요 가격대 boost\"\n        },\n        {\n          \"filter\": {\n            \"term\": { \"is_free_shipping\": true }\n          },\n          \"weight\": 1.1,\n          \"_comment\": \"무료배송 선호 사용자 boost\"\n        }\n      ],\n      \"score_mode\": \"sum\",\n      \"boost_mode\": \"multiply\",\n      \"max_boost\": 3.0\n    }\n  },\n  \"size\": 20\n}\n```\n\n### Session-Based Personalization Signals\n\n| Signal | 수집 방법 | 적용 방식 | 반영 시점 |\n|--------|----------|----------|----------|\n| **최근 본 카테고리** | 세션 내 클릭 로그 | category boost (weight 1.2~1.5) | 실시간 |\n| **최근 본 브랜드** | 세션 내 클릭 로그 | brand boost (weight 1.1~1.3) | 실시간 |\n| **가격대 선호** | 클릭\u002F구매 가격 분포 | price range filter boost | 세션 내 |\n| **카트 내 상품** | 장바구니 데이터 | 보완재\u002F대체재 boost | 실시간 |\n| **검색 히스토리** | 세션 내 이전 쿼리 | query expansion 재료 | 세션 내 |\n\n```kotlin\n@Service\nclass PersonalizedSearchService(\n    private val client: ElasticsearchClient,\n    private val userProfileService: UserProfileService\n) {\n    suspend fun personalizedSearch(\n        keyword: String,\n        userId: String\n    ): SearchResult = withContext(Dispatchers.IO) {\n        val profile = userProfileService.getProfile(userId)\n\n        val functions = mutableListOf\u003CString>()\n\n        \u002F\u002F 선호 카테고리 boost\n        profile.preferredCategories.takeIf { it.isNotEmpty() }?.let { cats ->\n            functions.add(\"\"\"\n                {\n                  \"filter\": { \"terms\": { \"category\": ${objectMapper.writeValueAsString(cats)} } },\n                  \"weight\": 1.4\n                }\n            \"\"\".trimIndent())\n        }\n\n        \u002F\u002F 선호 브랜드 boost\n        profile.preferredBrands.takeIf { it.isNotEmpty() }?.let { brands ->\n            functions.add(\"\"\"\n                {\n                  \"filter\": { \"terms\": { \"brand\": ${objectMapper.writeValueAsString(brands)} } },\n                  \"weight\": 1.3\n                }\n            \"\"\".trimIndent())\n        }\n\n        \u002F\u002F 가격대 boost\n        profile.priceRange?.let { range ->\n            functions.add(\"\"\"\n                {\n                  \"filter\": { \"range\": { \"price\": { \"gte\": ${range.first}, \"lte\": ${range.last} } } },\n                  \"weight\": 1.2\n                }\n            \"\"\".trimIndent())\n        }\n\n        val queryJson = \"\"\"\n        {\n          \"query\": {\n            \"function_score\": {\n              \"query\": {\n                \"multi_match\": {\n                  \"query\": ${objectMapper.writeValueAsString(keyword)},\n                  \"fields\": [\"title^3\", \"description\"],\n                  \"type\": \"best_fields\"\n                }\n              },\n              \"functions\": [${functions.joinToString(\",\")}],\n              \"score_mode\": \"sum\",\n              \"boost_mode\": \"multiply\",\n              \"max_boost\": 3.0\n            }\n          },\n          \"size\": 20\n        }\n        \"\"\".trimIndent()\n\n        val request = SearchRequest.of { s ->\n            s.index(\"products\").withJson(StringReader(queryJson))\n        }\n        val response = client.search(request, Product::class.java)\n\n        SearchResult(\n            items = response.hits().hits().mapNotNull { it.source() },\n            total = response.hits().total()?.value() ?: 0,\n            maxScore = response.hits().maxScore()\n        )\n    }\n\n    companion object {\n        private val objectMapper = ObjectMapper()\n    }\n}\n```\n\n### Collaborative Filtering as Document-Level Features\n\n| 방식 | 설명 | 구현 | 장단점 |\n|------|------|------|--------|\n| **Pre-computed CF score** | 사용자-상품 유사도를 사전 계산하여 문서 필드에 저장 | 배치로 `cf_score_segment_{A,B,C}` 인덱싱 | 검색 시 빠름, 갱신 주기에 종속 |\n| **User segment boost** | 사용자 세그먼트별 인기 상품에 가중치 | `function_score` filter by segment | 구현 간단, 개인화 수준 낮음 |\n| **Real-time embedding** | 사용자\u002F상품 임베딩 벡터 유사도 | kNN + RRF retriever 활용 | 정밀하지만 인프라 복잡 |\n\n### Privacy 고려사항\n\n| 원칙 | 설명 | 구현 방법 |\n|------|------|----------|\n| **데이터 최소화** | 개인화에 필요한 최소 데이터만 수집 | 카테고리\u002F브랜드 수준 집계, 개별 상품 기록 X |\n| **익명화** | 사용자 식별 불가능하게 처리 | 해시된 세션 ID, 세그먼트 기반 집계 |\n| **TTL** | 개인화 데이터 보존 기간 제한 | 세션 데이터 24h, 프로필 90일 TTL |\n| **Opt-out** | 사용자가 개인화를 비활성화할 수 있어야 함 | API 파라미터로 `personalized=false` 지원 |\n| **쿼리 로그 분리** | 검색 로그에 PII 미포함 | 사용자 ID 대신 익명 세션 ID만 기록 |\n\n## 8. Multi-Stage Retrieval Architecture\n\n### 아키텍처 다이어그램\n\n```\n┌─────────────────────────────────────────────────────────────────────────┐\n│                    Multi-Stage Retrieval Pipeline                        │\n├─────────────────────────────────────────────────────────────────────────┤\n│                                                                         │\n│  ┌─────────────────┐                                                    │\n│  │   User Query     │                                                    │\n│  └────────┬────────┘                                                    │\n│           ▼                                                             │\n│  ┌─────────────────┐  \u003C 10ms                                            │\n│  │  L0: Query       │  spell check, intent detection,                   │\n│  │  Understanding   │  query expansion, 한영 변환                        │\n│  └────────┬────────┘                                                    │\n│           ▼                                                             │\n│  ┌─────────────────┐  \u003C 50ms         ┌──────────────────┐              │\n│  │  L1: Candidate   │  BM25 + kNN ──→│ ~1000 candidates │              │\n│  │  Generation      │  broad recall   └────────┬─────────┘              │\n│  └────────┬────────┘                           │                        │\n│           ▼                                    ▼                        │\n│  ┌─────────────────┐  \u003C 20ms         ┌──────────────────┐              │\n│  │  L2: Lightweight │  rescore        │ ~200 candidates  │              │\n│  │  Scoring         │  phrase boost   └────────┬─────────┘              │\n│  └────────┬────────┘                           │                        │\n│           ▼                                    ▼                        │\n│  ┌─────────────────┐  \u003C 100ms        ┌──────────────────┐              │\n│  │  L3: Heavy       │  LTR model \u002F   │ ~50 candidates   │              │\n│  │  Reranking       │  cross-encoder  └────────┬─────────┘              │\n│  └────────┬────────┘                           │                        │\n│           ▼                                    ▼                        │\n│  ┌─────────────────┐  \u003C 10ms         ┌──────────────────┐              │\n│  │  L4: Business    │  pinning,       │ ~20 final results│              │\n│  │  Rules           │  diversity,     └──────────────────┘              │\n│  └────────┬────────┘  personalization                                   │\n│           ▼                                                             │\n│  ┌─────────────────┐                                                    │\n│  │   Final Results  │  Total latency \u003C 200ms                            │\n│  └─────────────────┘                                                    │\n│                                                                         │\n└─────────────────────────────────────────────────────────────────────────┘\n```\n\n### 단계별 레이턴시 버짓\n\n| Stage | 목표 레이턴시 | 문서 수 | 역할 | 주요 기술 |\n|-------|-------------|---------|------|----------|\n| **L0** | \u003C 10ms | - | 쿼리 전처리 | spell check, intent, 한영 변환 |\n| **L1** | \u003C 50ms | 전체 → ~1000 | 후보 생성 | BM25, kNN, RRF |\n| **L2** | \u003C 20ms | ~1000 → ~200 | 경량 재순위화 | rescore (phrase, recency) |\n| **L3** | \u003C 100ms | ~200 → ~50 | 정밀 재순위화 | LTR, cross-encoder |\n| **L4** | \u003C 10ms | ~50 → ~20 | 비즈니스 규칙 | pinning, diversity, personalization |\n| **Total** | **\u003C 200ms** | - | 전체 파이프라인 | - |\n\n### 단계 추가 판단 기준\n\n| 현재 상태 | 추가할 단계 | 판단 기준 |\n|----------|-----------|----------|\n| L1만 운영 | L0 (Query Understanding) | zero-result rate > 5%, 오타 비율 높음 |\n| L0 + L1 | L2 (Rescore) | exact match가 상위에 안 나옴, NDCG 정체 |\n| L0 + L1 + L2 | L3 (LTR) | 충분한 클릭 로그 확보, NDCG 오프라인 평가 준비 |\n| L0~L3 | L4 (Business Rules) | 머천다이징\u002F프로모션 요구, 결과 다양성 민원 |\n\n### Kotlin MultiStageSearchService\n\n```kotlin\n@Service\nclass MultiStageSearchService(\n    private val queryUnderstanding: QueryUnderstandingService,\n    private val candidateGenerator: CandidateGenerationService,\n    private val lightweightScorer: LightweightScoringService,\n    private val heavyReranker: HeavyRerankingService,\n    private val businessRules: BusinessRulesService,\n    private val meterRegistry: MeterRegistry\n) {\n    private val logger = LoggerFactory.getLogger(javaClass)\n\n    \u002F**\n     * 다단계 검색 파이프라인 실행.\n     * 각 단계의 레이턴시를 측정하고, 실패 시 이전 단계 결과로 fallback.\n     *\u002F\n    suspend fun search(request: SearchRequest): SearchResponse {\n        val timer = Timer.start(meterRegistry)\n\n        \u002F\u002F ── L0: Query Understanding ──\n        val l0Result = measureStage(\"l0_query_understanding\") {\n            try {\n                queryUnderstanding.process(request.query)\n            } catch (e: Exception) {\n                logger.warn(\"L0 failed, using raw query\", e)\n                QueryUnderstandingResult(\n                    originalQuery = request.query,\n                    processedQuery = request.query,\n                    intent = QueryIntent.INFORMATIONAL,\n                    corrections = emptyList()\n                )\n            }\n        }\n        logger.debug(\"L0 result: intent={}, corrected={}\", l0Result.intent, l0Result.processedQuery)\n\n        \u002F\u002F ── L1: Candidate Generation ──\n        val l1Result = measureStage(\"l1_candidate_generation\") {\n            candidateGenerator.generate(\n                query = l0Result.processedQuery,\n                intent = l0Result.intent,\n                filters = request.filters,\n                maxCandidates = 1000\n            )\n        }\n        logger.debug(\"L1 candidates: {} docs\", l1Result.size)\n\n        if (l1Result.isEmpty()) {\n            timer.stop(meterRegistry.timer(\"search.pipeline.total\", \"status\", \"no_results\"))\n            return SearchResponse(\n                items = emptyList(),\n                total = 0,\n                metadata = SearchMetadata(\n                    relaxationLevel = null,\n                    corrections = l0Result.corrections\n                )\n            )\n        }\n\n        \u002F\u002F ── L2: Lightweight Scoring ──\n        val l2Result = measureStage(\"l2_lightweight_scoring\") {\n            try {\n                lightweightScorer.score(\n                    candidates = l1Result,\n                    query = l0Result.processedQuery,\n                    windowSize = 200\n                )\n            } catch (e: Exception) {\n                logger.warn(\"L2 failed, using L1 results\", e)\n                l1Result.take(200)\n            }\n        }\n        logger.debug(\"L2 scored: {} docs\", l2Result.size)\n\n        \u002F\u002F ── L3: Heavy Reranking ──\n        val l3Result = measureStage(\"l3_heavy_reranking\") {\n            try {\n                heavyReranker.rerank(\n                    candidates = l2Result,\n                    query = l0Result.processedQuery,\n                    intent = l0Result.intent,\n                    topK = 50\n                )\n            } catch (e: Exception) {\n                logger.warn(\"L3 failed, using L2 results\", e)\n                l2Result.take(50)\n            }\n        }\n        logger.debug(\"L3 reranked: {} docs\", l3Result.size)\n\n        \u002F\u002F ── L4: Business Rules ──\n        val l4Result = measureStage(\"l4_business_rules\") {\n            try {\n                businessRules.apply(\n                    candidates = l3Result,\n                    userId = request.userId,\n                    query = l0Result.processedQuery,\n                    intent = l0Result.intent,\n                    topK = request.size\n                )\n            } catch (e: Exception) {\n                logger.warn(\"L4 failed, using L3 results\", e)\n                l3Result.take(request.size)\n            }\n        }\n\n        timer.stop(meterRegistry.timer(\"search.pipeline.total\", \"status\", \"success\"))\n\n        return SearchResponse(\n            items = l4Result,\n            total = l1Result.size.toLong(),\n            metadata = SearchMetadata(\n                corrections = l0Result.corrections,\n                intent = l0Result.intent,\n                stages = StageMetrics(\n                    l1Candidates = l1Result.size,\n                    l2Scored = l2Result.size,\n                    l3Reranked = l3Result.size,\n                    l4Final = l4Result.size\n                )\n            )\n        )\n    }\n\n    private suspend fun \u003CT> measureStage(stageName: String, block: suspend () -> T): T {\n        val start = System.nanoTime()\n        try {\n            val result = block()\n            val durationMs = (System.nanoTime() - start) \u002F 1_000_000.0\n            meterRegistry.timer(\"search.stage.duration\", \"stage\", stageName, \"status\", \"success\")\n                .record(durationMs.toLong(), TimeUnit.MILLISECONDS)\n            return result\n        } catch (e: Exception) {\n            val durationMs = (System.nanoTime() - start) \u002F 1_000_000.0\n            meterRegistry.timer(\"search.stage.duration\", \"stage\", stageName, \"status\", \"error\")\n                .record(durationMs.toLong(), TimeUnit.MILLISECONDS)\n            throw e\n        }\n    }\n}\n\ndata class SearchRequest(\n    val query: String,\n    val filters: Map\u003CString, Any> = emptyMap(),\n    val userId: String? = null,\n    val size: Int = 20\n)\n\ndata class SearchResponse(\n    val items: List\u003CProduct>,\n    val total: Long,\n    val metadata: SearchMetadata\n)\n\ndata class SearchMetadata(\n    val corrections: List\u003CString> = emptyList(),\n    val intent: QueryIntent? = null,\n    val relaxationLevel: Int? = null,\n    val stages: StageMetrics? = null\n)\n\ndata class StageMetrics(\n    val l1Candidates: Int,\n    val l2Scored: Int,\n    val l3Reranked: Int,\n    val l4Final: Int\n)\n```\n\n## 9. Anti-Patterns\n\n| Anti-Pattern | 문제 | 올바른 접근 | Severity |\n|-------------|------|-----------|----------|\n| rescore `window_size` > 500 without justification | 레이턴시 급증, first-pass 이점 상실 | 벤치마크 기반으로 적정 window_size 결정 (보통 100~200) | **High** |\n| LTR 모델 배포 without offline evaluation | 프로덕션에서 검색 품질 저하 감지 못함 | 배포 전 NDCG@10 오프라인 평가 필수, 기존 대비 개선 증명 | **Critical** |\n| Reranking on unfiltered full result set | O(N) 스코어링으로 레이턴시 폭증 | first-pass로 후보 축소 후 상위 N개만 reranking | **High** |\n| Personalization에 PII를 query logs에 저장 | 개인정보 유출 위험, 법적 이슈 | 익명 세션 ID만 기록, PII는 별도 암호화 저장소에서 관리 | **Critical** |\n| 모든 단계를 single ES query에 구현 | 디버깅 불가, 단계별 최적화 불가 | 각 단계를 독립 컴포넌트로 분리, 단계별 메트릭 수집 | **Medium** |\n| 새 ranking 단계 추가 시 A\u002FB test 없이 배포 | 개선\u002F악화 판단 불가, 롤백 근거 없음 | 트래픽 일부에만 적용 후 NDCG, CTR 비교 | **High** |\n| Query relaxation without logging | 어떤 단계에서 결과를 찾았는지 추적 불가 | relaxation level을 응답 메타데이터에 포함, 로그로 수집 | **Medium** |\n| Cross-encoder reranking without timeout | 외부 inference 서비스 장애 시 전체 검색 중단 | 타임아웃 + fallback (L2 결과로 대체) 필수 | **High** |\n| Feature set 변경 후 모델 재학습 없이 사용 | feature 순서\u002F의미 불일치로 예측 오류 | feature set 변경 시 반드시 모델 재학습 + 재배포 | **Critical** |\n| 개인화 boost weight를 하드코딩 | 환경별 최적값 다름, 튜닝 어려움 | 설정 파일 또는 remote config로 외부화 | **Medium** |\n\n---\n\n**Remember**: 다단계 검색은 점진적으로 구축하세요. L1(BM25 검색)에서 시작하고, 오프라인 평가(NDCG)로 병목을 확인한 후 필요한 단계만 추가하세요. 모든 단계를 한 번에 도입하는 것은 복잡성만 증가시킵니다. rescore는 가장 비용 대비 효과가 큰 재순위화 방법입니다.\n",{"name":5,"description":7},"main","e9f37e996e3d2b1b6ac9c879e9dfba1223b597c3","archive\u002Fcomponents\u002Fsearch\u002Fskills\u002Fsearch-pipeline-reranking",0,"https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness","https:\u002F\u002Fdokkabei97.github.io\u002Fdokkabei-harness\u002F","https:\u002F\u002Fgithub.com\u002FDokkabei97","Contact: wkdrn970@gmail.com","2026-10-29 06:49:16",{"source":8,"skillName":5,"commands":59,"prompt":69,"zipAvailable":70},[60,63,66],{"tool":61,"command":62},"npx","npx skills add Dokkabei97\u002Fdokkabei-harness --skill search-pipeline-reranking",{"tool":64,"command":65},"bunx","bunx skills add Dokkabei97\u002Fdokkabei-harness --skill search-pipeline-reranking",{"tool":67,"command":68},"pnpm","pnpm dlx skills add Dokkabei97\u002Fdokkabei-harness --skill search-pipeline-reranking","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Farchive\u002Fcomponents\u002Fsearch\u002Fskills\u002Fsearch-pipeline-reranking\u002FSKILL.md",true,[],[73,99,129,152,173,195],{"skillNo":74,"name":75,"nameZh":6,"description":76,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":77,"categories":78,"keywords":80,"publishedAt":44},"01M6QZQTNF815AWYMAMJ3B25GY","sync-claude-md","커밋·푸시·PR\u002FMR 직전에 이번 변경이 프로젝트 CLAUDE.md 를 낡게 만드는지 검토하고, 필요할 때만 갱신 diff 를 제안한다 — 아키텍처·디렉터리 구조, 새 통합\u002FMCP, 컨벤션, 빌드·CI·도구, 주요 의존성 변화가 대상. 해당 없으면 한 줄로 끝낸다.\nReviews whether pending changes make the project CLAUDE.md stale (architecture, integrations, conventions, build\u002FCI\u002Ftooling, major dependencies) and proposes a minimal update diff only when needed.\nUse when: \"CLAUDE.md 갱신 필요해?\", \"CLAUDE.md 동기화\", 커밋\u002F푸시\u002FMR 전에 구조·도구가 바뀐 변경을 정리할 때, sync CLAUDE.md, check CLAUDE.md before commit or PR.","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Fplugins\u002Fworkflow\u002Fskills\u002Fsync-claude-md\u002FSKILL.md",[79],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},[81,82,83,84,85,86,87,88,89,90,42,91,92,93,94,95,96,97,98,29],"sync","claude md","sync claude md","MCP","commit","architecture","pr","update","ci","integrations","diff","only","minimal","project","make","reviews","pending","mr",{"skillNo":100,"name":101,"nameZh":6,"description":102,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":103,"categories":104,"keywords":111,"publishedAt":44},"01M6QZQEK94G00N955VQH9EFQN","spec-driven-dev","새 기능·프로젝트·큰 변경을 코드보다 스펙 먼저로 진행하는 게이트 워크플로(SPECIFY → PLAN → TASKS → IMPLEMENT) — 가정 명시, 측정 가능한 성공 기준, Always\u002FAsk\u002FNever 경계, 단계별 사람 검토로 범위 이탈과 재작업을 줄인다.\nSpec-before-code gated workflow for new features or significant changes: explicit assumptions, measurable success criteria, boundaries, and a human review between stages.\nUse when: \"스펙 먼저\", \"기능 명세\", \"요구사항 정리\", starting a new feature or project, a significant change with vague requirements. 단순 버그 수정·1~2줄 변경엔 불필요.","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Fplugins\u002Fworkflow\u002Fskills\u002Fspec-driven-dev\u002FSKILL.md",[105,106],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":107,"categoryName":108,"taxonomyType":109,"taxonomyTypeDesc":110},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",[112,113,114,115,116,117,118,119,120,121,122,123,124,125,94,126,32,127,128,29],"spec","spec driven","dev","spec driven dev","driven","review","workflow","change","tasks","code","plan","ask","never","human","feature","vague","features",{"skillNo":130,"name":131,"nameZh":6,"description":132,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":133,"categories":134,"keywords":135,"publishedAt":44},"01M6QZQA9F6QVGTVDMJ28YQGDV","shipping-guide","프로덕션 배포 전후 가이드 — 배포 전 체크리스트, 피처 플래그 수명주기, 단계적 롤아웃과 진행\u002F보류\u002F롤백 임계값, 롤백 계획 템플릿, 배포 후 모니터링.\nProduction shipping guide: pre-launch checklist, feature-flag lifecycle, staged rollout with go\u002Fhold\u002Frollback thresholds, rollback plan template, post-launch monitoring.\nUse when: \"배포 준비\", \"릴리즈 전 점검\", \"롤아웃 계획\", \"롤백 계획\", preparing a production deploy, staged rollout, feature flags.","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Fplugins\u002Fworkflow\u002Fskills\u002Fshipping-guide\u002FSKILL.md",[],[136,137,138,139,140,141,142,122,143,144,42,145,146,126,147,148,149,150,151,29],"shipping","guide","shipping guide","launch","monitoring","lifecycle","feature flags","template","rollback","rollout","flags","thresholds","post","with","pre-launch","rollback plan",{"skillNo":153,"name":154,"nameZh":6,"description":155,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":156,"categories":157,"keywords":158,"publishedAt":44},"01M6QZQ3M962PCWYM6YH62D76Z","retro","세션 회고·교훈 컴파운딩 — 이번 세션의 교정·실수·발견을 추출해 품질 기준(재현·일반화·판정 가능성)으로 거르고, 컨벤션 변화는 sync-claude-md 위임, 반복 실수는 tasks\u002Flessons.md(#가드-훅-후보 태그), 스킬 지식 갱신은 수정 제안으로 나눈다. 모든 적용은 승인 후.\nSession retrospective that turns corrections, mistakes, and discoveries into vetted lessons routed to CLAUDE.md sync, tasks\u002Flessons.md, or skill-edit proposals, with user approval before any write.\nUse when: \"회고\", \"레트로\", \"교훈 정리\", \"같은 실수 반복\", \"재발 방지 규칙\", retrospective, lessons learned, wrapping up a task, review, or loop. 여러 실행에 걸친 하네스 개선은 harness:evolve.","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Fplugins\u002Fworkflow\u002Fskills\u002Fretro\u002FSKILL.md",[],[154,117,81,120,159,160,161,162,163,164,165,166,92,167,168,169,170,171,172,29],"run","harness","loop","evolve","write","edit","proposals","session","up","dry","learned","Session Retrospective","lessons","mistakes",{"skillNo":174,"name":175,"nameZh":6,"description":176,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":177,"categories":178,"keywords":180,"publishedAt":44},"01M6QZPPDATJ7DAGNP0JCXFGRV","project-rules","현재 프로젝트의 스택(빌드 파일 버전, 마이그레이션 도구, ORM 유무, ES 사용, git 호스트)을 감지해 프로젝트 루트 AGENTS.md 의 마커 블록(스택 사실 + 항상 적용되는 스페셜리스트 핵심 ≤15줄 — Claude Code·Codex·Antigravity 공통)과 CLAUDE.md 의 `@AGENTS.md` 연결, Claude 전용 경로 한정 .claude\u002Frules\u002F\u003Cstack>-specialist.md(해당 버전에 실제로 맞는 상세 함정)를 diff 로 제안하고, 스택 플러그인을 런타임별로 프로젝트 범위에서 켜는 법을 안내한다. 이전 버전 출력(CLAUDE.md 블록·specialist-core.md)은 AGENTS.md 로 이전을 제안. --check 는 버전 불일치·사라진 ID·크기 한도·연결 누락을 점검.\nDetects the project's stack and proposes a cross-runtime AGENTS.md block (stack facts + always-on specialist core) linked from CLAUDE.md via @AGENTS.md, plus Claude-only path-scoped specialist rules; migrates the older CLAUDE.md-block layout; --check audits drift.\nUse when: \"이 프로젝트 세팅\", \"프로젝트 규칙 생성\", \"AGENTS.md\u002FCLAUDE.md 점검\", project setup, generate project rules, audit AGENTS.md or CLAUDE.md drift.","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Fplugins\u002Fworkflow\u002Fskills\u002Fproject-rules\u002FSKILL.md",[179],{"categoryNo":107,"categoryName":108,"taxonomyType":109,"taxonomyTypeDesc":110},[181,94,182,183,184,121,185,186,187,188,91,92,41,189,190,191,192,193,194,29],"Rules","project rules","core","generate","git","audit","stack","orm","drift","layout","runtime","-specialist","path","scoped",{"skillNo":196,"name":197,"nameZh":6,"description":198,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":199,"categories":200,"keywords":205,"publishedAt":44},"01M6QZPF3S8YAM27F297F6GVD7","post-merge","MR 머지 완료 후 Plane 이슈 종료와 Outline 문서 업데이트를 자동으로 처리하는 오케스트레이터 스킬입니다.\nissue-tracker와 document-latest 스킬을 연계 실행하여 머지 후 후속 작업을 일괄 처리합니다.\nOrchestrates post-merge follow-up after an MR is merged: chains the issue-tracker and document-latest skills to close the Plane issue and update Outline wiki docs in a single batch. Use when: an MR was just merged, closing tickets and syncing documentation after a merge, post-merge cleanup.","https:\u002F\u002Fgithub.com\u002FDokkabei97\u002Fdokkabei-harness\u002Fblob\u002Fmain\u002Fplugins\u002Fworkflow\u002Fskills\u002Fpost-merge\u002FSKILL.md",[201,204],{"categoryNo":202,"categoryName":203,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000002","文档处理",{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},[206,207,148,208,209,88,210,211,212,167,213,214,215,216,217,218,219,220,98,29],"merge","post merge","documentation","cleanup","batch","wiki","just","issue","tracker","close","closing","follow-up","true","plane","single"]