- 프로젝트 개요
- 헥사고날 아키텍처
- 멀티모듈 구조
- 도메인 모델
- 기능 플로우 — 상품 색인
- 기능 플로우 — 검색
- 기능 플로우 — 동의어 생성 & 적용
- 기능 플로우 — 분석기 추천
- 기능 플로우 — 검색 품질 평가
- 기능 플로우 — A/B 비교 & 통계 검증
- 기능 플로우 — Blue-Green 마이그레이션
- IR 메트릭 수학적 원리
- Elasticsearch 인덱스 설계
- 전체 API 목록
- 인프라 & 실행 방법
- 기술 결정 이유 (ADR 요약)
한국 이커머스 검색의 두 가지 핵심 문제를 해결한다.
문제 1 — 측정 가능성 부재
"AI 동의어 사전을 도입했더니 검색이 좋아졌습니다"는 측정 불가능한 주장이다. 이 프로젝트는 정량적이고 재현 가능한 검색 품질 측정 파이프라인을 제공한다.
nDCG@10: 0.72 → 0.85 (+17.7%)
P@5: 0.68 → 0.81 (+19.1%)
MRR: 0.74 → 0.88 (+18.3%)
p-value: 0.003 (통계적으로 유의)
문제 2 — 다운타임 없는 동의어 적용
일반적인 ES 동의어 변경은 인덱스를 close → 설정 변경 → open 해야 한다. 이 프로젝트는 두 가지 무중단 전략을 제공한다.
| 전략 | 방식 | 적합한 경우 |
|---|---|---|
| Reload | updateable: true + _reload_search_analyzers |
동의어 사전만 변경 |
| Blue-Green | 신규 인덱스 빌드 → Alias 원자적 전환 | 분석기/매핑 변경 |
헥사고날 아키텍처(Ports & Adapters)의 핵심 규칙은 "의존성은 항상 바깥 → 안쪽으로만" 이다. 비즈니스 로직이 담긴 Core는 Spring, MySQL, Elasticsearch를 전혀 모른다.
graph TD
subgraph Core ["Core (순수 Kotlin)"]
Domain["도메인 엔티티<br/>Product, SynonymSet, EvaluationResult ..."]
InPort["Input Port (Interface)<br/>SearchProductUseCase<br/>GenerateSynonymUseCase<br/>EvaluateSearchQualityUseCase ..."]
Service["Application Service<br/>ProductService<br/>SynonymGenerationService<br/>SearchQualityEvaluationService ..."]
OutPort["Output Port (Interface)<br/>ElasticsearchPort<br/>LlmPort<br/>ProductPersistencePort ..."]
Domain --> Service
Service --> OutPort
InPort --> Service
end
subgraph Adapters_In ["Inbound Adapters (search-tuner-api)"]
Controller["REST Controller<br/>ProductController<br/>SynonymController ..."]
end
subgraph Adapters_Out ["Outbound Adapters"]
EsAdapter["ElasticsearchAdapter<br/>(infra-es)"]
LlmAdapter["LlmAdapter<br/>(infra-llm)"]
JpaAdapter["JpaProductAdapter<br/>(infra-persistence)"]
end
Controller -->|"calls"| InPort
EsAdapter -->|"implements"| OutPort
LlmAdapter -->|"implements"| OutPort
JpaAdapter -->|"implements"| OutPort
style Core fill:#e8f5e9,stroke:#4caf50
style Adapters_In fill:#e3f2fd,stroke:#2196f3
style Adapters_Out fill:#fff3e0,stroke:#ff9800
graph LR
API["search-tuner-api"] --> Core["search-tuner-core"]
API --> ES["search-tuner-infra-es"]
API --> LLM["search-tuner-infra-llm"]
API --> Persistence["search-tuner-infra-persistence"]
ES --> Core
LLM --> Core
Persistence --> Core
style Core fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
Core는 아무것도 의존하지 않는다. Spring 어노테이션조차 없다. 따라서 Core의 단위 테스트는 Spring Context 없이 순수 JVM으로 실행된다.
search-tuner/
├── build.gradle.kts # 루트: 공통 플러그인 버전, 서브프로젝트 공통 설정
├── settings.gradle.kts # 5개 모듈 include
├── gradle/libs.versions.toml # Version Catalog (단일 버전 관리)
├── docker-compose.yml # MySQL 8 + ES 8.17 (Nori) + Kibana 8.17
├── docker/
│ ├── elasticsearch/Dockerfile # elasticsearch:8.17.0 + analysis-nori plugin
│ ├── elasticsearch/config/synonyms/ # 동의어 파일 (volume mount)
│ └── mysql/init.sql # 스키마 DDL
│
├── search-tuner-core/ # 순수 Kotlin, Spring 무의존
│ └── src/main/kotlin/.../core/
│ ├── domain/ # 도메인 엔티티 (data class)
│ │ ├── product/Product.kt
│ │ ├── shop/Shop.kt
│ │ ├── synonym/SynonymSet.kt, SynonymGroup.kt, SynonymType.kt
│ │ ├── analyzer/AnalyzerConfig.kt
│ │ ├── evaluation/EvaluationResult.kt, QueryEntry.kt, RelevanceScore.kt
│ │ └── index/IndexJob.kt, MigrationJob.kt
│ └── application/
│ ├── port/in/ # Input Port (UseCase Interface)
│ ├── port/out/ # Output Port (Repository/External Interface)
│ └── service/ # UseCase 구현체
│ └── metric/IrMetricCalculator.kt # nDCG, P@K, MRR, t-test
│
├── search-tuner-infra-es/ # ES Java Client 8.x
│ └── src/main/kotlin/.../infra/es/
│ ├── config/ElasticsearchConfig.kt
│ ├── adapter/ElasticsearchAdapter.kt # ElasticsearchPort 구현
│ ├── index/ProductIndexer.kt # IndexProductUseCase 구현
│ ├── index/IndexJobRegistry.kt # ConcurrentHashMap 기반 Job 추적
│ ├── index/MigrationJobRegistry.kt
│ └── dto/ProductDocument.kt
│
├── search-tuner-infra-llm/ # Spring AI — 공통 (인터페이스 + 어댑터 + 프롬프트)
│ └── src/main/kotlin/.../infra/llm/
│ ├── config/LlmConfig.kt # 전략 선택 + ChatClient Bean 등록
│ ├── provider/LlmProviderStrategy.kt # 전략 인터페이스 (buildChatClient)
│ ├── LlmAdapter.kt # LlmPort 구현
│ ├── LlmResponseParser.kt # markdown 코드블록 strip + JSON parse
│ └── prompt/ # 동의어/분석기/Relevance 프롬프트 템플릿
│
├── search-tuner-infra-llm-gemini/ # Gemini 전략 (priority=2, GEMINI_API_KEY/MODEL)
├── search-tuner-infra-llm-openai/ # OpenAI 전략 (priority=1, OPENAI_API_KEY/MODEL)
├── search-tuner-infra-llm-claude/ # Claude 전략 (priority=3, ANTHROPIC_API_KEY/MODEL)
│
├── search-tuner-infra-persistence/ # Spring Data JPA + MySQL
│ └── src/main/kotlin/.../infra/persistence/
│ ├── entity/ # JPA @Entity
│ ├── repository/ # JpaRepository interface
│ └── adapter/ # XxxPersistencePort 구현
│
└── search-tuner-api/ # Spring Boot 진입점
└── src/main/kotlin/.../api/
├── SearchTunerApplication.kt
├── config/ # Swagger, Jackson, Service Bean 등록
├── controller/ # REST Controllers (5개)
├── dto/request/, dto/response/ # API DTO
└── init/
├── DataInitializer.kt # 앱 시작 시 샘플 데이터 생성
└── GoldenQuerySetLoader.kt # golden_query_set.yaml 로드
| 컴포넌트 | 버전 | 비고 |
|---|---|---|
| Spring Boot | 3.4.3 | Spring AI 호환성 확보 |
| Kotlin | 2.1.20 | Spring Boot 3.4 BOM 기준 |
| Spring AI | 1.0.0 GA | spring-ai-starter-model-openai / spring-ai-starter-model-anthropic |
| ES Java Client | 8.17.0 | |
| SpringDoc OpenAPI | 2.8.5 | |
| MySQL | 8.0 | Docker |
| Elasticsearch | 8.17.0 | Docker + Nori plugin |
| Kibana | 8.17.0 | Docker, http://localhost:5601 |
| Java toolchain | 21 |
classDiagram
class Product {
+Long id
+Long shopId
+String productName
+String? description
+String? brand
+String category
+BigDecimal? price
+LocalDateTime createdAt
+LocalDateTime updatedAt
}
class Shop {
+Long id
+String name
+String? description
+String category
}
class SynonymSet {
+Long id
+String name
+List~SynonymGroup~ groups
+SynonymSetStatus status
+approve() SynonymSet
+apply() SynonymSet
+toSynonymFileContent() String
+updateGroup(groupId, updated) SynonymSet
}
class SynonymGroup {
+String id
+List~String~ terms
+SynonymType type
+Double confidence
+String? reasoning
+toEsSynonymLine() String
}
class SynonymType {
<<enumeration>>
EQUIVALENT
ONEWAY
}
class SynonymSetStatus {
<<enumeration>>
PENDING_REVIEW
APPROVED
APPLIED
}
class EvaluationResult {
+Long id
+String configLabel
+Double ndcgAt10
+Double precisionAt5
+Double mrr
+Int queryCount
+List~QueryEvaluationScore~ perQueryScores
}
class QueryEntry {
+String id
+String query
+String intent
+List~ProductRelevance~ expectedRelevant
+List~Long~ expectedIrrelevant
}
class IndexJob {
+String id
+String type
+IndexJobStatus status
+Long total
+Long indexed
+Int progressPercent
}
class MigrationJob {
+String id
+String oldIndex
+String newIndex
+String alias
+MigrationJobStatus status
}
Shop "1" --> "N" Product
SynonymSet "1" --> "N" SynonymGroup
SynonymGroup --> SynonymType
SynonymSet --> SynonymSetStatus
EvaluationResult "1" --> "N" QueryEvaluationScore
SynonymGroup.toEsSynonymLine() — ES 동의어 파일 포맷으로 직렬화
// EQUIVALENT: 나이키, Nike, NIKE, 나이크
"나이키, Nike, NIKE, 나이크"
// ONEWAY: 캐구 => 캐나다구스 (캐구 검색 시 캐나다구스도 매칭, 반대는 아님)
"캐구 => 캐나다구스"SynonymSet — 상태 전이
stateDiagram-v2
[*] --> PENDING_REVIEW : LLM 생성 완료
PENDING_REVIEW --> APPROVED : 사람 리뷰 후 approve()
APPROVED --> APPLIED : ES 적용 후 apply()
PENDING_REVIEW --> APPLIED : 직접 적용 (자동화 모드)
MySQL에 저장된 모든 상품을 ES로 색인한다. 1,000건씩 청크로 나눠 Bulk API로 전송한다.
sequenceDiagram
participant Client
participant IndexController
participant ProductIndexer
participant IndexJobRegistry
participant MySQL
participant ES
Client->>IndexController: POST /api/v1/index/full
IndexController->>ProductIndexer: startFullIndex("products")
ProductIndexer->>IndexJobRegistry: register(IndexJob{PENDING})
ProductIndexer-->>IndexController: IndexJob(jobId, PENDING)
IndexController-->>Client: 202 Accepted {jobId}
Note over ProductIndexer,ES: 별도 Thread에서 비동기 실행
ProductIndexer->>MySQL: countAll()
MySQL-->>ProductIndexer: total = 10,000
loop page = 0, 1, 2 ... (1,000건씩)
ProductIndexer->>MySQL: findAll(page, 1000)
MySQL-->>ProductIndexer: List<Product>
ProductIndexer->>ES: bulkIndex("products", products)
ProductIndexer->>IndexJobRegistry: updateProgress(indexed, total)
end
ProductIndexer->>IndexJobRegistry: complete(jobId)
Client->>IndexController: GET /api/v1/index/jobs/{jobId}
IndexController->>IndexJobRegistry: get(jobId)
IndexJobRegistry-->>Client: {status: COMPLETED, indexed: 10000, progressPercent: 100}
핵심 설계 포인트:
@Async대신Thread { }.start()— Spring AOP는 동일 클래스 내부 호출 시 프록시를 거치지 않아@Async가 무효화되기 때문IndexJobRegistry=ConcurrentHashMap<String, IndexJob>— 인메모리 Job 상태 추적- Chunk size 1,000 — Spring Batch의 청크 지향 처리와 동일한 패턴을 단순화 구현
마지막 동기화 이후 변경된 상품만 색인한다.
sequenceDiagram
participant ProductIndexer
participant MySQL
participant ES
Note over ProductIndexer: lastSyncAt = 이전 실행 시각 (메모리 유지)
ProductIndexer->>MySQL: findUpdatedAfter(lastSyncAt, page, 1000)
MySQL-->>ProductIndexer: updated products (updated_at > lastSyncAt)
ProductIndexer->>ES: bulkIndex("products", updatedProducts)
Note over ProductIndexer: lastSyncAt = now (업데이트)
lastSyncAt은 in-memory — 앱 재시작 시 리셋됨 (데모 범위). 운영 환경에서는 Debezium(CDC) → Kafka → ES Sink Connector 패턴을 사용한다.
sequenceDiagram
participant Client
participant ProductController
participant ProductService
participant ElasticsearchAdapter
participant ES
Client->>ProductController: GET /api/v1/products/search?q=캐구 패딩
ProductController->>ProductService: search(SearchProductQuery{query:"캐구 패딩"})
ProductService->>ElasticsearchAdapter: search(query)
ElasticsearchAdapter->>ES: multi_match query
Note over ES: product_name^3, brand^2, description<br/>search_analyzer: korean_search<br/>(nori + synonym_filter)
ES-->>ElasticsearchAdapter: hits [캐나다구스 롱패딩, ...]
ElasticsearchAdapter-->>ProductService: SearchResult
ProductService-->>ProductController: SearchResult
ProductController-->>Client: SearchResponse{hits, total, took}
{
"query": {
"multi_match": {
"query": "캐구 패딩",
"fields": ["product_name^3", "brand^2", "description"],
"type": "best_fields"
}
},
"highlight": {
"fields": {
"product_name": {},
"description": {}
}
}
}product_name^3: 상품명 필드에 3배 가중치.
검색 시 korean_search 분석기가 적용되어 "캐구"가 동의어 필터를 통해 "캐나다구스"로 확장된다.
flowchart TD
A["POST /api/v1/synonyms/generate<br/>{name, category, confidenceThreshold: 0.7}"]
B["ES sampleFieldValues<br/>terms aggregation으로<br/>상품명 상위 N개 추출"]
C["500개씩 청크 분할"]
D["LLM에게 배치 전송<br/>SynonymPromptTemplate"]
E{LLM 응답 성공?}
F["JSON parse<br/>LlmResponseParser.parse()"]
G["confidence >= 0.7 필터링"]
H["mergeOverlappingGroups<br/>공통 term을 가진 그룹 병합"]
I["SynonymSet 생성<br/>status: PENDING_REVIEW"]
J["MySQL 저장"]
K["SynonymSetResponse 반환"]
L["retry 1회"]
M["해당 배치 skip"]
A --> B --> C --> D --> E
E -->|"성공"| F --> G --> H --> I --> J --> K
E -->|"실패"| L --> E
L -->|"재시도 실패"| M --> C
핵심: 동의어 클러스터 병합 알고리즘
// LLM이 따로 생성한 그룹들이 공통 term을 공유할 수 있다
// Group A: [캐나다구스, 캐구]
// Group B: [캐구, Canada Goose]
// → 병합: [캐나다구스, 캐구, Canada Goose]
private fun mergeOverlappingGroups(groups: List<SynonymGroup>): List<SynonymGroup> {
for (i in groups.indices) {
for (j in (i+1) until groups.size) {
if (groups[j].terms.any { it in merged.terms }) {
// 공통 term 발견 → 두 그룹 합치기
merged = merged.copy(terms = (merged.terms + groups[j].terms).distinct())
}
}
}
}[System]
당신은 한국 이커머스 검색 엔진의 동의어 사전 전문가입니다.
규칙:
1. 동의어는 "같은 상품/개념을 다르게 표현한 것"만 포함합니다.
2. 상위-하위 개념은 동의어가 아닙니다. (신발 ≠ 운동화)
3. 브랜드명 한글/영문/줄임말은 동의어입니다. (나이키 = Nike = NIKE)
4. 한국 소비자 줄임말을 포함합니다. (캐나다구스 = 캐구)
5. 다의어 주의: 카테고리 컨텍스트에 맞는 의미만 묶습니다.
6. confidence < 0.7 은 제외합니다.
출력: JSON only (마크다운 없이)
{ "synonymGroups": [{ "terms": [...], "type": "EQUIVALENT",
"confidence": 0.95, "reasoning": "..." }] }
설계 포인트:
reasoning필드 강제 → LLM이 신중하게 판단 (Chain-of-Thought 효과)confidencethreshold → 억지 동의어 차단- "상위-하위 개념 제외" 명시 → 신발→운동화→나이키 같은 잘못된 그룹 방지
- JSON 파싱 실패 시
LlmResponseParser가 markdown 코드블록(```json...) 자동 제거 후 재시도
flowchart TD
A["POST /api/v1/synonyms/{id}/apply<br/>{strategy: RELOAD or BLUE_GREEN}"]
B{전략 선택}
C["SynonymSet.toSynonymFileContent()"]
D["파일 시스템에 synonyms.txt 저장<br/>(Docker volume mount 경로)"]
E["POST /products/_reload_search_analyzers"]
F["다음 검색부터 즉시 적용<br/>다운타임 ZERO"]
G["Blue-Green Migration 시작<br/>startMigration(alias)"]
H["신규 인덱스 생성 products-v{ts}"]
I["전체 상품 재색인"]
J["Alias 원자적 전환<br/>products → products-v{ts}"]
K["구 인덱스 보관 (롤백용)"]
A --> B
B -->|"RELOAD"| C --> D --> E --> F
B -->|"BLUE_GREEN"| G --> H --> I --> J --> K
style F fill:#c8e6c9
style K fill:#fff9c4
Reload 전략의 ES 내부 메커니즘:
search_analyzer에만 synonym_filter를 적용하고
updateable: true로 설정해두면, 인덱스를 닫지 않고
_reload_search_analyzers API로 동의어를 즉시 교체할 수 있다.
index_analyzer: nori만 적용 (색인 시점)
search_analyzer: nori + synonym_filter (검색 시점) ← updateable
sequenceDiagram
participant Client
participant AnalyzerController
participant AnalyzerRecommendationService
participant ES
participant LLM
Client->>AnalyzerController: POST /api/v1/analyzers/recommend
Note right of Client: {domain: "패션 커머스",<br/>sampleTexts: ["무선블루투스이어폰", "캐나다구스"]}
loop 3가지 preset config
AnalyzerRecommendationService->>ES: analyzeText(text, "korean_none")
AnalyzerRecommendationService->>ES: analyzeText(text, "korean_discard")
AnalyzerRecommendationService->>ES: analyzeText(text, "korean_mixed")
ES-->>AnalyzerRecommendationService: 각 설정별 토큰 목록
end
Note over AnalyzerRecommendationService: 토큰화 결과 비교표 생성
AnalyzerRecommendationService->>LLM: recommendAnalyzer(domain, sampleTexts, tokenizationResults)
LLM-->>AnalyzerRecommendationService: {recommendation, reasoning, tradeoffs}
AnalyzerRecommendationService-->>Client: AnalyzerRecommendationResponse
3가지 Nori Decompound Mode 비교:
| 모드 | "무선블루투스이어폰" 토큰 | 특징 |
|---|---|---|
none |
무선블루투스이어폰 |
분리 안 함. 복합명사 검색 어려움 |
discard |
무선, 블루투스, 이어폰 |
분리 후 원형 제거. 재현율 ↑, 정밀도 ↓ |
mixed |
무선블루투스이어폰, 무선, 블루투스, 이어폰 |
원형 + 분리어 모두 보존. 권장 |
flowchart TD
subgraph L1 ["Layer 1: Golden Query Set"]
GQS["golden_query_set.yaml<br/>100개 쿼리<br/>TREC-DL 2023 형식<br/>0~3점 relevance label"]
end
subgraph L2 ["Layer 2: Automated Scoring"]
ES_SEARCH["ES 검색<br/>top-10 결과 조회"]
RANK["순위별 relevance 추출<br/>(Golden Label 기준)"]
METRICS["IR 메트릭 계산<br/>nDCG@10, P@5, MRR"]
end
subgraph L3 ["Layer 3: Statistical Validation"]
TTEST["Paired t-test<br/>p-value 계산"]
JUDGE["유의성 판정<br/>p < 0.05 → 통계적으로 유의"]
end
GQS --> ES_SEARCH --> RANK --> METRICS --> TTEST --> JUDGE
sequenceDiagram
participant EvalService as SearchQualityEvaluationService
participant ES
participant IrCalc as IrMetricCalculator
Note over EvalService: query "캐구 패딩"<br/>Golden Label: {1001:3, 1002:3, 2001:1}
EvalService->>ES: search({query:"캐구 패딩", size:10})
ES-->>EvalService: hits [1001, 3001, 1002, 5001, ...]
Note over EvalService: 순위별 relevance 매핑<br/>[rank1:3, rank2:0, rank3:3, rank4:0, ...]
EvalService->>IrCalc: calculateNdcgAt(10, [3,0,3,0,...])
IrCalc-->>EvalService: nDCG = 0.85
EvalService->>IrCalc: calculatePrecisionAt(5, [3,0,3,0,...])
IrCalc-->>EvalService: P@5 = 0.40
EvalService->>IrCalc: calculateMrr([3,0,3,0,...])
IrCalc-->>EvalService: MRR = 1.0 (rank 1이 relevant)
| 카테고리 | 개수 | 예시 |
|---|---|---|
| 브랜드 동의어 (한글/영문) | 20 | "나이키 운동화", "Nike 런닝화", "캐구 패딩" |
| 줄임말/신조어 | 15 | "에팟 프로", "갓성비 무선청소기", "홈트 기구" |
| 복합명사 (붙여쓰기) | 15 | "무선블루투스이어폰", "남성용겨울장갑" |
| 오타 | 10 | "블루투쓰 이어폰", "나이끼 운동화" |
| 카테고리 모호성 | 10 | "배" (과일 vs 배낭), "크림" (화장품 vs 식품) |
| 일반 키워드 | 30 | "겨울 코트", "요가 매트" |
sequenceDiagram
participant Client
participant EvalController
participant EvalService
participant DB
participant IrCalc
Note over Client: 사전에 두 설정으로 각각 평가 실행 완료
Client->>EvalController: POST /api/v1/evaluation/compare
Note right of Client: {configLabelA: "baseline",<br/>configLabelB: "with-synonym"}
EvalController->>EvalService: compareEvaluations(command)
EvalService->>DB: findByConfigLabel("baseline") → 최신 EvaluationResult
EvalService->>DB: findByConfigLabel("with-synonym") → 최신 EvaluationResult
Note over EvalService: 쿼리별 nDCG 점수 페어 생성<br/>scoresA = [0.72, 0.45, 0.88, ...]<br/>scoresB = [0.85, 0.88, 0.91, ...]
EvalService->>IrCalc: pairedTTest(scoresA, scoresB)
IrCalc-->>EvalService: p-value = 0.003
Note over EvalService: diffs 정렬 → topImproved 5개, topDegraded 3개
EvalService-->>Client: ComparisonReport{<br/>ndcgDelta:+17.7%, pValue:0.003,<br/>isSignificant:true,<br/>topImproved:[...], topDegraded:[...]}
결과 리포트 형태:
Config A (baseline) vs Config B (with-synonym)
Metric │ Config A │ Config B │ Δ
────────────┼──────────┼──────────┼────────
nDCG@10 │ 0.7234 │ 0.8512 │ +17.7%
P@5 │ 0.6800 │ 0.8100 │ +19.1%
MRR │ 0.7456 │ 0.8823 │ +18.3%
p-value │ │ │ 0.003
→ 통계적으로 유의한 차이 (p < 0.05)
Top 5 개선된 쿼리:
1. "캐구 패딩" 0.31 → 0.95 (+206%)
2. "블루투쓰 이어폰" 0.45 → 0.88 (+96%)
Top 3 저하된 쿼리 (주의):
1. "배 과일" 0.82 → 0.65 (-21%) ← 다의어 충돌
분석기 설정이나 매핑 자체를 바꿀 때 사용한다. ES 인덱스를 통째로 교체하면서 알리아스 전환으로 무중단을 보장한다.
stateDiagram-v2
[*] --> CREATED : POST /api/v1/index/migrate
CREATED --> INDEXING : 신규 인덱스 생성 완료\nproducts-v{timestamp}
INDEXING --> SWITCHING : 전체 색인 완료
Note right of INDEXING : MySQL → ES\n1,000건씩 청크
SWITCHING --> COMPLETED : Alias 원자적 전환\nproducts → products-v{ts}
SWITCHING --> FAILED : 전환 실패
COMPLETED --> ROLLED_BACK : POST .../rollback\n구 인덱스로 alias 복구
FAILED --> ROLLED_BACK : 자동 롤백 가능
Alias 전환이 왜 다운타임 제로인가:
// 이 두 작업이 하나의 원자적 트랜잭션으로 실행됨
POST /_aliases
{
"actions": [
{ "remove": { "index": "products-v1", "alias": "products" } },
{ "add": { "index": "products-v2", "alias": "products" } }
]
}
// remove와 add 사이에 검색이 끊기는 순간이 없음모든 계산은 IrMetricCalculator.kt (외부 의존성 없이 Kotlin만 사용)에 구현되어 있다.
DCG@k = Σ(i=1..k) rel_i / log₂(i + 1)
iDCG@k = 이상적인 순위(관련도 내림차순)로 계산한 DCG
nDCG@k = DCG@k / iDCG@k (0.0 ~ 1.0)
직관: 순위가 낮을수록(뒤에 있을수록) log₂ 분모가 커져 기여도가 줄어든다. 관련도 3짜리 문서가 1위에 있으면 3/log₂(2)=3.0, 5위에 있으면 3/log₂(6)≈1.16.
예시: 쿼리 "캐구 패딩"
rank 1: rel=3 → 3 / log₂(2) = 3.000
rank 2: rel=0 → 0
rank 3: rel=3 → 3 / log₂(4) = 1.500
rank 4: rel=0 → 0
DCG = 4.500
이상적 순위: [3,3,0,0,...] → iDCG = 3/1 + 3/1.585 = 4.893
nDCG@10 = 4.500 / 4.893 = 0.920
P@5 = (상위 5개 결과 중 관련 있는 결과 수) / 5
"관련 있다"는 relevance > 0인 경우(즉, 0점이 아닌 경우).
MRR = 1 / (첫 번째 관련 결과의 순위)
관련 결과가 1위면 1.0, 2위면 0.5, 3위면 0.333, 없으면 0.
외부 라이브러리(Commons Math) 없이 직접 구현했다.
H₀: 설정 A와 B의 쿼리별 nDCG 차이 = 0
H₁: 차이 ≠ 0
diffs[i] = nDCG_B[i] - nDCG_A[i] (각 쿼리에 대해)
mean_diff = Σ diffs / n
t = mean_diff / (std_dev / √n)
p-value: t 분포의 양측 검정
→ p < 0.05: "B가 A보다 통계적으로 유의하게 좋다"
t 분포 p-value는 불완전 베타 함수를 연분수 전개(Lentz 알고리즘)로 계산하고, log-Gamma는 Lanczos 근사(g=7, n=9 계수)를 사용한다.
flowchart LR
subgraph Index_Time ["색인 시점 (korean_index)"]
I1["원문\n캐나다구스 남성 롱패딩"] --> I2["nori_mixed tokenizer\n(decompound_mode: mixed)"] --> I3["lowercase filter"] --> I4["nori_part_of_speech filter\n(조사·어미 제거)"] --> I5["토큰 저장\n캐나다구스 남성 롱 패딩"]
end
subgraph Search_Time ["검색 시점 (korean_search)"]
S1["검색어\n캐구 패딩"] --> S2["nori_mixed tokenizer"] --> S3["lowercase filter"] --> S4["nori_part_of_speech filter"] --> S5["synonym_filter\n(updateable: true)\n캐구→캐나다구스"] --> S6["확장된 토큰\n캐나다구스 패딩"]
end
핵심: synonym_filter는 search_analyzer에만 적용된다.
index_analyzer에 적용하면 동의어 변경 시 전체 재색인이 필요하지만,
search_analyzer에만 적용하면 _reload_search_analyzers API만으로 즉시 반영된다.
{
"product_name": {
"type": "text",
"analyzer": "korean_index",
"search_analyzer": "korean_search",
"fields": {
"keyword": { "type": "keyword" }
}
},
"brand": {
"type": "text",
"analyzer": "korean_index",
"search_analyzer": "korean_search"
},
"category": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 }
}product_name.keyword — terms aggregation으로 동의어 생성 시 상품명 샘플링에 사용.
# EQUIVALENT: 모든 term이 동등하게 매칭
나이키, Nike, NIKE, 나이크
캐나다구스, 캐구, Canada Goose
# ONEWAY: term1 검색 시 term2도 매칭 (반대는 아님)
에팟 => 에어팟
블루투쓰 => 블루투스
| Method | Path | 설명 |
|---|---|---|
GET |
/api/v1/products |
상품 목록 (페이지네이션) |
GET |
/api/v1/products/{id} |
상품 단건 조회 |
POST |
/api/v1/products |
상품 생성 |
GET |
/api/v1/products/search?q={query} |
ES 검색 (GET) |
POST |
/api/v1/products/search |
ES 검색 (explain/highlight 옵션 포함) |
POST |
/api/v1/products/search/analyze |
텍스트 분석기 토큰화 테스트 |
| Method | Path | 설명 |
|---|---|---|
POST |
/api/v1/index/full |
전체 색인 시작 (비동기) |
POST |
/api/v1/index/sync |
증분 색인 시작 (비동기) |
GET |
/api/v1/index/jobs/{jobId} |
색인 Job 상태 조회 |
POST |
/api/v1/index/migrate |
Blue-Green 마이그레이션 시작 |
GET |
/api/v1/index/migrate/{migrationId} |
마이그레이션 상태 조회 |
POST |
/api/v1/index/migrate/{migrationId}/rollback |
마이그레이션 롤백 |
| Method | Path | 설명 |
|---|---|---|
POST |
/api/v1/synonyms/generate |
LLM으로 동의어 생성 |
GET |
/api/v1/synonyms |
동의어 세트 목록 |
GET |
/api/v1/synonyms/{id} |
동의어 세트 단건 조회 |
GET |
/api/v1/synonyms/{id}/download |
.txt 파일로 다운로드 |
POST |
/api/v1/synonyms/{id}/apply |
ES에 적용 (RELOAD or BLUE_GREEN) |
PATCH |
/api/v1/synonyms/{id}/groups/{groupId} |
동의어 그룹 수정 |
| Method | Path | 설명 |
|---|---|---|
POST |
/api/v1/analyzers/recommend |
LLM 분석기 추천 |
POST |
/api/v1/analyzers/compare |
분석기 설정 비교 |
| Method | Path | 설명 |
|---|---|---|
POST |
/api/v1/evaluation/run |
품질 평가 실행 (nDCG@10, P@5, MRR) |
POST |
/api/v1/evaluation/compare |
A/B 비교 + paired t-test |
GET |
/api/v1/evaluation/{id}/report |
평가 결과 조회 |
GET |
/api/v1/evaluation/query-sets |
쿼리 세트 목록 |
POST |
/api/v1/evaluation/query-sets |
커스텀 쿼리 세트 등록 |
graph TD
App["search-tuner-app\n:8080"]
MySQL["MySQL 8\n:3306\nDB: search_tuner"]
ES["Elasticsearch 8.17\n:9200\nNori plugin"]
SynFile["synonyms/\nproduct_synonyms.txt\n(volume mount)"]
App -->|"JDBC"| MySQL
App -->|"HTTP"| ES
ES --- SynFile
App --- SynFile
# 인프라만 먼저 기동 (앱 제외)
docker compose up mysql elasticsearch
# 앱 포함 전체 기동
docker compose --profile full upsequenceDiagram
participant Spring
participant GoldenQuerySetLoader
participant DataInitializer
Spring->>GoldenQuerySetLoader: @Order(1) run()
GoldenQuerySetLoader->>GoldenQuerySetLoader: golden_query_set.yaml 파싱
GoldenQuerySetLoader->>EvaluationService: saveQuerySet("golden", 100 queries)
Spring->>DataInitializer: @Profile("!test") run()
DataInitializer->>DataInitializer: countAll() > 0? → skip
DataInitializer->>MySQL: saveAll(100 shops)
DataInitializer->>MySQL: saveAll(10,000 products)
# 1. 전체 색인
curl -X POST http://localhost:8080/api/v1/index/full
# → {"jobId":"xxx", "status":"PENDING"}
# 2. Job 상태 확인
curl http://localhost:8080/api/v1/index/jobs/xxx
# → {"status":"COMPLETED", "indexed":10000, "progressPercent":100}
# 3. 기본 검색
curl "http://localhost:8080/api/v1/products/search?q=패딩"
# 4. 동의어 생성 (LLM API 키 필요)
curl -X POST http://localhost:8080/api/v1/synonyms/generate \
-H "Content-Type: application/json" \
-d '{"name":"fashion-synonyms","category":"패션/의류","confidenceThreshold":0.7}'
# 5. 동의어 적용 (다운타임 없이)
curl -X POST http://localhost:8080/api/v1/synonyms/1/apply \
-d '{"strategy":"RELOAD","indexName":"products"}'
# 6. 품질 평가
curl -X POST http://localhost:8080/api/v1/evaluation/run \
-d '{"configLabel":"baseline","indexName":"products","querySetId":"golden"}'
# 7. Swagger UI
open http://localhost:8080/swagger-ui.htmlSPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/search_tuner
SPRING_DATASOURCE_USERNAME: tuner
SPRING_DATASOURCE_PASSWORD: tuner123
ES_HOST: localhost
ES_PORT: 9200
# LLM 제공자 중 하나 설정 (Claude > Gemini > OpenAI 우선순위)
GEMINI_API_KEY: ... # Google Gemini
GEMINI_MODEL: gemini-2.5-flash-lite
# OPENAI_API_KEY: sk-... # OpenAI
# OPENAI_MODEL: gpt-4o-mini
# ANTHROPIC_API_KEY: sk-ant-... # Anthropic Claude
# ANTHROPIC_MODEL: claude-3-5-haiku-20241022
ES_SYNONYMS_PATH: ./docker/elasticsearch/config/synonyms/product_synonyms.txtSpring Boot 4.0.3은 Spring AI 및 ES Java Client 8.x와 의존성 충돌이 있다. Spring AI 1.0.0 GA는 Spring Boot 3.4.x BOM 기준으로 동작한다.
Spring AI 1.0.0 GA에서 스타터 아티팩트명이 변경됐다.
이전 (M6 이하): spring-ai-openai-spring-boot-starter
이후 (1.0.0 GA): spring-ai-starter-model-openai
Spring의 @Async는 AOP 프록시를 통해 동작한다.
같은 클래스 내에서 메서드를 호출하면 프록시를 거치지 않아 @Async가 무효화된다.
ProductIndexer.startFullIndex() → runFullIndex() 호출이 이 패턴이므로
Thread { runFullIndex(...) }.start() 방식으로 직접 실행했다.
| index-time | search-time (채택) | |
|---|---|---|
| 동의어 변경 시 | 전체 재색인 필요 | reload API 호출만으로 즉시 반영 |
| 성능 | 색인 부하 분산 | 검색 시 약간의 추가 연산 |
| 관리 용이성 | 어려움 | 쉬움 |
SearchQualityEvaluationService.querySets는 MutableMap으로 메모리에 보관된다.
Golden Query Set은 앱 시작 시 YAML에서 로드되고, 커스텀 쿼리는 API로 추가할 수 있다.
재시작 시 YAML 쿼리는 자동 복원되므로 DB 저장 없이 충분하다 (데모 범위).
LLM은 종종 JSON을 마크다운 코드블록으로 감싸서 응답한다.
LlmResponseParser가 ```json ... ``` 패턴을 자동으로 제거한 뒤 파싱한다.
파싱 실패 시 retry 1회, 그래도 실패하면 해당 배치를 skip하고 계속 진행한다.
외부 라이브러리(Apache Commons Math) 의존 없이
Lanczos log-gamma 근사 + 불완전 베타 함수 연분수 전개로 p-value를 계산했다.
search-tuner-core를 순수 Kotlin으로 유지하기 위한 결정이다 (Spring, DB 무의존 원칙).