어디살지 POC 검색 — 2026 최적 설계

헥사고날 · 하이브리드 검색 (Dense + Sparse + Filter + Rerank) · 오버피팅 방지 · 점진 검증 · 2026-05-19
TL;DR

1. 임베딩 lane 분리 매트릭스 — "무엇을 임베딩하고 무엇을 안 하는가"

한 필드를 모든 인덱스에 넣으면 신호가 희석되고 비용·지연만 늘어남. 의미·정확매칭·필터 세 lane 으로 분리하는 게 2026 표준.

필드유형현재 위치Lane이유
description (LLM-enrich)자연어properties.descriptionDENSE단지명·평수·층·옵션·지역특성 의미 표현
address지명 텍스트properties.addressDENSE + SPARSE의미(주변 지역) + 정확 매칭(동·구 정확 토큰)
domain_keywords (LLM 5-10개 명사)토큰 setraw_payloadSPARSE"역세권"·"학원가" BM25 IDF 변별력
complex_name (단지명)고유명사raw_payloadSPARSE"래미안"·"자이" 정확 토큰 매칭 중요
property_typeenumproperty_type_enumFILTER한국어 토큰 ("투룸") 은 description 에 이미 포함 → 영문 enum 임베딩 노이즈
room_count / bath_countintpropertiesFILTER"2룸" 텍스트 의미 약함 → range filter 가 정확
deposit / rent / maintenance_feeBIGINTpropertiesFILTER9자리 숫자 임베딩 의미 손실, BM25 IDF 0
floor_categoryenumfloor_category_enumFILTER"고층" 토큰은 description 에 자연어로 포함됨
floor_numberintpropertiesFILTERrange filter
pet_allowed / parking / elevatorboolraw_payloadFILTER매물 카드 조건 일치 — exact filter
coord (lat/lng)geographyPostGIS POINTFILTERST_DWithin / Qdrant geo radius — embedding 절대 X
available_in_daysintpropertiesFILTER"즉시입주" urgency 슬롯과 결합
options (가전·가구 list)text[]raw_payload.optionsSPARSE + FILTER"풀옵션" BM25 + required_amenities 정확 매칭
directionenumraw_payloadFILTER"남향" 필터
build_yearintraw_payloadFILTER"신축"은 enrich description 으로, exact 는 range filter
contract_typeenum (전세/월세)raw_payloadFILTER"월세"/"전세" — 검색 의도 강함, filter 명확
area_featurestext[]raw_payloadDENSE"한강뷰"·"학원가" 자연어 — description 에 LLM 이 흡수
persona_tags (예측치)setpersona_rulesFILTER발화에서 추출 → required_payload subset 매칭
id / external_id / created_at식별·시간properties제외의미·검색 무관, 디버깅·페이지네이션 보조
raw_payload (원시 JSONB)blobproperties제외구조화 후 위 lane 으로 재분배. raw 자체는 인덱싱 X

현재 src/domain/listing_text.py:build_indexable_text 는 이미 이 원칙을 따른다 (address + description + domain_keywords만 임베딩, 가격/방수/영문 enum 제외). 본 PR 에선 sparse(BM25/SPLADE) 별도 인덱스만 추가하면 됨.

2. 검색 파이프라인 — 2026 최적 패턴

사용자 발화 ─► [L1] LLM Query Understanding (Groq function-calling) └─► structured slots: intent · clarity · property_types · area_keywords price_range · room_count · required_amenities persona_tags · urgency · sentiment │ ▼ [L2] Filter 구성 (slots → Qdrant Filter + PG WHERE) - 정확 매칭은 모두 filter (반드시 필터) - 동의어 확장은 PG `synonyms` 테이블 lookup (LLM 호출 X) │ ▼ [L3] Hybrid Retrieval (Qdrant native fusion) ┌─ Dense : Gemini Embedding 2 (3072d) ─┐ RRF / linear ├─ Sparse : BM25 + Kiwi 형태소 ─┤ weight α / β └─ Filter : payload + geo radius ─┘ (config.yaml) │ top-50 후보 ▼ [L4] Cross-encoder Rerank Cohere Rerank-Multilingual-v3 또는 BGE-Reranker-v2-M3 top-50 → top-10 (의미적 재정렬) │ ▼ [L5] Persona Boost (PG `persona_rules` 룰 기반 가중치) 오버라이드 X — 단순 score adjustment (deterministic) │ ▼ [L6] Result + Suggestion Generation (LLM) 매물 카드 + 4축 추천 발화 (다음 행동 제안) │ ▼ [L7] Streaming Response (SSE) stage → listing → suggestion → delta → signature → done

3. 헥사고날 5 Port — 어댑터 swap 가능성

Port인터페이스현 어댑터대체 후보 (swap 검증)
EmbeddingPortembed(texts) → vectors[3072]OpenRouter Gemini Embedding 2bge-m3 (1024) · text-embedding-3-large (3072) · KoSimCSE
SparsePort (신규)encode(text) → sparse_vecBM25+KiwiSPLADE-multilingual · BGE-M3 sparse mode
VectorStorePortsearch(filter, dense, sparse, k)Qdrant 1.16 (collection poc_listings_gemini_3072)Weaviate · Milvus · PG pgvector+RUM
RerankerPortrerank(query, docs) → reorderedCohere Rerank-4-Pro (OpenRouter 경유)BAAI/bge-reranker-v2-m3 (로컬, 무비용) · ColBERTv2 (late interaction)
LLMPortextract_slots / classify_listing / suggest_4Groq gpt-oss-120bGemini 3 Pro · Claude Haiku 4.5 (다국어·function calling 강세)

