AI_Menu_Search / core /search_engine.py
Juhaha
feat: 카테고리 소프트 필터 도입 (하드 pre-filter → 약한 가점)
46f10b0
Raw
History Blame Contribute Delete
30.3 kB
"""
하이브리드 메뉴 검색 엔진 (Dense + BM25 with RRF)
기존 MenuSearchEngine API 완전 호환:
search(query, top_n, category_filter, threshold) -> List[Dict]
내부 동작:
1. 쿼리 전처리: 조사/어미 제거 → 정규화된 쿼리
2. QueryExpander로 BM25용 쿼리 확장
3. Dense 검색 (ChromaDB, 정규화된 쿼리) <- 의미 유사도
4. BM25 검색 (확장된 쿼리) <- 키워드 매칭
5. RRF 점수 결합: score(d) = 1/(k+rank_dense) + 1/(k+rank_bm25)
- 스케일 정규화 불필요, 순위 기반이라 점수 분포 안정적
6. Dense 결과 기준 교집합 (category_filter 우회 방지)
BM25 인덱스(data/bm25_index.pkl) 없으면 Dense 단독 모드로 자동 폴백.
"""
import os
from typing import List, Optional, Dict
import numpy as np
from config import (
DEFAULT_TOP_N, SIMILARITY_THRESHOLD, SEARCH_COUNT_BOOST_WEIGHT,
STOCK_DOMAIN_BOOST_WEIGHT, STOCK_ETF_BOOST_WEIGHT,
HYDE_ANCHOR_WEIGHT, DOMAIN_BOOST_WEIGHT,
PRODUCT_PATH_BOOST_WEIGHT,
CATEGORY_SOFT_FILTER, CATEGORY_SOFT_BOOST_WEIGHT,
)
from core.domain_detector import detect_domain_with_confidence
# 상품 하위폴더 경로 부스트 매핑: (정규화 쿼리에 포함될 토큰, 메뉴 경로에 포함될 부분문자열)
# 카테고리(파생상품)는 같지만 하위 상품군이 갈리는 동명 메뉴(예: 국내CFD 호가 vs 해외CFD 호가)를
# 쿼리의 명시 상품군에 맞게 가른다. 토큰은 소문자, 경로 부분문자열은 원문 대소문자 유지.
_PRODUCT_PATH_HINTS = [
("해외cfd", "해외CFD"),
("국내cfd", "국내CFD"),
("금현물", "금현물"),
]
from core.embedder import MenuEmbedder
from core.vectorstore import MenuVectorStore
from core.bm25_index import BM25Index, DEFAULT_INDEX_PATH
from core.query_expander import QueryExpander
from core.hyde_generator import HyDEGenerator
from core.query_vectorstore import QueryVectorStore
from core.stock_entity import StockEntityDetector
# ---------------------------------------------------------------------------
# RRF 파라미터
# ---------------------------------------------------------------------------
# k=60: Cormack et al. 2009 원논문 기본값 (실험적으로 안정적)
# 값이 클수록 하위 순위 결과에도 관대해짐 (top 결과 변별력 약화)
# 값이 작을수록 top 결과에 더 집중 (순위 차이 민감)
RRF_K = 60
# Dense/BM25 순위가 모두 없을 때 사용할 최하위 순위 (후보 개수 상한)
_MAX_RANK_PENALTY = 1000
# Weighted RRF 가중치
# Dense(의미 검색): 구어체 자연어 의도 파악에 강함 → 높은 가중치
# BM25(키워드 검색): 정확한 메뉴명/키워드 매칭 보조 역할
# QueryIndex(쿼리↔쿼리 매칭): 어휘 갭 해소, 현업 쿼리 정확히 Hit시 강한 신호
W_DENSE = 1.5
W_BM25 = 0.5
W_QUERY_IDX = 0.4 # 쿼리 인덱스 가중치 — 보조 신호, Dense/BM25 결과를 뒤집지 않을 수준
# 쿼리 인덱스 유사도 임계값 (이 이상일 때만 RRF에 반영)
# 0.85는 거의 동일 문장만 통과 → 의미는 같지만 표현이 다른 앵커(0.74~0.85)가 전부
# 컷되어 행위의도/도달성 케이스에서 QI 기여 0이 됨. 환경변수로 조정 가능.
QUERY_IDX_THRESHOLD = float(os.getenv("QUERY_IDX_THRESHOLD", "0.85"))
# ---------------------------------------------------------------------------
# 조사/어미 정규화 규칙 (konlpy 없이 규칙 기반)
# ---------------------------------------------------------------------------
# 처리 순서: 긴 패턴 우선 (짧은 패턴이 먼저 걸리는 문제 방지)
_SUFFIX_RULES = [
# 의미 보존 변환 (가장 먼저 — 제거가 아닌 치환이므로 우선순위 최상위)
("주식 어때요", "주가"), # "삼성전자 주식 어때요" → "삼성전자 주가"
("주식 어때", "주가"), # "삼성전자 주식 어때" → "삼성전자 주가"
("주가 어때요", "주가"), # "삼성전자 주가 어때요" → "삼성전자 주가"
("주가 어때", "주가"), # "삼성전자 주가 어때" → "삼성전자 주가"
# 복합 어미 (먼저 처리)
("어때요", ""), # 기타 "어때요" → 제거
("어때", ""), # 기타 "어때" → 제거
("하고싶어", ""),
("하고 싶어", ""),
("하고싶은데", ""),
("하고 싶은데", ""),
("해주세요", ""),
("해줘", ""),
("하려고해", ""),
("할수있어", ""),
("할 수 있어", ""),
("알려줘", ""),
("알려주세요", ""),
("보여줘", ""),
("보여주세요", ""),
("찾아줘", ""),
("찾아주세요", ""),
# 구어체 어미
("하고", ""),
("하는", ""),
("하기", ""),
("했어", ""),
("했는데", ""),
("있어", ""),
("있는데", ""),
("싶어", ""),
("줘", ""),
# 조사 (어간 보존)
("하고싶어요", ""),
("이야", ""),
("이에요", ""),
("예요", ""),
("이죠", ""),
("이라고", ""),
]
# 단어 단위 어미 정규화 (토큰 마지막에 붙은 어미 제거)
_TOKEN_SUFFIX_RULES = [
# 어미 (긴 것 우선)
("하고싶어", ""),
("하고싶은", ""),
("하려고", ""),
("하려면", ""),
("하는데", ""),
("해야해", ""),
("해줘", ""),
("해주세요", ""),
("하고", ""),
("하면", ""),
("하기", ""),
("하는", ""),
("했어", ""),
("했는", ""),
("한다", ""),
("합니다", ""),
("할게요", ""),
("할거야", ""),
("할건데", ""),
("하러", ""), # "확인하러" → "확인"
("왔어", ""), # "왔어" → ""
("왔는데", ""),
("왔습니다", ""),
("가고싶어", ""),
("가려고", ""),
("갔어", ""),
("이야", ""), # "얼마이야" → "얼마"
("이에요", ""),
("예요", ""),
("이죠", ""),
("야", ""), # "얼마야" → "얼마", "어디야" → "어디" (이야 처리 후 남은 야)
("줘", ""),
("봐", ""),
# 조사
("으로", ""),
("에서", ""),
("에게", ""),
("한테", ""),
("에서의", ""),
("이랑", ""),
("이랑은", ""),
("이라고", ""),
]
# 검색 의미 없는 단어 (독립 토큰으로 존재할 때 제거)
# 이동 동사 완전체만 포함 — 명사("내", "어디" 등)는 제거하면 의미가 손실됨
_STOP_TOKENS = {
"왔어", "왔는데", "왔습니다", "왔어요",
"갔어", "갔는데", "갔습니다",
}
def _normalize_query(query: str) -> str:
"""
구어체 조사/어미를 제거해 핵심 명사/동사 위주로 정규화.
Dense 임베딩에도 적용 (더 깔끔한 쿼리 → 임베딩 품질 향상).
BM25 쿼리 확장 전에 실행.
예:
"주문 취소하고 싶어" → "주문 취소"
"내 주식 얼마나 올랐어?" → "내 주식 얼마나 올랐" (물음표 제거 포함)
"차트 보여줘" → "차트"
"알림 설정해줘" → "알림 설정"
"잔고 확인하러 왔어" → "잔고 확인"
"""
# 1. 특수문자 제거 (물음표, 느낌표, 마침표)
q = query.strip().rstrip("?!.")
# 2. 토큰 단위 어미 제거 + stop token 필터
tokens = q.split()
normalized_tokens = []
for token in tokens:
# 2-a. stop token 제거 (의미 없는 독립 단어)
if token in _STOP_TOKENS:
continue
new_token = token
for suffix, replacement in _TOKEN_SUFFIX_RULES:
if new_token.endswith(suffix) and len(new_token) > len(suffix):
new_token = new_token[: -len(suffix)] + replacement
break # 첫 번째 매칭만 적용
# 처리 후에도 stop token이면 제거 (예: "오다" 같은 잔여)
if new_token.strip() and new_token not in _STOP_TOKENS:
normalized_tokens.append(new_token)
normalized = " ".join(normalized_tokens)
# 3. 문장 단위 어미 후처리 (replacement가 빈 문자열이면 제거, 아니면 치환)
for suffix, replacement in _SUFFIX_RULES:
if normalized.endswith(suffix):
prefix = normalized[: -len(suffix)].strip()
normalized = (prefix + " " + replacement).strip() if replacement else prefix
break
return normalized.strip() or query # 빈 문자열이면 원본 반환
def _rrf_score(rank_dense: int, rank_bm25: int, k: int = RRF_K) -> float:
"""
Reciprocal Rank Fusion 점수 계산.
score(d) = 1/(k + rank_dense) + 1/(k + rank_bm25)
- rank는 1-based (1위가 가장 높음)
- 한쪽에만 있는 경우 해당 rank에 _MAX_RANK_PENALTY 사용
- k=60 기본값 (Cormack et al. 2009)
Returns:
float: 0~2/(k+1) 범위 (일반적으로 0~0.03 수준)
"""
return 1.0 / (k + rank_dense) + 1.0 / (k + rank_bm25)
class MenuSearchEngine:
"""
하이브리드 메뉴 검색 엔진 (Dense + BM25, RRF 결합).
싱글톤 패턴으로 모델 재로드 방지.
BM25 인덱스가 없으면 Dense 단독 모드로 동작 (하위 호환).
"""
_instance = None
@classmethod
def get_instance(cls) -> "MenuSearchEngine":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self.embedder = MenuEmbedder()
self.vectorstore = MenuVectorStore()
self.query_expander = QueryExpander()
self.hyde_gen = HyDEGenerator.get_instance()
# 종목 엔티티 탐지기(국내/해외·ETF 1차 필터). 파일 없으면 available=False.
self.stock_detector = StockEntityDetector.get_instance()
self._last_stock_hit = None
# 쿼리 인덱스 로드 (없으면 None → 자동 스킵)
self.query_index = QueryVectorStore.get_instance()
if self.query_index:
print("[검색엔진] 쿼리 인덱스 활성 (Q<->Q 매칭 RRF 반영)")
else:
print("[검색엔진] 쿼리 인덱스 없음 (Dense+BM25만 사용)")
# BM25 인덱스 로드 (없으면 Dense 단독 모드)
try:
self.bm25_index = BM25Index.load(DEFAULT_INDEX_PATH)
self._use_bm25 = True
stats = self.bm25_index.get_stats()
print(
f"[검색엔진] RRF 하이브리드 모드 (Dense + BM25) "
f"({stats['total_documents']}개 문서, "
f"평균 {stats['avg_doc_length']:.1f}토큰, "
f"RRF k={RRF_K})"
)
except FileNotFoundError as e:
self.bm25_index = None
self._use_bm25 = False
print(f"[검색엔진] Dense 단독 모드 (BM25 인덱스 없음: {e})")
# 리랭커: Lazy Load (use_reranker=True 첫 호출 시점에만 로드)
self.reranker = None
self._use_reranker = None # None = 아직 시도 안 함
def _load_reranker(self):
"""use_reranker=True 첫 호출 시 리랭커를 로드 (Lazy Load)."""
try:
from core.cohere_reranker import CohereReranker
self.reranker = CohereReranker.get_instance()
self._use_reranker = True
print("[검색엔진] Cohere Rerank 4 리랭킹 활성")
except Exception:
try:
from core.reranker import CrossEncoderReranker
self.reranker = CrossEncoderReranker.get_instance()
self._use_reranker = True
print("[검색엔진] bge-reranker-v2-m3 로컬 리랭킹 활성")
except Exception as e:
self.reranker = None
self._use_reranker = False
print(f"[검색엔진] 리랭킹 비활성 ({e})")
# -------------------------------------------------------------------------
# 메인 검색 메서드 (기존 API 완전 유지)
# -------------------------------------------------------------------------
def search(
self,
query: str,
top_n: int = DEFAULT_TOP_N,
category_filter: Optional[str] = None,
threshold: float = SIMILARITY_THRESHOLD,
use_reranker: bool = False,
use_hyde: bool = False,
) -> List[Dict]:
"""
RRF 하이브리드 자연어 메뉴 검색.
Args:
query: 사용자 입력 ("내 주식 얼마나 올랐어?")
top_n: 반환할 최대 결과 수
category_filter: 특정 카테고리만 검색 (예: "국내주식")
threshold: 최소 최종 점수 (0~1).
RRF 점수 범위가 다르므로 자동 스케일링 적용.
use_reranker: True 시 RRF Top-10 후보에 Cross-Encoder 리랭킹 적용.
기본값 False (하위 호환 유지).
use_hyde: True 시 HyDE 적용.
사용자 쿼리 → GPT → 가상 메뉴 설명문 → Dense 임베딩.
BM25는 원본 쿼리 그대로 사용 (키워드 매칭 유지).
기본값 False (하위 호환 유지).
Returns:
[
{
"menu_id": str,
"menu_name": str,
"menu_path": str,
"similarity": float, <- 정규화된 RRF 점수 (0~1)
"similarity_pct": str,
"_dense": float, <- Dense 순위 기반 기여도 (디버깅용)
"_bm25": float, <- BM25 순위 기반 기여도 (디버깅용)
"_dense_rank": int, <- Dense 순위 (디버깅용)
"_bm25_rank": int, <- BM25 순위 (디버깅용)
"_norm_query": str, <- 정규화된 검색 쿼리 (디버깅용)
"_rrf_similarity": float, <- 리랭킹 전 RRF 점수 (use_reranker=True 시만 존재)
},
...
]
"""
# ── 1. 쿼리 전처리 (조사/어미 정규화) ────────────────────────────────
norm_query = _normalize_query(query)
# ── 2. 쿼리 확장 (BM25용) ─────────────────────────────────────────
expanded_query = self.query_expander.expand(norm_query)
# ── 3. Dense 검색 ─────────────────────────────────────────────────
# HyDE 모드: GPT로 가상 메뉴 설명문 생성 → Dense 임베딩에 사용
# 일반 모드: 정규화 쿼리 + 확장어 조합 → Dense 임베딩에 사용
# BM25는 항상 원본 expand 쿼리 사용 (키워드 매칭은 구어체 유리)
expansion_terms = self.query_expander.get_expansion_terms(norm_query)
dense_query = (
norm_query + " " + " ".join(expansion_terms[:8])
if expansion_terms
else norm_query
)
hyde_doc = None
# raw_query_vector: 원쿼리(확장 적용) 임베딩. HyDE 앵커링·Q↔Q 매칭에 재사용.
# HyDE 활성 시: embed(쿼리)와 OpenAI 호출을 병렬 실행해 대기 시간 단축.
# embed()는 CPU 연산, OpenAI는 I/O → 서로 독립적이라 ThreadPoolExecutor 안전.
if use_hyde and self.hyde_gen.available:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as _pool:
_f_embed = _pool.submit(self.embedder.embed_text, dense_query)
_f_hyde = _pool.submit(self.hyde_gen.generate, query)
raw_query_vector = _f_embed.result()
hyde_doc = _f_hyde.result()
hyde_vector = self.embedder.embed_text(hyde_doc)
# dense 벡터 앵커링: 원쿼리(W)와 HyDE문서(1-W)의 가중 평균 후 재정규화.
# HyDE가 나쁜 draw여도 원쿼리가 닻이 되어 dense 신호 납치를 막는다.
w = HYDE_ANCHOR_WEIGHT
blended = w * np.asarray(raw_query_vector) + (1.0 - w) * np.asarray(hyde_vector)
norm = np.linalg.norm(blended)
if norm > 0:
blended = blended / norm
query_vector = blended.tolist()
else:
raw_query_vector = self.embedder.embed_text(dense_query)
query_vector = raw_query_vector
# 카테고리 필터: 하드(pre-filter) vs 소프트(가점). 소프트면 where 없이 전체
# recall 확보 후 매칭 카테고리에 가점만 줘서 교차카테고리 정답을 살린다.
soft_cat: Optional[str] = None
if category_filter and CATEGORY_SOFT_FILTER:
soft_cat = category_filter
where = None
else:
where = {"category": category_filter} if category_filter else None
# 결합 후 재정렬을 위해 전체 메뉴 후보 확보
total_menus = self.vectorstore.count()
dense_top_k = total_menus if total_menus > 0 else top_n * 5
raw_dense = self.vectorstore.search(
query_vector, top_n=dense_top_k, where=where
)
# menu_id → dense 순위 맵 (1-based)
dense_rank_map: Dict[str, int] = {
r["menu_id"]: rank for rank, r in enumerate(raw_dense, 1)
}
# menu_id → dense 점수 맵 (디버깅용)
dense_score_map: Dict[str, float] = {
r["menu_id"]: r["similarity"] for r in raw_dense
}
# menu_id → 메타데이터 맵
meta_map: Dict[str, dict] = {
r["menu_id"]: r["metadata"] for r in raw_dense
}
# menu_id → document 맵 (리랭킹 시 Cross-Encoder 입력으로 사용)
dense_doc_map: Dict[str, str] = {
r["menu_id"]: r.get("document", "") for r in raw_dense
}
# ── 4. 쿼리 인덱스 검색 (Q↔Q 매칭) ───────────────────────────────
# Q↔Q 매칭엔 HyDE 블렌딩 전 원본 쿼리 벡터(raw_query_vector) 사용.
# raw_query_vector는 위에서 이미 계산됨 (중복 임베딩 제거).
query_idx_rank_map: Dict[str, int] = {}
if self.query_index:
qi_results = self.query_index.search(raw_query_vector, top_n=50)
# 임계값 이상만 RRF에 반영 (낮은 유사도는 노이즈)
qi_filtered = [r for r in qi_results if r["similarity"] >= QUERY_IDX_THRESHOLD]
# menu_id → 최고 유사도 (같은 메뉴에 여러 쿼리가 있을 수 있음)
qi_best: Dict[str, float] = {}
for r in qi_filtered:
mid = r["menu_id"]
if mid not in qi_best or r["similarity"] > qi_best[mid]:
qi_best[mid] = r["similarity"]
# 유사도 내림차순으로 순위 부여 (1-based)
sorted_qi = sorted(qi_best.items(), key=lambda x: x[1], reverse=True)
query_idx_rank_map = {mid: rank for rank, (mid, _) in enumerate(sorted_qi, 1)}
# ── 5. BM25 검색 ──────────────────────────────────────────────────
if self._use_bm25:
# 확장된 쿼리로 BM25 검색 (키워드 매칭 강화)
bm25_results = self.bm25_index.search(expanded_query, top_n=dense_top_k)
# menu_id → bm25 순위 맵 (1-based)
bm25_rank_map: Dict[str, int] = {
r["menu_id"]: r["bm25_rank"] for r in bm25_results
}
# menu_id → bm25 원시 점수 (정규화용)
max_bm25 = self.bm25_index.get_max_score(expanded_query)
bm25_score_map: Dict[str, float] = {
r["menu_id"]: r["bm25_score"] / max_bm25
for r in bm25_results
}
else:
bm25_rank_map = {}
bm25_score_map = {}
# ── 6. RRF 점수 결합 (Weighted RRF) ──────────────────────────────
# 후보 집합 = Dense 결과 기준 (category_filter 우회 방지)
# Dense(의미검색) × W_DENSE + BM25(키워드) × W_BM25 + QueryIdx × W_QUERY_IDX
scored_results: List[Dict] = []
for menu_id, d_rank in dense_rank_map.items():
if self._use_bm25:
b_rank = bm25_rank_map.get(menu_id, _MAX_RANK_PENALTY)
dense_contrib = W_DENSE / (RRF_K + d_rank)
bm25_contrib = W_BM25 / (RRF_K + b_rank)
rrf = dense_contrib + bm25_contrib
else:
rrf = W_DENSE / (RRF_K + d_rank)
dense_contrib = rrf
bm25_contrib = 0.0
# 쿼리 인덱스 기여도 (임계값 이상 매칭 시에만)
qi_rank = query_idx_rank_map.get(menu_id)
if qi_rank is not None:
qi_contrib = W_QUERY_IDX / (RRF_K + qi_rank)
rrf += qi_contrib
else:
qi_contrib = 0.0
scored_results.append({
"menu_id": menu_id,
"rrf_score": rrf,
"dense_contrib": dense_contrib,
"bm25_contrib": bm25_contrib,
"qi_contrib": qi_contrib,
"dense_rank": d_rank,
"bm25_rank": bm25_rank_map.get(menu_id, _MAX_RANK_PENALTY),
"qi_rank": qi_rank if qi_rank else _MAX_RANK_PENALTY,
"dense_score": dense_score_map.get(menu_id, 0.0),
"bm25_norm": bm25_score_map.get(menu_id, 0.0),
})
# ── 6-b. SEARCH_COUNT additive boost ──────────────────────────────────
# RRF 점수에 인기도 점수를 직접 가산 (RRF 스케일과 동일 단위)
# sc_norm = log1p(search_count) / log1p(MAX) → 0~1 범위
# sc_norm² 적용: 저조한 인기 메뉴 boost를 억제, 최고 인기 메뉴만 강하게 boost
# boost = WEIGHT * sc_norm² (국내잔고 0.964 vs 소수점잔고 0.300 → 3.2배 차이)
for item in scored_results:
sc_norm = meta_map.get(item["menu_id"], {}).get("search_count_norm", 0.0)
if sc_norm > 0:
item["rrf_score"] += SEARCH_COUNT_BOOST_WEIGHT * (sc_norm ** 2)
# ── 6-c. 종목 엔티티 1차 필터 부스트(국내/해외·ETF) ────────────────────
# 쿼리에 종목명이 있으면(예: "테슬라 차트") 해당 category 메뉴에 soft boost.
# 하드 필터가 아니라 가산이므로 오탐 시에도 후보를 제거하지 않음(recall 보존).
stock_hit = (
self.stock_detector.detect(query)
if self.stock_detector.available
else None
)
if stock_hit:
target_cat = stock_hit["domain"] # "국내주식"/"해외주식"/None
want_etf = stock_hit["etf"]
# 명확한 일반 주식(STOCK 신호만, ETF 신호 없음) 여부
pure_stock = (stock_hit["counts"]["stock"] > 0 and not want_etf)
for item in scored_results:
meta = meta_map.get(item["menu_id"], {})
if target_cat and meta.get("category") == target_cat:
item["rrf_score"] += STOCK_DOMAIN_BOOST_WEIGHT
# ETF/ETN 전용 메뉴 여부
name_path = (meta.get("menu_name", "") + meta.get("menu_path", "")).upper()
is_etf_menu = ("ETF" in name_path or "ETN" in name_path)
if want_etf and is_etf_menu:
item["rrf_score"] += STOCK_ETF_BOOST_WEIGHT # ETF 종목 → ETF 메뉴 ↑
elif pure_stock and is_etf_menu:
item["rrf_score"] -= STOCK_ETF_BOOST_WEIGHT # 일반주식 → ETF 전용 메뉴 ↓
self._last_stock_hit = stock_hit # 디버깅/표시용
else:
self._last_stock_hit = None
# ── 6-d2. 상품 하위폴더(국내CFD/해외CFD/금현물) 경로 부스트 ─────────────
# 카테고리 필터(파생상품)만으로는 못 가르는 하위 상품군을, 쿼리에 명시된
# 상품군과 경로가 일치하는 후보에 가산해 1위로 끌어올린다.
if PRODUCT_PATH_BOOST_WEIGHT > 0:
ql = norm_query.lower()
for token, path_sub in _PRODUCT_PATH_HINTS:
if token in ql:
for item in scored_results:
meta = meta_map.get(item["menu_id"], {})
if path_sub in meta.get("menu_path", ""):
item["rrf_score"] += PRODUCT_PATH_BOOST_WEIGHT
# ── 6-d1. 카테고리 소프트 부스트 ─────────────────────────────────────
# 하드 pre-filter 대신, 감지 카테고리와 일치하는 후보에 가점만 준다.
# 시장 케이스(해외/국내)는 가점으로 상위 유지하되, 정답이 다른 카테고리에
# 있는 행위 의도 케이스는 후보에서 안 잘리고 자력으로 경쟁한다.
if soft_cat and CATEGORY_SOFT_BOOST_WEIGHT > 0:
for item in scored_results:
if meta_map.get(item["menu_id"], {}).get("category") == soft_cat:
item["rrf_score"] += CATEGORY_SOFT_BOOST_WEIGHT
# ── 6-d. 도메인 감지 부스트 ──────────────────────────────────────────
# 종목 감지(6-c)가 이미 도메인을 특정했으면 중복 적용 방지
if DOMAIN_BOOST_WEIGHT > 0 and not stock_hit:
detected_domain, is_explicit = detect_domain_with_confidence(norm_query)
if is_explicit: # 명시 신호 있을 때만 부스트 (기본값 국내주식 폴백엔 미적용)
for item in scored_results:
meta = meta_map.get(item["menu_id"], {})
if meta.get("category") == detected_domain:
item["rrf_score"] += DOMAIN_BOOST_WEIGHT
# RRF 점수 내림차순 정렬
scored_results.sort(key=lambda x: x["rrf_score"], reverse=True)
# ── 7. RRF 점수 → [0,1] 정규화 ───────────────────────────────────
# 최고점을 1.0으로 스케일링 → threshold 비교 가능하게
if scored_results:
max_rrf = scored_results[0]["rrf_score"]
min_rrf = scored_results[-1]["rrf_score"]
rrf_range = max_rrf - min_rrf if max_rrf > min_rrf else 1.0
else:
max_rrf = 1.0
rrf_range = 1.0
# ── 8. threshold 필터 + 메타데이터 결합 + top_n 자르기 ───────────
filtered: List[Dict] = []
for item in scored_results:
# RRF 점수를 [0,1]로 min-max 정규화
norm_score = (item["rrf_score"] - min_rrf) / rrf_range if scored_results else 0.0
if norm_score < threshold:
continue
menu_id = item["menu_id"]
meta = meta_map.get(menu_id, {})
# dense/bm25 기여도도 같은 비율로 정규화
dense_norm = (item["dense_contrib"] - min_rrf / 2) / rrf_range if scored_results else 0.0
bm25_norm = (item["bm25_contrib"] - min_rrf / 2) / rrf_range if scored_results else 0.0
filtered.append({
"menu_id": menu_id,
"menu_name": meta.get("menu_name", ""),
"menu_path": meta.get("menu_path", ""),
"similarity": round(norm_score, 4),
"similarity_pct": f"{norm_score * 100:.1f}%",
# 디버깅 필드
"_dense": round(item["dense_score"], 4), # 원래 코사인 유사도
"_bm25": round(item["bm25_norm"], 4), # BM25 정규화 점수
"_dense_rank": item["dense_rank"],
"_bm25_rank": item["bm25_rank"],
"_qi_rank": item["qi_rank"], # 쿼리 인덱스 순위 (999=미매칭)
"_norm_query": norm_query,
"_hyde_doc": hyde_doc or "", # HyDE 가상 문서 (디버깅용, 비어있으면 비활성)
"_stock_hit": self._last_stock_hit, # 종목 1차 필터 결과(없으면 None)
})
if len(filtered) >= top_n * 2:
# 리랭킹 후보 확보: top_n * 2 (top_n=5 → 10개)
break
# ── 9. Cross-Encoder 리랭킹 (옵션, Lazy Load) ──────────────────────
if use_reranker and len(filtered) > 1:
# 첫 use_reranker=True 호출 시점에 리랭커 로드
if self._use_reranker is None:
self._load_reranker()
if self._use_reranker:
for item in filtered:
if "document" not in item or not item["document"]:
item["document"] = dense_doc_map.get(item["menu_id"], item.get("menu_name", ""))
filtered = self.reranker.rerank(norm_query, filtered, top_n=top_n)
else:
filtered = filtered[:top_n]
else:
filtered = filtered[:top_n]
return filtered