""" 하이브리드 메뉴 검색 엔진 (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