""" BIX 확인요청 1~4번용 디버그 로그 덤프 ===================================== 대표 케이스에 대해 질의 분석 / 후보 Top-N(점수) / 부스트 반영 / 최종 Top-3를 사람이 읽을 수 있는 리포트(markdown)로 떠준다. 실행: .venv/Scripts/python.exe scripts/41_debug_dump.py > ../BIX_메뉴검색_20260624/BIX_디버그로그.md """ import sys, os from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) sys.stdout.reconfigure(encoding="utf-8") os.environ.setdefault("LLM_BACKEND", "openai") import warnings; warnings.filterwarnings("ignore") import logging; logging.disable(logging.CRITICAL) import json from core.search_engine import MenuSearchEngine from core.agent import _detect_category, _detect_intent from config import RAW_DIR _RAW = {m["menu_id"]: m for m in json.loads((RAW_DIR / "real_menus.json").read_text(encoding="utf-8"))} def _top_category(menu_id: str) -> str: """메뉴 경로의 최상위 카테고리 (예: '자산/뱅킹').""" path = _RAW.get(menu_id, {}).get("menu_path", "") return path.split(">")[0].strip() if path else "" CASES = [ ("해외주식잔고 어떻게 확인해", "SCR_3032", "시장 제약 (해결)"), ("해외CFD 호가 보여줘", "SCR_3510", "상품 제약 (해결)"), ("미국옵션 기간별 손익 보여줘", "SCR_3477", "상품 제약 (해결)"), ("적립식 펀드 자동매수 중단하고 싶어", "SCR_5631", "행위 의도 (해결)"), ("해외선물옵션대회중계 보여줘", "SCR_6097", "행위 의도 (해결)"), ("해외주식 옮기기 어떻게 해", "SCR_5008", "행위 의도 (미해결)"), ("해외주식 시세알림", "", "관련성 컷오프 예시"), ("내 키움 잔고 보여줘", "SCR_1300", "정상 (대조군)"), ] eng = MenuSearchEngine.get_instance() print("# bix-mcp 단계별 디버그 로그 (확인요청 1~4번 + 컷오프 예시)\n") print("각 케이스마다 ①질의 분석 ②후보 Top-10(점수) ③부스트 반영 ④최종 Top-3 순으로 떴습니다.") print("점수 컬럼: `최종`=RRF를 0~1 정규화한 최종 점수, `cos`=원 코사인 유사도, " "`bm25`=BM25 정규화, `D/B/Q순위`=Dense/BM25/질문매칭 각 순위(999=미매칭).\n") print("> 참고: 저희는 별도 리랭커 단계가 없습니다(②③이 한 흐름). 감지 카테고리는 후보를 " "거르지 않고(전체 recall 유지) 해당 카테고리에 약한 가점만 주는 방식(소프트)입니다.\n") print("---\n") for query, expected, label in CASES: cat = _detect_category(query) intent = _detect_intent(query) # 실제 서빙(run_agent)과 동일하게 감지 카테고리를 소프트 가점으로 반영한다. res = eng.search(query=query, top_n=10, threshold=0.0, category_filter=cat, use_hyde=True, use_reranker=False) print(f"## 「{query}」 — {label}") if expected: print(f"- **기대 정답**: `{expected}`") print(f"\n**① 질의 분석**") print(f"- 감지 카테고리(시장/상품): `{cat or '없음 → 전체 검색'}` ← 순위에 소프트 가점") print(f"- 감지 의도(업무): `{intent}` ← (참고: 현재 순위에는 미반영)") if res: print(f"- 정규화 질의: `{res[0].get('_norm_query','')}`") hyde = res[0].get("_hyde_doc", "") if hyde: print(f"- HyDE 가상문서: {hyde[:90]}…") print(f"\n**②③ 후보 Top-10 (점수·부스트 반영 후)**\n") print("| 순위 | menu_id | 경로 | 최종 | cos | bm25 | D순위 | B순위 | Q순위 |") print("|---|---|---|---|---|---|---|---|---|") for i, r in enumerate(res[:10], 1): hit = "✅" if r["menu_id"] == expected else "" path = r["menu_path"][:32] print(f"| {i}{hit} | {r['menu_id']} | {path} | {r['similarity_pct']} | " f"{r['_dense']} | {r['_bm25']} | {r['_dense_rank']} | " f"{r['_bm25_rank']} | {r['_qi_rank']} |") top3 = res[:3] print(f"\n**④ 최종 노출 Top-3**") for i, r in enumerate(top3, 1): print(f"{i}. `{r['menu_id']}` {r['menu_name']} ({r['menu_path']}) — {r['similarity_pct']}") if expected: in3 = any(r["menu_id"] == expected for r in top3) print(f"\n→ 기대 정답 `{expected}` Top-3 {'포함 ✅' if in3 else '미포함 ❌'}") # 미해결 케이스 진단: 정답이 다른 카테고리라 소프트 가점으로도 못 올라오는지 if not in3: exp_cat = _top_category(expected) in_candidates = any(r["menu_id"] == expected for r in res) exp_rank = next((j for j, r in enumerate(res, 1) if r["menu_id"] == expected), None) if cat and exp_cat and cat != exp_cat: loc = f"후보 {exp_rank}위" if exp_rank else "후보 밖" print(f"\n> ⚠️ **원인**: 정답 `{expected}`는 `{exp_cat}` 소속인데 질의가 " f"`{cat}`로 분류됩니다. 소프트 전환으로 후보에는 살아남았으나" f"({loc}), `{cat}` 쪽 dense 유사도가 강해 아직 1위로는 못 올라옵니다. " f"업무 의도를 순위에 반영하면 해소될 케이스입니다.") else: # 관련성 컷오프 예시: 딱 맞는 메뉴가 없어 최상위도 cos(원 유사도)가 낮음 top_cos = res[0]["_dense"] if res else 0.0 print(f"\n> 📌 **컷오프 관점**: 이 질의는 딱 맞는 메뉴가 없어 최상위 후보도 " f"cos(원 코사인 유사도)가 `{top_cos}`로 낮습니다(정상 케이스는 보통 0.8+). " f"cos 기준 하한선을 두면 이런 관련성 낮은 후보 노출을 줄일 수 있습니다.") print("\n---\n")