""" Step 18: Contextual Retrieval — 메뉴별 식별 문맥 생성 및 embedding_text 보강 [Anthropic Contextual Retrieval, 2024] 각 메뉴에 대해 GPT가 "같은 카테고리의 유사 메뉴들과 구별되는 1~2문장 식별 문맥"을 생성. 이 문맥을 embedding_text 앞에 prepend 후 벡터 재임베딩. → 비슷한 메뉴 간 혼동 감소, 검색 정확도 향상 (Anthropic 발표: ~47% 실패 감소) 실행: python scripts/18_contextual_retrieval.py 완료 후 실행: python scripts/02_build_vectordb.py --reset python scripts/06_rebuild_bm25.py python scripts/17_eval_comparison.py """ import asyncio import json import shutil import subprocess import sys from datetime import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) from openai import AsyncAzureOpenAI from config import AZURE_KEY, AZURE_ENDPOINT, AZURE_API_VERSION, LLM_MODEL # ── 경로 ────────────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent.parent JSONL_PATH = BASE_DIR / "data" / "generated" / "menu_descriptions.jsonl" BACKUP_PATH = JSONL_PATH.with_name("menu_descriptions.jsonl.pre_contextual") # ── 파라미터 ────────────────────────────────────────────────────────────────── CONCURRENCY = 15 # 동시 API 호출 (이미지 없음 → 높게 설정) MAX_SIBLINGS = 25 # 형제 메뉴 최대 표시 개수 CONTEXT_MARKER = "【식별문맥】" # 재실행 시 기존 문맥 제거를 위한 마커 # ── 유틸 ────────────────────────────────────────────────────────────────────── def load_menus() -> list[dict]: with open(JSONL_PATH, encoding="utf-8") as f: return [json.loads(line) for line in f if line.strip()] def save_menus(menus: list[dict]) -> None: with open(JSONL_PATH, "w", encoding="utf-8") as f: for m in menus: f.write(json.dumps(m, ensure_ascii=False) + "\n") def strip_existing_context(text: str) -> str: """이미 추가된 식별 문맥 제거 (멱등성 보장)""" if CONTEXT_MARKER in text: idx = text.find("\n\n", text.find(CONTEXT_MARKER)) if idx != -1: return text[idx + 2:] return text def build_sibling_map(menus: list[dict]) -> dict[str, list[str]]: """2-depth 카테고리 키 → 메뉴명 목록""" result: dict[str, list[str]] = {} for m in menus: parts = [p.strip() for p in m.get("menu_path", "").split(">")] key = " > ".join(parts[:2]) if len(parts) >= 2 else (parts[0] if parts else "기타") result.setdefault(key, []).append(m["menu_name"]) return result # ── 비동기 GPT 호출 ────────────────────────────────────────────────────────── CONTEXT_SYSTEM = ( "당신은 증권 모바일 앱(영웅문S#) 메뉴 검색 시스템 전문가입니다. " "주어진 메뉴가 비슷한 메뉴들과 어떻게 구별되는지 명확하게 서술하세요." ) def _build_context_prompt(menu: dict, sibling_names: list[str]) -> str: others = [n for n in sibling_names if n != menu["menu_name"]][:MAX_SIBLINGS] sibling_str = ", ".join(others) if others else "없음" return ( f"같은 카테고리의 다른 메뉴들: {sibling_str}\n\n" f"메뉴 경로: {menu['menu_path']}\n" f"메뉴명: {menu['menu_name']}\n" f"기능 설명: {menu.get('function_desc', '')}\n" f"키워드: {', '.join(menu.get('keywords', [])[:15])}\n\n" f"위 메뉴에 대한 식별 문맥을 1~2문장으로 작성하세요.\n" f"[조건]\n" f"- 이 메뉴만의 핵심 기능과 사용 상황을 명확히 서술할 것\n" f"- 동일 카테고리의 비슷한 메뉴(예: 수익 관련 4개 메뉴)와 무엇이 다른지 구체적으로 언급할 것\n" f"- ~습니다 / ~입니다 말투로 끝낼 것\n" f"- JSON 없이 텍스트만 출력할 것" ) async def _gen_one( client: AsyncAzureOpenAI, semaphore: asyncio.Semaphore, menu: dict, sibling_names: list[str], idx: int, total: int, ) -> str: prompt = _build_context_prompt(menu, sibling_names) async with semaphore: try: resp = await client.chat.completions.create( model=LLM_MODEL, messages=[ {"role": "system", "content": CONTEXT_SYSTEM}, {"role": "user", "content": prompt}, ], max_tokens=160, temperature=0.1, ) ctx = resp.choices[0].message.content.strip() print(f" [{idx+1:3d}/{total}] {menu['menu_name'][:18]:<18} → {ctx[:50]}…") return ctx except Exception as e: print(f" [{idx+1:3d}/{total}] ⚠ {menu['menu_name']}: {e}") return "" async def _run_all(menus: list[dict], sibling_map: dict[str, list[str]]) -> list[str]: client = AsyncAzureOpenAI( api_key=AZURE_KEY, azure_endpoint=AZURE_ENDPOINT, api_version=AZURE_API_VERSION, ) sem = asyncio.Semaphore(CONCURRENCY) tasks = [] for i, menu in enumerate(menus): parts = [p.strip() for p in menu.get("menu_path", "").split(">")] key = " > ".join(parts[:2]) if len(parts) >= 2 else (parts[0] if parts else "기타") tasks.append(_gen_one(client, sem, menu, sibling_map.get(key, []), i, len(menus))) results = await asyncio.gather(*tasks) await client.close() return list(results) # ── 메인 ───────────────────────────────────────────────────────────────────── def main(): print(f"[18_ctx] Contextual Retrieval 시작 - {datetime.now():%Y-%m-%d %H:%M:%S}") print(f"[18_ctx] JSONL: {JSONL_PATH}") # ── 1. 로드 ────────────────────────────────────────────────────────────── menus = load_menus() print(f"[18_ctx] {len(menus)}개 메뉴 로드") # ── 2. 백업 ────────────────────────────────────────────────────────────── shutil.copy2(JSONL_PATH, BACKUP_PATH) print(f"[18_ctx] 백업 완료: {BACKUP_PATH.name}") # ── 3. 기존 식별 문맥 제거 (재실행 멱등성) ─────────────────────────────── already = sum(1 for m in menus if CONTEXT_MARKER in m.get("embedding_text", "")) if already: print(f"[18_ctx] 기존 식별 문맥 {already}개 제거 중…") for m in menus: m["embedding_text"] = strip_existing_context(m.get("embedding_text", "")) # ── 4. 형제 메뉴 맵 ────────────────────────────────────────────────────── sibling_map = build_sibling_map(menus) print(f"[18_ctx] 카테고리 그룹: {len(sibling_map)}개") # ── 5. GPT 식별 문맥 생성 (비동기 병렬) ────────────────────────────────── print(f"\n[18_ctx] GPT 식별 문맥 생성 (concurrency={CONCURRENCY}) …") contexts = asyncio.run(_run_all(menus, sibling_map)) # ── 6. embedding_text 보강 ─────────────────────────────────────────────── enriched = 0 for menu, ctx in zip(menus, contexts): if ctx: menu["embedding_text"] = ( f"{CONTEXT_MARKER} {ctx}\n\n{menu['embedding_text']}" ) enriched += 1 print(f"\n[18_ctx] 식별 문맥 추가: {enriched}/{len(menus)}개") # ── 7. 저장 ────────────────────────────────────────────────────────────── save_menus(menus) print(f"[18_ctx] 저장 완료: {JSONL_PATH}") # ── 8. 재빌드 안내 + 자동 실행 ─────────────────────────────────────────── print("\n" + "=" * 65) print(" ChromaDB & BM25 재빌드 시작...") print("=" * 65) py = sys.executable steps = [ [py, str(BASE_DIR / "scripts" / "02_build_vectordb.py"), "--reset"], [py, str(BASE_DIR / "scripts" / "06_rebuild_bm25.py")], ] for cmd in steps: label = Path(cmd[1]).name print(f"\n▶ {label} 실행 중…") result = subprocess.run(cmd, capture_output=False, text=True) if result.returncode != 0: print(f" ⚠ {label} 실패 (returncode={result.returncode})") print(" 수동으로 실행하세요:") print(f" {' '.join(cmd)}") else: print(f" ✓ {label} 완료") print("\n" + "=" * 65) print(" Contextual Retrieval 완료!") print("=" * 65) print(f" 식별 문맥 추가: {enriched}개 / {len(menus)}개") print(f" 백업: {BACKUP_PATH.name}") print() print(" 평가 비교 실행:") print(" python scripts/17_eval_comparison.py") print("=" * 65) if __name__ == "__main__": main()