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