Use case (chat_service.py·search_service.py·listing_registrar.py) 는 Port 만 의존 → 어댑터 어떤 조합이든 wiring (DI) 만 변경.

4. 오버피팅 방지 — 평가 분할 + cross-validation

원칙 1. Train ↔ Holdout 영구 분리. 시나리오 65 중 60 (train, 튜닝용) / 5 (holdout, 제출 직전만 평가). holdout 결과 보고 코드 안 바꿈.
원칙 2. 5축 균등 (의도 명확도·인생단계·라이프스타일·우선순위·직업) — 한 카테고리 과적합 방지. 매 단계 합격 기준은 5축 평균.
원칙 3. Synthetic ↔ Real 매물 cross. Stage 3 에서 메일 리포트의 대전 실주소 일부를 추가해 generalization 검증 (synthetic 만으로 학습된 가중치가 real address 에서 깨지는지).
원칙 4. 시드 N 단계별 성능 곡선. N=5 / 20 / 50 / 100 에서 NDCG@10·MRR@10 추적. 단조 증가 안 하면 retrieval 가중치/임베딩 텍스트 버그 의심.
원칙 5. LLM Judge 5점 평균 + 변동성 (std) 둘 다 본다. 평균만 높고 std 큰 경우는 일부 카테고리 과적합 신호.

5. 하드코딩 회피 — 어디서 어떻게 외부화

대상예전 위치 (회피해야)2026 위치 (외부화)
슬롯 추출 룰정규식 / 키워드 매칭LLM function calling (extract_filters structured output)
persona → required 필터코드 dict PERSONA_RULESPG persona_rules 테이블 (이미 적용)
지역 좌표코드 dict LOCATIONSPG areas 테이블 + Kakao geocoding 폴백 (이미 적용)
property_type 한국어 매핑코드 dict {"oneroom":"원룸",...}PG enum_labels 테이블 또는 i18n 리소스
동의어 확장코드 arrayPG synonyms 테이블 (LLM 정규화 산출물)
가중치 (α dense / β sparse / persona boost)magic value 0.75 / 0.25config.yaml · 환경변수 — 어댑터별 override 가능
임계값 (recall threshold·rerank top-K)코드 상수config.yaml
enum 정의각 어댑터·테스트 자체 enumsrc/domain/enums.py 단일 진실 (이미 적용)
매물 텍스트 enrich템플릿 문자열LLM classify_listing structured output (이미 적용)

6. 점진 검증 단계 — 소수 시작 + 합격 게이트

STAGE 1 · N=5

Smoke

  • POC compose up
  • 5 매물 (지역 5개)
  • 1 발화 ("강남 투룸 6000/80")
  • /v2/ai/chat 200 + 매물 카드 ≥ 1
합격: 200 응답 + SSE 모든 stage 수신
STAGE 2 · N=20

의도 추출 정확도

  • 20 매물 (지역 16개 균등)
  • scenarios_65 의 intent+life_stage 11건
  • slot subset match
합격: extract precision ≥ 80% · 11/11 응답 success
STAGE 3 · N=50

5축 균등

  • 50 매물 (synthetic 40 + real 10)
  • scenarios_65 의 5축 train 30건
  • NDCG@10 · MRR@10 측정
합격: NDCG@10 ≥ 0.55 · 모든 축 평균 ≥ 0.50
STAGE 4 · N=100

Holdout + LLM Judge

  • 100 매물
  • scenarios_65 전수 + holdout 5건
  • LLM Judge 5점 평균·std
합격: LLM Judge ≥ 4.0 · std ≤ 0.6

각 단계 사이 합격 못 하면 다음 매물 증가 금지. 회귀 발생 시 직전 stage 로 롤백 + 가중치/임베딩 텍스트 부터 점검.

7. 위험 / 완화

위험등급완화
Cohere Rerank 응답 지연 (외부 API)MEDtop-K 50 제한 + Redis 캐시 (query_hash, 5분) + 폴백 (BGE-Reranker 로컬)
Gemini 임베딩 비용·rate limitMED임베딩 캐시 24h (이미 적용) + 매물 등록 시점 1회만 호출
synthetic 만으로 train 시 real address 회귀HIGHStage 3 부터 real 매물 10건 cross-validate
LLM slot 추출 분산도 (재현성)MEDtemperature=0.0 + seed 고정 + Judge 평가 시 std 추적
POC ↔ 본 백엔드 의존 누수LOWimport-linter poc-chat-searchbackend 금지 추가
가중치 오버피팅 (특정 시나리오 hand-tune)HIGHholdout 5건 제출 직전 1회만 평가 + train 60건 cross-validation

8. 즉시 실행 — Stage 1 시작

cd poc-chat-search
docker compose up -d                       # PG 30432 / Redis 30379 / Qdrant 30333 / API 30811
cp .env.example .env                       # OPENROUTER_API_KEY · GROQ_API_KEY 필요
python scripts/init_db.py --reset --n=5    # Stage 1 시드 (5건)

# 검증
curl -X POST http://localhost:30811/v2/ai/chat \
  -H "Content-Type: application/json" \
  -d '{"user_id":"smoke","thread_id":"t1","user_message":{"content":"강남 투룸 6000/80"},"variant":"full"}'

# Frontend
echo "VITE_POC_API_URL=http://localhost:30811" >> frontend/.env.local
cd frontend && npm run dev   # /chat-v2 페이지에서 발화 테스트

참고 — 이미 구현된 자산