search-pipeline-reranking
Use this skill when designing multi-stage retrieval, configuring ES reranking (rescore/retriever/LTR/cross-encoder), query understanding, result diversification, or personalization. Covers ES 8.x rescore, the Retriever abstraction (RRF/kNN/text_similarity_reranker), Learning to Rank, and Kotlin service patterns for production search systems.
- License 许可
- Apache-2.0
- 最近更新
- 2026-10-29
- Stars
- ★ 1
- Forks
- 0
01 前言 Front Matter
- name
- search-pipeline-reranking
- description
- Use this skill when designing multi-stage retrieval, configuring ES reranking (rescore/retriever/LTR/cross-encoder), query understanding, result diversification, or personalization. Covers ES 8.x rescore, the Retriever abstraction (RRF/kNN/text_similarity_reranker), Learning to Rank, and Kotlin service patterns for production search systems.
03 SKILL.md 正文
Search Pipeline & Reranking
검색 파이프라인, 재순위화(reranking), 쿼리 이해(query understanding), 결과 다양성, 개인화를 포괄하는 고급 검색 아키텍처 가이드입니다.
When to Activate
- ES 8.x search pipeline 설정 또는 커스텀 프로세서 개발 시
- 재순위화(rescore, LTR, cross-encoder) 전략 설계 시
- 다단계 검색(multi-stage retrieval) 아키텍처 설계 시
- 쿼리 분류, 확장, 스펠 체크 등 query understanding 구현 시
- field_collapse, pinned queries 등 결과 다양성/머천다이징 시
- 사용자 프로필 기반 개인화 검색 설계 시
- Retriever abstraction (ES 8.14+) 활용 시
1. 검색 요청/응답 후처리 (ES 8.x)
⚠️ 주의 — Search Pipeline 은 OpenSearch 전용이다.
PUT _search/pipeline,request_processors/response_processors,filter_query·rename_field프로세서,index.search.default_pipeline,?search_pipeline=쿼리 파라미터, 그리고 elasticsearch-java 의searchPipeline(...)은 Elasticsearch 8.x 에 존재하지 않는다. (ES 의index.default_pipeline은 ingest 파이프라인 지정용으로 색인 시 동작하며, 검색 요청/응답을 변환하지 않는다.) ES 에서 같은 목적을 달성하는 정석 수단:
| 목적 | OpenSearch(참고용, ES 미지원) | Elasticsearch 8.x 대안 |
|---|---|---|
| 멀티테넌트 격리 필터 자동 주입 | search pipeline filter_query |
filtered alias / 애플리케이션 bool.filter / DLS(상용) |
| 삭제 문서 제외 | search pipeline filter_query |
filtered alias 또는 soft-delete 필드 필터 |
| 응답 필드명 변경 | search pipeline rename_field |
서버측 미지원 — 애플리케이션 매핑, 또는 fields + runtime field |
| 동적 boost·재랭킹 | search pipeline script |
Rescore(§2) / function_score / Retriever(§3) |
멀티테넌트 격리 — filtered alias
alias 에 filter 를 걸면, 그 alias 로 검색하는 모든 요청에 필터가 자동 적용된다. 애플리케이션이 매 쿼리에 조건을 붙이는 것을 잊어도 격리가 보장되는 게 핵심 이점이다.
// 테넌트 격리 + 삭제 문서 제외를 결합한 filtered alias
POST _aliases
{
"actions": [
{
"add": {
"index": "products",
"alias": "products_secure_42",
"filter": {
"bool": {
"filter": [
{ "term": { "tenant_id": "42" } },
{ "term": { "is_deleted": false } }
]
}
}
}
}
]
}
// 검색은 alias 로만 — 필터가 항상 적용된다
GET products_secure_42/_search
{
"query": { "match": { "title": "무선 이어폰" } }
}
동적 컨텍스트 필터 — 애플리케이션 레벨 (Kotlin)
요청별 테넌트처럼 alias 로 관리하기 번거로운 동적 조건은 서비스에서 bool.filter 로 주입한다.
filter 절은 스코어에 영향을 주지 않아(캐시 가능) 격리·삭제 제외에 적합하다.
@Service
class TenantSearchService(
private val client: ElasticsearchClient
) {
fun search(keyword: String, tenantId: String): List<Product> {
val response = client.search({ s ->
s.index("products")
.query { q ->
q.bool { b ->
b.must { m -> m.match { mm -> mm.field("title").query(keyword) } }
.filter { f -> f.term { t -> t.field("tenant_id").value(tenantId) } }
.filter { f -> f.term { t -> t.field("is_deleted").value(false) } }
}
}
.size(20)
}, Product::class.java)
return response.hits().hits().mapNotNull { it.source() }
}
}
검색 결과 재랭킹·후처리는 §2 Rescore Query, 다단계·하이브리드 검색은 §3 Retriever 를 참조.
2. Rescore Query (Built-in Reranking)
Rescore 동작 원리
Rescore는 first-pass 검색 결과의 상위 N개 문서에 대해 추가 스코어링을 적용합니다. 전체 문서에 대해 무거운 쿼리를 실행하는 것보다 훨씬 효율적입니다.
[전체 문서] → first-pass (BM25) → [상위 window_size개] → rescore query → [최종 순위]
Rescore 파라미터
| Parameter | 설명 | 기본값 | 권장값 |
|---|---|---|---|
window_size |
rescore 대상 문서 수 | 10 | 50~200 |
query_weight |
first-pass 스코어 가중치 | 1.0 | 0.7~1.0 |
rescore_query_weight |
rescore 스코어 가중치 | 1.0 | 1.0~2.0 |
score_mode |
스코어 결합 방식 | total |
total / multiply |
score_mode 옵션:
| Mode | 계산 | 사용 시나리오 |
|---|---|---|
total |
query_weight * first_pass + rescore_query_weight * rescore |
기본, 대부분의 케이스 |
multiply |
first_pass * rescore |
rescore가 0~1 범위 factor일 때 |
avg |
(first_pass + rescore) / 2 |
두 스코어를 동등하게 |
max |
max(first_pass, rescore) |
더 높은 스코어 채택 |
min |
min(first_pass, rescore) |
보수적 스코어링 |
Phrase Proximity Rescore
// first-pass: 일반 매칭 → rescore: 구문 근접도로 재순위화
POST /products/_search
{
"query": {
"match": {
"title": {
"query": "삼성 무선 이어폰",
"operator": "or"
}
}
},
"rescore": {
"window_size": 100,
"query": {
"rescore_query": {
"match_phrase": {
"title": {
"query": "삼성 무선 이어폰",
"slop": 2
}
}
},
"query_weight": 0.7,
"rescore_query_weight": 1.5,
"score_mode": "total"
}
}
}
Chained Rescore (Phrase + Recency Decay)
// 다단계 rescore: 1단계 구문 근접도 → 2단계 최신성 가산
POST /products/_search
{
"query": {
"multi_match": {
"query": "노트북 가방",
"fields": ["title^3", "description"],
"type": "best_fields"
}
},
"rescore": [
{
"window_size": 200,
"query": {
"rescore_query": {
"match_phrase": {
"title": {
"query": "노트북 가방",
"slop": 1
}
}
},
"query_weight": 0.7,
"rescore_query_weight": 2.0,
"score_mode": "total"
}
},
{
"window_size": 100,
"query": {
"rescore_query": {
"function_score": {
"query": { "match_all": {} },
"functions": [
{
"gauss": {
"created_at": {
"origin": "now",
"scale": "14d",
"offset": "3d",
"decay": 0.5
}
}
}
],
"boost_mode": "replace"
}
},
"query_weight": 1.0,
"rescore_query_weight": 0.5,
"score_mode": "total"
}
}
]
}
Kotlin Rescore 구현 패턴
@Service
class RescoreSearchService(
private val client: ElasticsearchClient
) {
fun searchWithRescore(
keyword: String,
category: String? = null,
windowSize: Int = 100
): SearchResult {
val response = client.search({ s ->
s.index("products")
.query { q ->
q.bool { b ->
b.must { m ->
m.multiMatch { mm ->
mm.query(keyword)
.fields("title^3", "title.search^2", "description")
.type(TextQueryType.BestFields)
}
}
category?.let { cat ->
b.filter { f ->
f.term { t -> t.field("category").value(cat) }
}
}
b
}
}
// Phrase proximity rescore
.rescore { r ->
r.windowSize(windowSize)
.query { rq ->
rq.rescoreQuery { q ->
q.matchPhrase { mp ->
mp.field("title").query(keyword).slop(2)
}
}
.queryWeight(0.7)
.rescoreQueryWeight(1.5)
.scoreMode(ScoreMode.Total)
}
}
.size(20)
}, Product::class.java)
return SearchResult(
items = response.hits().hits().mapNotNull { it.source() },
total = response.hits().total()?.value() ?: 0,
maxScore = response.hits().maxScore()
)
}
}
window_size vs Latency 트레이드오프
| window_size | 레이턴시 영향 | 적합 시나리오 | 주의사항 |
|---|---|---|---|
| 50~100 | Minimal (+1~3ms) | 대부분의 검색, 기본 권장값 | 상위 결과 품질 개선에 충분 |
| 100~200 | Low (+3~8ms) | 정밀 구문 매칭 필요 시 | 첫 페이지 결과에 영향 |
| 200~500 | Moderate (+8~25ms) | LTR 모델 적용, 복잡한 rescore | 페이징 시 일관성 주의 |
| >500 | High (+25ms~) | 전체 결과 재정렬 필요 시 | 반드시 벤치마크로 검증 |
핵심 원칙: window_size는 반환할 결과 수(size)보다 크거나 같아야 합니다. 일반적으로 size * 5 ~ size * 10 범위가 적절합니다.
3. ES 8.x Retriever Abstraction
ES 8.14+에서 도입된 Retriever는 다단계 검색을 선언적으로 구성하는 추상화 계층입니다.
Retriever 유형
| Retriever | 설명 | 최소 ES 버전 | 용도 |
|---|---|---|---|
standard |
일반 query DSL 래핑 | 8.14.0 | 기존 쿼리를 retriever로 표현 |
knn |
k-nearest neighbors 검색 | 8.14.0 | 벡터 유사도 검색 |
rrf |
Reciprocal Rank Fusion | 8.14.0 | 여러 retriever 결과 융합 |
text_similarity_reranker |
Inference API 기반 시맨틱 재순위화 | 8.15.0 | cross-encoder, LLM reranking |
rule |
query rules 기반 결과 조작 | 8.16.0 | 머천다이징, pinned results |
Hybrid RRF: Standard + kNN Retriever
// BM25 텍스트 검색과 벡터 검색을 RRF로 융합
POST /products/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": "무선 블루투스 이어폰",
"fields": ["title^3", "description"],
"type": "best_fields"
}
}
}
},
{
"knn": {
"field": "title_embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "무선 블루투스 이어폰"
}
},
"k": 100,
"num_candidates": 200
}
}
],
"rank_window_size": 200,
"rank_constant": 60
}
},
"_source": ["title", "price", "category", "brand"],
"size": 20
}
RRF 스코어 계산: RRF(d) = Σ 1 / (rank_constant + rank_i(d))
rank_constant가 클수록 하위 순위 문서의 기여도 감소- 기본값 60은 대부분의 시나리오에서 적절
Semantic Reranking via text_similarity_reranker
// 1단계: BM25로 후보 추출 → 2단계: cross-encoder로 시맨틱 재순위화
POST /products/_search
{
"retriever": {
"text_similarity_reranker": {
"retriever": {
"standard": {
"query": {
"match": {
"title": "소음 차단되는 이어폰"
}
}
}
},
"field": "title",
"inference_id": "my-cross-encoder-model",
"inference_text": "소음 차단되는 이어폰",
"rank_window_size": 100,
"min_score": 0.5
}
},
"size": 20
}
Inference API 사전 설정 필요:
PUT _inference/rerank/my-cross-encoder-model
{
"service": "cohere",
"service_settings": {
"model_id": "rerank-multilingual-v3.0",
"api_key": "{{COHERE_API_KEY}}"
}
}
Retriever 최소 버전 요구사항
| 기능 | 최소 버전 | 비고 |
|---|---|---|
standard, knn, rrf retriever |
8.14.0 | GA |
text_similarity_reranker |
8.15.0 | Inference API 필요 |
rule retriever |
8.16.0 | Query rules 사전 정의 필요 |
| Retriever 내 aggregations 지원 | 8.16.0 | compound retriever에서 aggs 가능 |
| Nested retriever composition | 8.14.0 | retriever 내부에 retriever 중첩 |
4. Learning to Rank (LTR)
LTR 개요
Learning to Rank는 머신러닝 모델을 사용하여 검색 결과의 순위를 최적화하는 방법입니다. ES LTR 플러그인(elasticsearch-ltr)을 통해 학습된 모델을 rescore 단계에서 적용합니다.
[검색 로그] → feature 추출 → 학습 데이터 → 오프라인 학습 → 모델 배포 → rescore 적용
Feature 유형
| Feature Type | 설명 | 예시 | 특성 |
|---|---|---|---|
| BM25 score | 텍스트 매칭 스코어 | match query on title |
텍스트 관련성 |
| Recency | 문서 최신성 | function_score.gauss on created_at |
시간 감쇠 |
| Popularity | 인기도 지표 | field_value_factor on sales_count |
클릭/구매 수 |
| Field length | 필드 길이 | script_score on title length |
간결성 선호 |
| Category match | 카테고리 일치도 | term query on category |
의도 매칭 |
| CTR | Click-Through Rate | 문서별 사전 계산된 CTR | 과거 사용자 행동 |
| Price ratio | 가격 대비 경쟁력 | script로 카테고리 평균 대비 계산 | 가격 경쟁력 |
| Review score | 리뷰 평점 | field_value_factor on avg_rating |
품질 지표 |
Feature Set 정의
// LTR 플러그인에 feature set 등록
POST _ltr/_featureset/product_features
{
"featureset": {
"name": "product_features",
"features": [
{
"name": "title_bm25",
"params": ["keywords"],
"template_language": "mustache",
"template": {
"match": {
"title": "{{keywords}}"
}
}
},
{
"name": "description_bm25",
"params": ["keywords"],
"template_language": "mustache",
"template": {
"match": {
"description": "{{keywords}}"
}
}
},
{
"name": "recency_score",
"params": [],
"template_language": "mustache",
"template": {
"function_score": {
"query": { "match_all": {} },
"functions": [
{
"gauss": {
"created_at": {
"origin": "now",
"scale": "30d",
"decay": 0.5
}
}
}
],
"boost_mode": "replace"
}
}
},
{
"name": "popularity_score",
"params": [],
"template_language": "mustache",
"template": {
"function_score": {
"query": { "match_all": {} },
"functions": [
{
"field_value_factor": {
"field": "sales_count",
"modifier": "log1p",
"missing": 0
}
}
],
"boost_mode": "replace"
}
}
},
{
"name": "category_match",
"params": ["category"],
"template_language": "mustache",
"template": {
"term": {
"category": "{{category}}"
}
}
},
{
"name": "title_phrase_match",
"params": ["keywords"],
"template_language": "mustache",
"template": {
"match_phrase": {
"title": {
"query": "{{keywords}}",
"slop": 2
}
}
}
}
]
}
}
Feature Logging
// 기존 검색 결과에 대해 feature 값 로깅
POST /products/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "무선 이어폰"
}
}
],
"should": [
{
"sltr": {
"_name": "logged_features",
"featureset": "product_features",
"params": {
"keywords": "무선 이어폰",
"category": "electronics"
}
}
}
]
}
},
"ext": {
"ltr_log": {
"log_specs": {
"name": "log_entry",
"named_query": "logged_features"
}
}
},
"size": 100
}
로깅 응답 예시:
{
"hits": {
"hits": [
{
"_id": "product_001",
"_score": 12.5,
"fields": {
"_ltrlog": [
{
"log_entry": [
{ "name": "title_bm25", "value": 8.2 },
{ "name": "description_bm25", "value": 3.1 },
{ "name": "recency_score", "value": 0.85 },
{ "name": "popularity_score", "value": 6.7 },
{ "name": "category_match", "value": 1.0 },
{ "name": "title_phrase_match", "value": 5.3 }
]
}
]
}
}
]
}
}
Named Queries로 절 매칭 디버깅·관측 (LTR feature logging과 용도 구분)
위 Feature Logging의 named_query는 sltr feature set 로깅 대상을 지정하는 LTR 학습 파이프라인 전용 메커니즘이다. 이와 별개로, 일반 bool 절에 _name을 부착해 응답 hit.matched_queries로 어떤 절이 매칭을 일으켰는지 관측하는 디버깅·운영 용도가 있다. 둘은 이름이 비슷하나 목적이 다르다 — 전자는 모델 학습용 feature 값 추출, 후자는 매칭 경로 진단·ROI 데이터화.
절별 _name 부착으로 매칭 경로 집계:
POST /products/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title.exact": { "query": "무선 이어폰", "_name": "exact" } } },
{ "match": { "title.synonym": { "query": "무선 이어폰", "_name": "synonym" } } },
{ "fuzzy": { "title": { "value": "이어폰", "fuzziness": "AUTO", "_name": "typo" } } },
{ "match_phrase": { "title": { "query": "무선 이어폰", "boost": 2, "_name": "boost" } } }
]
}
},
"include_named_queries_score": true
}
응답 (ES 8.8.0+에서 include_named_queries_score:true이면 matched_queries가 List → {name: score} Map으로 변경):
{
"hits": {
"hits": [
{
"_id": "product_001",
"_score": 18.4,
"matched_queries": { "exact": 12.1, "synonym": 0.0, "boost": 6.3 }
}
]
}
}
- 절별 매칭 빈도를 집계하면 동의어/오타 절이 실제로 얼마나 매칭에 기여하는지 ROI 데이터화(예:
synonym절이 거의 매칭 안 되면 동의어 사전 점검). - ES 8.8.0 미만에서는
matched_queries가 매칭된 이름의 List(점수 없음)만 반환 — 절 기여 점수 관측은 8.8.0+ 필요.
함정:
matched_queries는 hit마다 절 재실행 오버헤드가 있다 → 프로덕션 상시 사용 금지,size제한·표본 추출로만.filter/must_not/constant_score/function_score내부의_name은 score에 비기여(점수 0 또는 final score와 불일치) → "matched인데 점수 0"을 버그로 오해하지 말 것.nested쿼리 안의 named query는 루트matched_queries가 아니라inner_hits.matched_queries로 봐야 정확하다(issue#46231).
절 매칭 디버깅 증상 진입은 search-diagnostics 스킬 참조.
모델 배포
// 오프라인 학습된 XGBoost 모델을 ES에 업로드
POST _ltr/_featureset/product_features/_createmodel
{
"model": {
"name": "product_ranking_v1",
"model": {
"type": "model/xgboost+json",
"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\"}}}}"
}
}
}
LTR Rescore 적용
// 학습된 모델을 rescore 단계에서 적용
POST /products/_search
{
"query": {
"multi_match": {
"query": "무선 이어폰",
"fields": ["title^3", "description"],
"type": "best_fields"
}
},
"rescore": {
"window_size": 200,
"query": {
"rescore_query": {
"sltr": {
"params": {
"keywords": "무선 이어폰",
"category": "electronics"
},
"model": "product_ranking_v1"
}
},
"query_weight": 0,
"rescore_query_weight": 1,
"score_mode": "total"
}
}
}
LTR Training Workflow
┌─────────────────────────────────────────────────────────────────┐
│ LTR Training Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Step 1. Feature Set 생성 │
│ POST _ltr/_featureset/product_features │
│ → feature 템플릿 정의 (BM25, recency, popularity 등) │
│ │
│ Step 2. Feature Logging │
│ POST /products/_search (with sltr + ltr_log) │
│ → 검색 결과별 feature 값 수집 │
│ │
│ Step 3. 학습 데이터 Export │
│ feature 로그 + relevance judgment → LibSVM/CSV 형식 변환 │
│ → qid:1 1:8.2 2:3.1 3:0.85 4:6.7 5:1.0 6:5.3 # label=4 │
│ │
│ Step 4. 오프라인 학습 │
│ XGBoost/LambdaMART rank:ndcg 목적함수로 학습 │
│ → 교차 검증, NDCG@10 평가, 하이퍼파라미터 튜닝 │
│ │
│ Step 5. 모델 업로드 │
│ POST _ltr/_featureset/_createmodel │
│ → xgboost+json 형식으로 ES에 배포 │
│ │
│ Step 6. Rescore로 배포 │
│ rescore.query.rescore_query.sltr = 학습된 모델 │
│ → A/B 테스트로 기존 대비 NDCG 개선 확인 │
│ │
└─────────────────────────────────────────────────────────────────┘
Kotlin LtrSearchService
@Service
class LtrSearchService(
private val client: ElasticsearchClient
) {
/**
* LTR 모델을 rescore에 적용한 검색.
* elasticsearch-java에서 sltr 쿼리를 직접 지원하지 않으므로
* withJson escape hatch를 사용합니다.
*/
fun searchWithLtr(
keyword: String,
category: String? = null,
modelName: String = "product_ranking_v1",
windowSize: Int = 200
): SearchResult {
val ltrParams = buildMap<String, Any> {
put("keywords", keyword)
category?.let { put("category", it) }
}
val rescoreJson = """
{
"window_size": $windowSize,
"query": {
"rescore_query": {
"sltr": {
"params": ${objectMapper.writeValueAsString(ltrParams)},
"model": "$modelName"
}
},
"query_weight": 0,
"rescore_query_weight": 1,
"score_mode": "total"
}
}
""".trimIndent()
// first-pass 쿼리 + LTR rescore (withJson으로 rescore 부분 주입)
val queryJson = """
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": ${objectMapper.writeValueAsString(keyword)},
"fields": ["title^3", "title.search^2", "description"],
"type": "best_fields"
}
}
]
${category?.let {
""", "filter": [{"term": {"category": ${objectMapper.writeValueAsString(it)}}}]"""
} ?: ""}
}
},
"rescore": $rescoreJson,
"size": 20
}
""".trimIndent()
val request = SearchRequest.of { s ->
s.index("products").withJson(StringReader(queryJson))
}
val response = client.search(request, Product::class.java)
return SearchResult(
items = response.hits().hits().mapNotNull { it.source() },
total = response.hits().total()?.value() ?: 0,
maxScore = response.hits().maxScore()
)
}
companion object {
private val objectMapper = ObjectMapper()
}
}
5. Query Understanding
Query Classification
| Query Type | 특성 | 예시 | Routing 전략 |
|---|---|---|---|
| Navigational | 특정 상품/브랜드 탐색 | "갤럭시 버즈3", "애플 에어팟" | exact match boost + brand filter |
| Informational | 정보 탐색, 비교 | "무선 이어폰 추천", "노트북 비교" | broad match + aggregation + 리뷰 |
| Transactional | 구매 의도 명확 | "이어폰 최저가", "무선 이어폰 할인" | price sort + deal filter + availability |
| Category | 카테고리 브라우징 | "이어폰", "신발" | category landing + facets |
| Long-tail | 구체적 속성 조합 | "삼성 무선 이어폰 노이즈캔슬링 화이트" | 모든 조건 필터 + relaxation 대비 |
Query Expansion via significant_terms
// "이어폰" 검색 사용자가 자주 함께 사용하는 용어 추출
POST /search_logs/_search
{
"query": {
"match": {
"query_text": "이어폰"
}
},
"aggregations": {
"significant_related_terms": {
"significant_terms": {
"field": "clicked_product_tags",
"size": 10,
"min_doc_count": 5,
"mutual_information": {
"include_negatives": false,
"background_is_superset": true
}
}
}
},
"size": 0
}
// 결과: ["블루투스", "노이즈캔슬링", "무선", "갤럭시버즈", "에어팟"]
// → 쿼리 확장 또는 관련 검색어 추천에 활용
Spell Correction (Phrase Suggester)
// 한국어 오타 교정: phrase suggester
POST /products/_search
{
"suggest": {
"spell_check": {
"text": "삼섯 무선 이어폰",
"phrase": {
"field": "title.trigram",
"size": 3,
"gram_size": 3,
"confidence": 1.0,
"direct_generator": [
{
"field": "title.trigram",
"suggest_mode": "always",
"min_word_length": 2
}
],
"collate": {
"query": {
"source": {
"match": {
"title": "{{suggestion}}"
}
}
},
"prune": true
},
"highlight": {
"pre_tag": "<em>",
"post_tag": "</em>"
}
}
}
}
}
Trigram 분석기 설정 (spell correction 전제 조건):
PUT /products
{
"settings": {
"analysis": {
"filter": {
"trigram_filter": {
"type": "ngram",
"min_gram": 3,
"max_gram": 3
}
},
"analyzer": {
"trigram_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "trigram_filter"]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"fields": {
"trigram": {
"type": "text",
"analyzer": "trigram_analyzer"
}
}
}
}
}
}
Query Relaxation Cascade (Kotlin)
@Service
class QueryRelaxationService(
private val client: ElasticsearchClient
) {
/**
* 쿼리 완화 캐스케이드: 결과가 부족하면 점진적으로 필터를 완화합니다.
*
* Level 0: 전체 필터 적용 (brand + category + attributes)
* Level 1: attribute 필터 제거
* Level 2: brand 필터 제거
* Level 3: category 필터 제거
* Level 4: fuzzy 매칭으로 전환
*/
suspend fun searchWithRelaxation(
keyword: String,
brand: String? = null,
category: String? = null,
attributes: Map<String, String> = emptyMap(),
minResults: Int = 5
): RelaxedSearchResult = withContext(Dispatchers.IO) {
// Level 0: 전체 필터
val level0 = executeSearch(keyword, brand, category, attributes, fuzzy = false)
if (level0.totalHits >= minResults) {
return@withContext RelaxedSearchResult(level0.items, relaxationLevel = 0)
}
// Level 1: attribute 필터 제거
val level1 = executeSearch(keyword, brand, category, emptyMap(), fuzzy = false)
if (level1.totalHits >= minResults) {
return@withContext RelaxedSearchResult(level1.items, relaxationLevel = 1)
}
// Level 2: brand 필터 제거
val level2 = executeSearch(keyword, null, category, emptyMap(), fuzzy = false)
if (level2.totalHits >= minResults) {
return@withContext RelaxedSearchResult(level2.items, relaxationLevel = 2)
}
// Level 3: category 필터 제거
val level3 = executeSearch(keyword, null, null, emptyMap(), fuzzy = false)
if (level3.totalHits >= minResults) {
return@withContext RelaxedSearchResult(level3.items, relaxationLevel = 3)
}
// Level 4: fuzzy 매칭
val level4 = executeSearch(keyword, null, null, emptyMap(), fuzzy = true)
return@withContext RelaxedSearchResult(level4.items, relaxationLevel = 4)
}
private fun executeSearch(
keyword: String,
brand: String?,
category: String?,
attributes: Map<String, String>,
fuzzy: Boolean
): RawSearchResult {
val response = client.search({ s ->
s.index("products")
.query { q ->
q.bool { b ->
// 텍스트 매칭
b.must { m ->
if (fuzzy) {
m.match { mt ->
mt.field("title").query(keyword).fuzziness("AUTO")
}
} else {
m.multiMatch { mm ->
mm.query(keyword)
.fields("title^3", "description")
.type(TextQueryType.BestFields)
}
}
}
// 조건부 필터
brand?.let { b0 ->
b.filter { f -> f.term { t -> t.field("brand").value(b0) } }
}
category?.let { c ->
b.filter { f -> f.term { t -> t.field("category").value(c) } }
}
attributes.forEach { (key, value) ->
b.filter { f ->
f.term { t -> t.field("attributes.$key").value(value) }
}
}
b
}
}
.size(20)
}, Product::class.java)
val hits = response.hits().hits()
return RawSearchResult(
items = hits.mapNotNull { it.source() },
totalHits = response.hits().total()?.value() ?: 0
)
}
}
data class RelaxedSearchResult(
val items: List<Product>,
val relaxationLevel: Int // 0=exact, 1=no-attr, 2=no-brand, 3=no-category, 4=fuzzy
)
private data class RawSearchResult(
val items: List<Product>,
val totalHits: Long
)
Intent Detection Routing
@Component
class QueryIntentDetector {
private val navigationalPatterns = listOf(
Regex("^(삼성|애플|소니|LG|나이키|아디다스)\\s+.+"), // 브랜드 + 상품
Regex(".+\\s+(공식|정품|오리지널)$"), // 공식/정품 의도
Regex("^[A-Za-z0-9\\-]+$"), // 모델명 (영문숫자)
)
private val transactionalPatterns = listOf(
Regex(".+(최저가|할인|세일|쿠폰|특가)$"),
Regex("^(싸게|저렴하게)\\s+.+"),
Regex(".+\\s+(구매|주문|배송)$"),
)
private val informationalPatterns = listOf(
Regex(".+(추천|비교|리뷰|후기|차이)$"),
Regex("^(어떤|무슨|뭐가|뭘)\\s+.+"),
Regex(".+(좋은지|나은지|어때)$"),
)
fun detectIntent(query: String): QueryIntent {
val trimmed = query.trim()
return when {
navigationalPatterns.any { it.matches(trimmed) } -> QueryIntent.NAVIGATIONAL
transactionalPatterns.any { it.matches(trimmed) } -> QueryIntent.TRANSACTIONAL
informationalPatterns.any { it.matches(trimmed) } -> QueryIntent.INFORMATIONAL
trimmed.split("\\s+".toRegex()).size <= 1 -> QueryIntent.CATEGORY
else -> QueryIntent.INFORMATIONAL
}
}
fun routeSearch(query: String): SearchStrategy {
return when (detectIntent(query)) {
QueryIntent.NAVIGATIONAL -> SearchStrategy(
boostExactMatch = true,
enableBrandFilter = true,
sortBy = SortBy.RELEVANCE,
rescoreWindow = 50
)
QueryIntent.TRANSACTIONAL -> SearchStrategy(
boostExactMatch = false,
enableBrandFilter = false,
sortBy = SortBy.PRICE_ASC,
rescoreWindow = 0, // rescore 불필요
filterAvailable = true
)
QueryIntent.INFORMATIONAL -> SearchStrategy(
boostExactMatch = false,
enableBrandFilter = false,
sortBy = SortBy.RELEVANCE,
rescoreWindow = 100,
includeAggregations = true
)
QueryIntent.CATEGORY -> SearchStrategy(
boostExactMatch = false,
enableBrandFilter = false,
sortBy = SortBy.POPULARITY,
rescoreWindow = 200,
includeFacets = true
)
}
}
}
enum class QueryIntent { NAVIGATIONAL, TRANSACTIONAL, INFORMATIONAL, CATEGORY }
data class SearchStrategy(
val boostExactMatch: Boolean = false,
val enableBrandFilter: Boolean = false,
val sortBy: SortBy = SortBy.RELEVANCE,
val rescoreWindow: Int = 100,
val filterAvailable: Boolean = false,
val includeAggregations: Boolean = false,
val includeFacets: Boolean = false
)
enum class SortBy { RELEVANCE, PRICE_ASC, PRICE_DESC, POPULARITY, RECENCY }
6. Result Diversification
field_collapse로 브랜드 다양성 확보
// 브랜드당 1개만 메인 노출, 나머지는 inner_hits로 접근
POST /products/_search
{
"query": {
"match": { "title": "무선 이어폰" }
},
"collapse": {
"field": "brand",
"inner_hits": {
"name": "brand_variants",
"size": 3,
"sort": [{ "_score": "desc" }],
"_source": ["title", "price", "brand"]
},
"max_concurrent_group_searches": 4
},
"sort": [{ "_score": "desc" }],
"size": 10
}
응답 구조:
{
"hits": {
"hits": [
{
"_source": { "title": "삼성 갤럭시 버즈3", "brand": "삼성" },
"inner_hits": {
"brand_variants": {
"hits": {
"hits": [
{ "_source": { "title": "삼성 갤럭시 버즈3 프로", "brand": "삼성" } },
{ "_source": { "title": "삼성 갤럭시 버즈FE", "brand": "삼성" } }
]
}
}
}
},
{
"_source": { "title": "애플 에어팟 프로 2", "brand": "애플" }
}
]
}
}
Pinned Queries로 머천다이징
// 특정 상품을 최상위에 고정 (프로모션, 광고, 큐레이션)
POST /products/_search
{
"query": {
"pinned": {
"ids": ["promo_001", "promo_002", "promo_003"],
"organic": {
"multi_match": {
"query": "무선 이어폰",
"fields": ["title^3", "description"]
}
}
}
},
"size": 20
}
pinned query 동작:
ids에 지정된 문서가 항상 최상위에 위치- 나머지는
organic쿼리 결과 순서대로 배치 - 지정된 ID의 문서가 존재하지 않으면 무시됨
- 최대 100개까지 pinning 가능 (성능 고려)
MMR (Maximal Marginal Relevance) Post-Processing
ES에서 직접 MMR을 지원하지 않으므로 애플리케이션 레벨에서 구현합니다.
/**
* MMR (Maximal Marginal Relevance) 기반 결과 다양화.
* MMR = λ * Sim(d, q) - (1-λ) * max(Sim(d, d_selected))
*
* @param lambda 관련성 vs 다양성 가중치 (1.0 = 관련성만, 0.0 = 다양성만)
*/
class MmrDiversifier(
private val lambda: Double = 0.7
) {
fun diversify(
candidates: List<ScoredDocument>,
topK: Int = 20
): List<ScoredDocument> {
if (candidates.size <= topK) return candidates
val selected = mutableListOf<ScoredDocument>()
val remaining = candidates.toMutableList()
// 첫 번째는 최고 스코어 문서
val first = remaining.maxByOrNull { it.score } ?: return emptyList()
selected.add(first)
remaining.remove(first)
// 나머지는 MMR 기준으로 선택
while (selected.size < topK && remaining.isNotEmpty()) {
val best = remaining.maxByOrNull { candidate ->
val relevance = candidate.score / (candidates.maxOf { it.score })
val maxSimilarity = selected.maxOf { sel ->
computeSimilarity(candidate, sel)
}
lambda * relevance - (1 - lambda) * maxSimilarity
} ?: break
selected.add(best)
remaining.remove(best)
}
return selected
}
/**
* 카테고리 + 브랜드 기반 유사도 계산 (0.0 ~ 1.0).
* 벡터 기반 유사도가 필요하면 코사인 유사도로 교체.
*/
private fun computeSimilarity(a: ScoredDocument, b: ScoredDocument): Double {
var similarity = 0.0
if (a.category == b.category) similarity += 0.5
if (a.brand == b.brand) similarity += 0.3
if (a.priceRange == b.priceRange) similarity += 0.2
return similarity
}
}
data class ScoredDocument(
val id: String,
val score: Double,
val category: String,
val brand: String,
val priceRange: String // "low", "mid", "high"
)
7. Personalization in Search
function_score with User Profile Boosting
// 사용자 선호 기반 개인화 점수 가산
POST /products/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "이어폰",
"fields": ["title^3", "description"]
}
},
"functions": [
{
"filter": {
"term": { "category": "electronics" }
},
"weight": 1.5,
"_comment": "사용자가 선호하는 카테고리 boost"
},
{
"filter": {
"terms": { "brand": ["삼성", "애플"] }
},
"weight": 1.3,
"_comment": "최근 30일 내 클릭/구매한 브랜드 boost"
},
{
"filter": {
"range": {
"price": {
"gte": 100000,
"lte": 300000
}
}
},
"weight": 1.2,
"_comment": "사용자의 주요 가격대 boost"
},
{
"filter": {
"term": { "is_free_shipping": true }
},
"weight": 1.1,
"_comment": "무료배송 선호 사용자 boost"
}
],
"score_mode": "sum",
"boost_mode": "multiply",
"max_boost": 3.0
}
},
"size": 20
}
Session-Based Personalization Signals
| Signal | 수집 방법 | 적용 방식 | 반영 시점 |
|---|---|---|---|
| 최근 본 카테고리 | 세션 내 클릭 로그 | category boost (weight 1.2~1.5) | 실시간 |
| 최근 본 브랜드 | 세션 내 클릭 로그 | brand boost (weight 1.1~1.3) | 실시간 |
| 가격대 선호 | 클릭/구매 가격 분포 | price range filter boost | 세션 내 |
| 카트 내 상품 | 장바구니 데이터 | 보완재/대체재 boost | 실시간 |
| 검색 히스토리 | 세션 내 이전 쿼리 | query expansion 재료 | 세션 내 |
@Service
class PersonalizedSearchService(
private val client: ElasticsearchClient,
private val userProfileService: UserProfileService
) {
suspend fun personalizedSearch(
keyword: String,
userId: String
): SearchResult = withContext(Dispatchers.IO) {
val profile = userProfileService.getProfile(userId)
val functions = mutableListOf<String>()
// 선호 카테고리 boost
profile.preferredCategories.takeIf { it.isNotEmpty() }?.let { cats ->
functions.add("""
{
"filter": { "terms": { "category": ${objectMapper.writeValueAsString(cats)} } },
"weight": 1.4
}
""".trimIndent())
}
// 선호 브랜드 boost
profile.preferredBrands.takeIf { it.isNotEmpty() }?.let { brands ->
functions.add("""
{
"filter": { "terms": { "brand": ${objectMapper.writeValueAsString(brands)} } },
"weight": 1.3
}
""".trimIndent())
}
// 가격대 boost
profile.priceRange?.let { range ->
functions.add("""
{
"filter": { "range": { "price": { "gte": ${range.first}, "lte": ${range.last} } } },
"weight": 1.2
}
""".trimIndent())
}
val queryJson = """
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": ${objectMapper.writeValueAsString(keyword)},
"fields": ["title^3", "description"],
"type": "best_fields"
}
},
"functions": [${functions.joinToString(",")}],
"score_mode": "sum",
"boost_mode": "multiply",
"max_boost": 3.0
}
},
"size": 20
}
""".trimIndent()
val request = SearchRequest.of { s ->
s.index("products").withJson(StringReader(queryJson))
}
val response = client.search(request, Product::class.java)
SearchResult(
items = response.hits().hits().mapNotNull { it.source() },
total = response.hits().total()?.value() ?: 0,
maxScore = response.hits().maxScore()
)
}
companion object {
private val objectMapper = ObjectMapper()
}
}
Collaborative Filtering as Document-Level Features
| 방식 | 설명 | 구현 | 장단점 |
|---|---|---|---|
| Pre-computed CF score | 사용자-상품 유사도를 사전 계산하여 문서 필드에 저장 | 배치로 cf_score_segment_{A,B,C} 인덱싱 |
검색 시 빠름, 갱신 주기에 종속 |
| User segment boost | 사용자 세그먼트별 인기 상품에 가중치 | function_score filter by segment |
구현 간단, 개인화 수준 낮음 |
| Real-time embedding | 사용자/상품 임베딩 벡터 유사도 | kNN + RRF retriever 활용 | 정밀하지만 인프라 복잡 |
Privacy 고려사항
| 원칙 | 설명 | 구현 방법 |
|---|---|---|
| 데이터 최소화 | 개인화에 필요한 최소 데이터만 수집 | 카테고리/브랜드 수준 집계, 개별 상품 기록 X |
| 익명화 | 사용자 식별 불가능하게 처리 | 해시된 세션 ID, 세그먼트 기반 집계 |
| TTL | 개인화 데이터 보존 기간 제한 | 세션 데이터 24h, 프로필 90일 TTL |
| Opt-out | 사용자가 개인화를 비활성화할 수 있어야 함 | API 파라미터로 personalized=false 지원 |
| 쿼리 로그 분리 | 검색 로그에 PII 미포함 | 사용자 ID 대신 익명 세션 ID만 기록 |
8. Multi-Stage Retrieval Architecture
아키텍처 다이어그램
┌─────────────────────────────────────────────────────────────────────────┐
│ Multi-Stage Retrieval Pipeline │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ User Query │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ < 10ms │
│ │ L0: Query │ spell check, intent detection, │
│ │ Understanding │ query expansion, 한영 변환 │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ < 50ms ┌──────────────────┐ │
│ │ L1: Candidate │ BM25 + kNN ──→│ ~1000 candidates │ │
│ │ Generation │ broad recall └────────┬─────────┘ │
│ └────────┬────────┘ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ < 20ms ┌──────────────────┐ │
│ │ L2: Lightweight │ rescore │ ~200 candidates │ │
│ │ Scoring │ phrase boost └────────┬─────────┘ │
│ └────────┬────────┘ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ < 100ms ┌──────────────────┐ │
│ │ L3: Heavy │ LTR model / │ ~50 candidates │ │
│ │ Reranking │ cross-encoder └────────┬─────────┘ │
│ └────────┬────────┘ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ < 10ms ┌──────────────────┐ │
│ │ L4: Business │ pinning, │ ~20 final results│ │
│ │ Rules │ diversity, └──────────────────┘ │
│ └────────┬────────┘ personalization │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Final Results │ Total latency < 200ms │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
단계별 레이턴시 버짓
| Stage | 목표 레이턴시 | 문서 수 | 역할 | 주요 기술 |
|---|---|---|---|---|
| L0 | < 10ms | - | 쿼리 전처리 | spell check, intent, 한영 변환 |
| L1 | < 50ms | 전체 → ~1000 | 후보 생성 | BM25, kNN, RRF |
| L2 | < 20ms | ~1000 → ~200 | 경량 재순위화 | rescore (phrase, recency) |
| L3 | < 100ms | ~200 → ~50 | 정밀 재순위화 | LTR, cross-encoder |
| L4 | < 10ms | ~50 → ~20 | 비즈니스 규칙 | pinning, diversity, personalization |
| Total | < 200ms | - | 전체 파이프라인 | - |
단계 추가 판단 기준
| 현재 상태 | 추가할 단계 | 판단 기준 |
|---|---|---|
| L1만 운영 | L0 (Query Understanding) | zero-result rate > 5%, 오타 비율 높음 |
| L0 + L1 | L2 (Rescore) | exact match가 상위에 안 나옴, NDCG 정체 |
| L0 + L1 + L2 | L3 (LTR) | 충분한 클릭 로그 확보, NDCG 오프라인 평가 준비 |
| L0~L3 | L4 (Business Rules) | 머천다이징/프로모션 요구, 결과 다양성 민원 |
Kotlin MultiStageSearchService
@Service
class MultiStageSearchService(
private val queryUnderstanding: QueryUnderstandingService,
private val candidateGenerator: CandidateGenerationService,
private val lightweightScorer: LightweightScoringService,
private val heavyReranker: HeavyRerankingService,
private val businessRules: BusinessRulesService,
private val meterRegistry: MeterRegistry
) {
private val logger = LoggerFactory.getLogger(javaClass)
/**
* 다단계 검색 파이프라인 실행.
* 각 단계의 레이턴시를 측정하고, 실패 시 이전 단계 결과로 fallback.
*/
suspend fun search(request: SearchRequest): SearchResponse {
val timer = Timer.start(meterRegistry)
// ── L0: Query Understanding ──
val l0Result = measureStage("l0_query_understanding") {
try {
queryUnderstanding.process(request.query)
} catch (e: Exception) {
logger.warn("L0 failed, using raw query", e)
QueryUnderstandingResult(
originalQuery = request.query,
processedQuery = request.query,
intent = QueryIntent.INFORMATIONAL,
corrections = emptyList()
)
}
}
logger.debug("L0 result: intent={}, corrected={}", l0Result.intent, l0Result.processedQuery)
// ── L1: Candidate Generation ──
val l1Result = measureStage("l1_candidate_generation") {
candidateGenerator.generate(
query = l0Result.processedQuery,
intent = l0Result.intent,
filters = request.filters,
maxCandidates = 1000
)
}
logger.debug("L1 candidates: {} docs", l1Result.size)
if (l1Result.isEmpty()) {
timer.stop(meterRegistry.timer("search.pipeline.total", "status", "no_results"))
return SearchResponse(
items = emptyList(),
total = 0,
metadata = SearchMetadata(
relaxationLevel = null,
corrections = l0Result.corrections
)
)
}
// ── L2: Lightweight Scoring ──
val l2Result = measureStage("l2_lightweight_scoring") {
try {
lightweightScorer.score(
candidates = l1Result,
query = l0Result.processedQuery,
windowSize = 200
)
} catch (e: Exception) {
logger.warn("L2 failed, using L1 results", e)
l1Result.take(200)
}
}
logger.debug("L2 scored: {} docs", l2Result.size)
// ── L3: Heavy Reranking ──
val l3Result = measureStage("l3_heavy_reranking") {
try {
heavyReranker.rerank(
candidates = l2Result,
query = l0Result.processedQuery,
intent = l0Result.intent,
topK = 50
)
} catch (e: Exception) {
logger.warn("L3 failed, using L2 results", e)
l2Result.take(50)
}
}
logger.debug("L3 reranked: {} docs", l3Result.size)
// ── L4: Business Rules ──
val l4Result = measureStage("l4_business_rules") {
try {
businessRules.apply(
candidates = l3Result,
userId = request.userId,
query = l0Result.processedQuery,
intent = l0Result.intent,
topK = request.size
)
} catch (e: Exception) {
logger.warn("L4 failed, using L3 results", e)
l3Result.take(request.size)
}
}
timer.stop(meterRegistry.timer("search.pipeline.total", "status", "success"))
return SearchResponse(
items = l4Result,
total = l1Result.size.toLong(),
metadata = SearchMetadata(
corrections = l0Result.corrections,
intent = l0Result.intent,
stages = StageMetrics(
l1Candidates = l1Result.size,
l2Scored = l2Result.size,
l3Reranked = l3Result.size,
l4Final = l4Result.size
)
)
)
}
private suspend fun <T> measureStage(stageName: String, block: suspend () -> T): T {
val start = System.nanoTime()
try {
val result = block()
val durationMs = (System.nanoTime() - start) / 1_000_000.0
meterRegistry.timer("search.stage.duration", "stage", stageName, "status", "success")
.record(durationMs.toLong(), TimeUnit.MILLISECONDS)
return result
} catch (e: Exception) {
val durationMs = (System.nanoTime() - start) / 1_000_000.0
meterRegistry.timer("search.stage.duration", "stage", stageName, "status", "error")
.record(durationMs.toLong(), TimeUnit.MILLISECONDS)
throw e
}
}
}
data class SearchRequest(
val query: String,
val filters: Map<String, Any> = emptyMap(),
val userId: String? = null,
val size: Int = 20
)
data class SearchResponse(
val items: List<Product>,
val total: Long,
val metadata: SearchMetadata
)
data class SearchMetadata(
val corrections: List<String> = emptyList(),
val intent: QueryIntent? = null,
val relaxationLevel: Int? = null,
val stages: StageMetrics? = null
)
data class StageMetrics(
val l1Candidates: Int,
val l2Scored: Int,
val l3Reranked: Int,
val l4Final: Int
)
9. Anti-Patterns
| Anti-Pattern | 문제 | 올바른 접근 | Severity |
|---|---|---|---|
rescore window_size > 500 without justification |
레이턴시 급증, first-pass 이점 상실 | 벤치마크 기반으로 적정 window_size 결정 (보통 100~200) | High |
| LTR 모델 배포 without offline evaluation | 프로덕션에서 검색 품질 저하 감지 못함 | 배포 전 NDCG@10 오프라인 평가 필수, 기존 대비 개선 증명 | Critical |
| Reranking on unfiltered full result set | O(N) 스코어링으로 레이턴시 폭증 | first-pass로 후보 축소 후 상위 N개만 reranking | High |
| Personalization에 PII를 query logs에 저장 | 개인정보 유출 위험, 법적 이슈 | 익명 세션 ID만 기록, PII는 별도 암호화 저장소에서 관리 | Critical |
| 모든 단계를 single ES query에 구현 | 디버깅 불가, 단계별 최적화 불가 | 각 단계를 독립 컴포넌트로 분리, 단계별 메트릭 수집 | Medium |
| 새 ranking 단계 추가 시 A/B test 없이 배포 | 개선/악화 판단 불가, 롤백 근거 없음 | 트래픽 일부에만 적용 후 NDCG, CTR 비교 | High |
| Query relaxation without logging | 어떤 단계에서 결과를 찾았는지 추적 불가 | relaxation level을 응답 메타데이터에 포함, 로그로 수집 | Medium |
| Cross-encoder reranking without timeout | 외부 inference 서비스 장애 시 전체 검색 중단 | 타임아웃 + fallback (L2 결과로 대체) 필수 | High |
| Feature set 변경 후 모델 재학습 없이 사용 | feature 순서/의미 불일치로 예측 오류 | feature set 변경 시 반드시 모델 재학습 + 재배포 | Critical |
| 개인화 boost weight를 하드코딩 | 환경별 최적값 다름, 튜닝 어려움 | 설정 파일 또는 remote config로 외부화 | Medium |
Remember: 다단계 검색은 점진적으로 구축하세요. L1(BM25 검색)에서 시작하고, 오프라인 평가(NDCG)로 병목을 확인한 후 필요한 단계만 추가하세요. 모든 단계를 한 번에 도입하는 것은 복잡성만 증가시킵니다. rescore는 가장 비용 대비 효과가 큰 재순위화 방법입니다.
04 同仓库更多 Skills
sync-claude-md
커밋·푸시·PR/MR 직전에 이번 변경이 프로젝트 CLAUDE.md 를 낡게 만드는지 검토하고, 필요할 때만 갱신 diff 를 제안한다 — 아키텍처·디렉터리 구조, 새 통합/MCP, 컨벤션, 빌드·CI·도구, 주요 의존성 변화가 대상. 해당 없으면 한 줄로 끝낸다. Reviews whether pending changes make the project CLAUDE.md stale (architecture, integrations, conventions, build/CI/tooling, major dependencies) and proposes a minimal update diff only when needed. Use when: "CLAUDE.md 갱신 필요해?", "CLAUDE.md 동기화", 커밋/푸시/MR 전에 구조·도구가 바뀐 변경을 정리할 때, sync CLAUDE.md, check CLAUDE.md before commit or PR.
spec-driven-dev
새 기능·프로젝트·큰 변경을 코드보다 스펙 먼저로 진행하는 게이트 워크플로(SPECIFY → PLAN → TASKS → IMPLEMENT) — 가정 명시, 측정 가능한 성공 기준, Always/Ask/Never 경계, 단계별 사람 검토로 범위 이탈과 재작업을 줄인다. Spec-before-code gated workflow for new features or significant changes: explicit assumptions, measurable success criteria, boundaries, and a human review between stages. Use when: "스펙 먼저", "기능 명세", "요구사항 정리", starting a new feature or project, a significant change with vague requirements. 단순 버그 수정·1~2줄 변경엔 불필요.
shipping-guide
프로덕션 배포 전후 가이드 — 배포 전 체크리스트, 피처 플래그 수명주기, 단계적 롤아웃과 진행/보류/롤백 임계값, 롤백 계획 템플릿, 배포 후 모니터링. Production shipping guide: pre-launch checklist, feature-flag lifecycle, staged rollout with go/hold/rollback thresholds, rollback plan template, post-launch monitoring. Use when: "배포 준비", "릴리즈 전 점검", "롤아웃 계획", "롤백 계획", preparing a production deploy, staged rollout, feature flags.
retro
세션 회고·교훈 컴파운딩 — 이번 세션의 교정·실수·발견을 추출해 품질 기준(재현·일반화·판정 가능성)으로 거르고, 컨벤션 변화는 sync-claude-md 위임, 반복 실수는 tasks/lessons.md(#가드-훅-후보 태그), 스킬 지식 갱신은 수정 제안으로 나눈다. 모든 적용은 승인 후. Session retrospective that turns corrections, mistakes, and discoveries into vetted lessons routed to CLAUDE.md sync, tasks/lessons.md, or skill-edit proposals, with user approval before any write. Use when: "회고", "레트로", "교훈 정리", "같은 실수 반복", "재발 방지 규칙", retrospective, lessons learned, wrapping up a task, review, or loop. 여러 실행에 걸친 하네스 개선은 harness:evolve.
project-rules
현재 프로젝트의 스택(빌드 파일 버전, 마이그레이션 도구, ORM 유무, ES 사용, git 호스트)을 감지해 프로젝트 루트 AGENTS.md 의 마커 블록(스택 사실 + 항상 적용되는 스페셜리스트 핵심 ≤15줄 — Claude Code·Codex·Antigravity 공통)과 CLAUDE.md 의 `@AGENTS.md` 연결, Claude 전용 경로 한정 .claude/rules/<stack>-specialist.md(해당 버전에 실제로 맞는 상세 함정)를 diff 로 제안하고, 스택 플러그인을 런타임별로 프로젝트 범위에서 켜는 법을 안내한다. 이전 버전 출력(CLAUDE.md 블록·specialist-core.md)은 AGENTS.md 로 이전을 제안. --check 는 버전 불일치·사라진 ID·크기 한도·연결 누락을 점검. Detects 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. Use when: "이 프로젝트 세팅", "프로젝트 규칙 생성", "AGENTS.md/CLAUDE.md 점검", project setup, generate project rules, audit AGENTS.md or CLAUDE.md drift.
post-merge
MR 머지 완료 후 Plane 이슈 종료와 Outline 문서 업데이트를 자동으로 처리하는 오케스트레이터 스킬입니다. issue-tracker와 document-latest 스킬을 연계 실행하여 머지 후 후속 작업을 일괄 처리합니다. Orchestrates 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.