"""질의 확장 — 도메인 용어 사전으로 동의어를 펼친다 (계획서 §5.6, 수정사항 4번). "입고"로 물었을 때 GR / MSEG / 101 로 색인된 문서도 찾아야 한다. ## 왜 한 MATCH 식에 동의어를 섞지 않는가 `index.db.fts_or` 는 토큰과 한글 2-gram 을 전부 OR 로 잇는다. 이미 재현율 편향이라 동의어까지 같은 식에 넣으면 정밀도가 더 나빠진다 ("총계정원장" 질의가 "원장" 2-gram 하나로 걸린 문서와 동일 가중치가 된다). 그래서 **2단 검색**을 한다: 1단 — 원질의만으로 검색. 이걸 항상 상위에 둔다. 2단 — 결과가 부족할 때만(top_k 미만) 동의어 식으로 보충하고, 점수에 감쇠 계수를 걸고 matched_by='동의어 확장' 으로 표시해 호출 측이 구분할 수 있게 한다. 감쇠 계수(EXPANDED_WEIGHT)는 원질의 히트가 항상 앞서도록 하는 장치다. """ from __future__ import annotations from config.glossary import synonym_map from index.db import fts_or, query_tokens # 동의어 히트에 곱하는 점수 감쇠 — 원질의 히트보다 항상 뒤에 놓이게 한다 EXPANDED_WEIGHT = 0.35 MAX_SYNONYMS = 24 def expansion_terms(q: str) -> list[str]: """질의 토큰의 동의어 목록 (원질의 토큰 자체는 제외).""" syn = synonym_map() tokens = query_tokens(q) seen = {t.upper() for t in tokens} out: list[str] = [] for t in tokens: for s in syn.get(t.upper(), []): if s.upper() in seen: continue seen.add(s.upper()) out.append(s) if len(out) >= MAX_SYNONYMS: return out return out def match_exprs(q: str) -> tuple[str, str | None]: """(원질의 MATCH 식, 동의어 MATCH 식 | None)""" terms = expansion_terms(q) return fts_or(query_tokens(q)), (fts_or(terms) if terms else None) def explain(q: str) -> dict: """확장 결과 설명 — API 응답에 실어 "왜 이게 나왔나"를 보이게 한다.""" terms = expansion_terms(q) return {"query": q, "tokens": query_tokens(q), "synonyms": terms, "expanded": bool(terms), "expanded_weight": EXPANDED_WEIGHT}