File size: 5,912 Bytes
c2b7e24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d42a8d4
 
 
 
 
 
 
 
c2b7e24
 
 
 
d42a8d4
c2b7e24
 
 
d42a8d4
 
c2b7e24
 
 
 
 
d42a8d4
c2b7e24
 
 
 
 
d42a8d4
 
c2b7e24
d42a8d4
c2b7e24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d42a8d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2b7e24
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
"""
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")