AI_Menu_Search / scripts /18_contextual_retrieval.py
Juhaha
HF Spaces 데모 배포 (Streamlit + Qdrant 임베디드, 색인 빌드타임 생성)
fbd1091
Raw
History Blame Contribute Delete
10.1 kB
"""
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()