AI_Menu_Search / scripts /make_arch_docx.py
Juhaha
HF Spaces 데모 배포 (Streamlit + Qdrant 임베디드, 색인 빌드타임 생성)
fbd1091
Raw
History Blame Contribute Delete
38.4 kB
# -*- coding: utf-8 -*-
"""
영웅문 S# AI 메뉴 검색 — 아키텍처 Word 문서 생성
출력: 영웅문S#_AI메뉴검색_아키텍처.docx
"""
from docx import Document
from docx.shared import Pt, RGBColor, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
OUTPUT_PATH = r"C:\Users\leeju\Documents\s#ai메뉴\prototype\영웅문S#_AI메뉴검색_아키텍처.docx"
# ── 색상 (hex 문자열) ─────────────────────────────────────────────────────────
HEX_DARK_BLUE = "1F4E79"
HEX_MID_BLUE = "2E75B6"
HEX_LIGHT_BLUE = "9DC3E6"
HEX_DARK_GRAY = "404040"
HEX_CODE_BG = "F0F0F0"
HEX_WHITE = "FFFFFF"
HEX_ROW_ALT = "DEF0F7"
def _rgb(hex6):
r = int(hex6[0:2], 16)
g = int(hex6[2:4], 16)
b = int(hex6[4:6], 16)
return RGBColor(r, g, b)
FONT_NAME = "맑은 고딕"
# ── 헬퍼 ─────────────────────────────────────────────────────────────────────
def _set_cell_bg(cell, hex6: str):
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), hex6)
tcPr.append(shd)
def _set_para_bg(para, hex6: str):
pPr = para._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), hex6)
pPr.append(shd)
def _para_space(para, before_pt=0, after_pt=4):
pf = para.paragraph_format
pf.space_before = Pt(before_pt)
pf.space_after = Pt(after_pt)
pf.line_spacing = Pt(15)
def heading1(doc, text):
para = doc.add_paragraph()
_para_space(para, before_pt=16, after_pt=6)
run = para.add_run(text)
run.bold = True
run.font.name = FONT_NAME
run.font.size = Pt(16)
run.font.color.rgb = _rgb(HEX_DARK_BLUE)
pPr = para._p.get_or_add_pPr()
pBdr = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "6")
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), HEX_DARK_BLUE)
pBdr.append(bottom)
pPr.append(pBdr)
return para
def heading2(doc, text):
para = doc.add_paragraph()
_para_space(para, before_pt=12, after_pt=4)
run = para.add_run(text)
run.bold = True
run.font.name = FONT_NAME
run.font.size = Pt(13)
run.font.color.rgb = _rgb(HEX_MID_BLUE)
return para
def heading3(doc, text):
para = doc.add_paragraph()
_para_space(para, before_pt=8, after_pt=3)
run = para.add_run(text)
run.bold = True
run.font.name = FONT_NAME
run.font.size = Pt(11)
run.font.color.rgb = _rgb(HEX_DARK_BLUE)
return para
def body(doc, text, indent_cm=0):
para = doc.add_paragraph()
_para_space(para, after_pt=4)
if indent_cm:
para.paragraph_format.left_indent = Cm(indent_cm)
run = para.add_run(text)
run.font.name = FONT_NAME
run.font.size = Pt(10.5)
run.font.color.rgb = _rgb(HEX_DARK_GRAY)
return para
def code_block(doc, text):
para = doc.add_paragraph()
_para_space(para, before_pt=2, after_pt=4)
para.paragraph_format.left_indent = Cm(0.5)
para.paragraph_format.right_indent = Cm(0.5)
_set_para_bg(para, HEX_CODE_BG)
run = para.add_run(text)
run.font.name = "Courier New"
run.font.size = Pt(9)
run.font.color.rgb = _rgb("202020")
return para
def make_table(doc, headers, rows, col_widths_cm=None):
n_col = len(headers)
table = doc.add_table(rows=1 + len(rows), cols=n_col)
table.style = "Table Grid"
# 헤더 행
hdr = table.rows[0]
for i, h in enumerate(headers):
cell = hdr.cells[i]
_set_cell_bg(cell, HEX_DARK_BLUE)
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
para = cell.paragraphs[0]
para.paragraph_format.space_before = Pt(3)
para.paragraph_format.space_after = Pt(3)
run = para.add_run(h)
run.bold = True
run.font.name = FONT_NAME
run.font.size = Pt(10)
run.font.color.rgb = _rgb(HEX_WHITE)
# 데이터 행
for r_idx, row_data in enumerate(rows):
row = table.rows[r_idx + 1]
bg = HEX_ROW_ALT if r_idx % 2 == 0 else HEX_WHITE
for c_idx, cell_text in enumerate(row_data):
cell = row.cells[c_idx]
_set_cell_bg(cell, bg)
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
para = cell.paragraphs[0]
para.paragraph_format.space_before = Pt(2)
para.paragraph_format.space_after = Pt(2)
run = para.add_run(str(cell_text))
run.font.name = FONT_NAME
run.font.size = Pt(9.5)
run.font.color.rgb = _rgb(HEX_DARK_GRAY)
if col_widths_cm:
for r in table.rows:
for i, w in enumerate(col_widths_cm):
if i < len(r.cells):
r.cells[i].width = Cm(w)
doc.add_paragraph()
return table
# ═══════════════════════════════════════════════════════════════════════════════
# 문서 생성
# ═══════════════════════════════════════════════════════════════════════════════
def build():
doc = Document()
for section in doc.sections:
section.top_margin = Cm(2.5)
section.bottom_margin = Cm(2.5)
section.left_margin = Cm(3.0)
section.right_margin = Cm(2.5)
# ── 표지 ────────────────────────────────────────────────────────────────
doc.add_paragraph("\n\n\n")
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
_para_space(title_para, after_pt=10)
run = title_para.add_run("영웅문 S# AI 메뉴 검색 시스템")
run.bold = True
run.font.name = FONT_NAME
run.font.size = Pt(22)
run.font.color.rgb = _rgb(HEX_DARK_BLUE)
sub_para = doc.add_paragraph()
sub_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
_para_space(sub_para, after_pt=6)
run2 = sub_para.add_run("아키텍처 & 워크플로우 기술 문서")
run2.bold = True
run2.font.name = FONT_NAME
run2.font.size = Pt(15)
run2.font.color.rgb = _rgb(HEX_MID_BLUE)
doc.add_paragraph()
date_para = doc.add_paragraph()
date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run3 = date_para.add_run("2026년 5월 14일")
run3.font.name = FONT_NAME
run3.font.size = Pt(11)
run3.font.color.rgb = _rgb(HEX_DARK_GRAY)
doc.add_page_break()
# ── 1. 프로젝트 개요 ────────────────────────────────────────────────────
heading1(doc, "1. 프로젝트 개요")
body(doc,
"영웅문 S# AI 메뉴 검색은 사용자의 구어체 자연어 질문을 이해하여 "
"증권 HTS 앱(영웅문 S#)의 관련 메뉴를 정확하게 추천하는 AI 검색 시스템입니다. "
"901개 메뉴를 의미 기반(Dense)·키워드(BM25) 하이브리드 검색과 "
"LangGraph 에이전트로 처리합니다.")
make_table(doc,
["항목", "내용"],
[
["목적", "사용자의 구어체 자연어 질문을 메뉴로 매핑"],
["대상 메뉴", "영웅문 S# 앱 내 901개 메뉴"],
["검색 방식", "Dense (의미) + BM25 (키워드) RRF 하이브리드"],
["고도화", "HyDE · Contextual Retrieval · Cross-Encoder 리랭킹"],
["AI 에이전트", "LangGraph 기반, 품질 미달 시 GPT 쿼리 재작성 후 재검색"],
["임베딩 모델", "BAAI/bge-m3 (100개 언어, ~570MB)"],
["리랭킹 모델", "BAAI/bge-reranker-v2-m3 (로컬) / Cohere Rerank 4 (API)"],
["LLM", "Azure OpenAI gpt-4.1-mini"],
["웹 UI", "Streamlit"],
],
col_widths_cm=[4.5, 12]
)
heading2(doc, "기술 스택")
code_block(doc,
"Python 3.11+\n"
"├── AI/ML\n"
"│ ├── sentence-transformers # bge-m3 임베딩 + bge-reranker 리랭킹\n"
"│ ├── chromadb # 벡터 DB (HNSW 코사인 유사도)\n"
"│ ├── rank-bm25 # BM25 키워드 검색\n"
"│ ├── langgraph # 멀티턴 검색 에이전트 그래프\n"
"│ ├── openai # Azure OpenAI (GPT-4.1-mini)\n"
"│ └── cohere # Cohere Rerank 4 (선택, API key 필요)\n"
"├── Web\n"
"│ └── streamlit # 데모 웹 UI\n"
"└── Data\n"
" ├── jsonlines # JSONL 파일 처리\n"
" └── openpyxl # Excel 리포트 생성"
)
doc.add_page_break()
# ── 2. 전체 아키텍처 ─────────────────────────────────────────────────────
heading1(doc, "2. 전체 아키텍처")
heading2(doc, "2-1. 데이터 파이프라인 (오프라인, 단계별 실행)")
body(doc,
"원본 CSV(menu.csv, 1,568행)에서 유효 메뉴를 필터링한 뒤 "
"GPT 설명 생성 → 임베딩 → 인덱싱 순으로 처리합니다.")
code_block(doc,
"원본 CSV (menu.csv) — 1,568행\n"
" │\n"
" ▼ [00_import_csv.py] IS_VISIBLE_MENU=true, 팝업 제외\n"
" │\n"
"data/raw/real_menus.json (901개)\n"
" │\n"
" ├──▶ [12_update_keywords.py] 현업 키워드 Excel → keywords 갱신\n"
" │\n"
" ▼ [01_generate_descriptions.py] (또는 13·15 재생성)\n"
" │ Azure GPT-4.1-mini (비동기 병렬, concurrency=10)\n"
" │ · 기본 생성: 텍스트 전용\n"
" │ · 이미지 있는 메뉴: Vision 멀티모달 (concurrency=5)\n"
" │ · 현업 피드백 있는 메뉴: feedback_map 주입\n"
" │\n"
"data/generated/menu_descriptions.jsonl (901개 × 8필드)\n"
" │\n"
" ▼ [18_contextual_retrieval.py]\n"
" │ 같은 카테고리 sibling 메뉴들을 컨텍스트로 제공\n"
" │ GPT가 각 메뉴별 1~2문장 식별문맥 생성\n"
" │ embedding_text 앞에 【식별문맥】 prepend\n"
" │\n"
" ├──▶ [02_build_vectordb.py] ├──▶ [06_rebuild_bm25.py]\n"
" │ bge-m3 임베딩 (배치, ~12분) │ BM25 Multi-field 인덱싱\n"
" │ │\n"
" ▼ ▼\n"
"data/chroma_db/ data/bm25_index.pkl\n"
" 901개 벡터 (HNSW, 코사인) BM25 역인덱스"
)
heading2(doc, "2-2. 검색 요청 흐름 (온라인, 실시간)")
code_block(doc,
"사용자 입력: \"주문 잘못 넣었는데 어떻게 취소해?\"\n"
" │\n"
" ▼ [app/streamlit_app.py]\n"
" │\n"
" ▼ [core/agent.py — LangGraph]\n"
" │\n"
" ├── 1. analyze_query (규칙 기반, LLM 없음)\n"
" │ 카테고리 감지: \"국내주식\" | 의도 감지: \"주문\"\n"
" │\n"
" ├── 2. search_menus [core/search_engine.py]\n"
" │ ├── 쿼리 정규화 (조사·어미 제거)\n"
" │ ├── HyDE: GPT가 가상 메뉴 설명 생성 → Dense 검색 벡터로 활용\n"
" │ ├── Dense 검색 (ChromaDB, bge-m3)\n"
" │ ├── BM25 검색 (동의어 확장 후)\n"
" │ ├── Weighted RRF 결합 (Dense×1.5 + BM25×0.5)\n"
" │ ├── Search Count Additive Boost\n"
" │ └── Cross-Encoder 리랭킹 (use_reranker=True 시)\n"
" │\n"
" ├── 3. evaluate_results\n"
" │ quality_score = top1×0.7 + top3avg×0.3 | 임계값: 0.55\n"
" │\n"
" ├── [만족] ──▶ 4. generate_response → 결과 반환\n"
" │\n"
" └── [불만족, 최대 2회] ──▶ rewrite_query (GPT) ──▶ 루프백 search_menus"
)
doc.add_page_break()
# ── 3. 데이터 파이프라인 ─────────────────────────────────────────────────
heading1(doc, "3. 데이터 파이프라인")
heading2(doc, "스크립트 목록")
make_table(doc,
["스크립트", "역할"],
[
["00_import_csv.py", "CSV → JSON 변환, 유효 메뉴 필터링 (901개)"],
["01_generate_descriptions.py", "GPT로 메뉴별 설명 생성 (비동기 병렬)"],
["02_build_vectordb.py", "bge-m3 임베딩 생성 & ChromaDB 저장"],
["06_rebuild_bm25.py", "BM25 인덱스 재구축"],
["07_export_top50_review.py", "상위 100개 메뉴 검토용 Excel 출력"],
["12_update_keywords.py", "현업 키워드 Excel → real_menus.json 갱신"],
["13_regen_with_images.py", "변경 메뉴 이미지(Vision) 기반 재생성"],
["14_export_regen_review.py", "재생성 결과 검토용 Excel 출력 (부서별 시트)"],
["15_import_review_feedback.py", "현업 피드백 Excel → 201개 메뉴 재생성"],
["16_eval_with_answer.py", "정답셋 기반 Top5 정확도 평가"],
["17_eval_comparison.py", "bge-m3 / +HyDE / +HyDE+Rerank 3-way 비교 평가"],
["18_contextual_retrieval.py", "카테고리별 식별문맥 생성 → embedding_text prepend"],
],
col_widths_cm=[6, 10.5]
)
heading2(doc, "현업 협업 워크플로우")
code_block(doc,
"[현업] 키워드 Excel 작성\n"
" │\n"
" ▼ 12_update_keywords.py\n"
" │ 키워드(현업) + 키워드(채널기획) union → real_menus.json\n"
" │\n"
" ▼ 13_regen_with_images.py\n"
" │ 변경 메뉴 재생성 (이미지 있는 메뉴 → GPT Vision)\n"
" │\n"
"[현업] 화면 설명서 검토 (14_export_regen_review.py 생성 Excel)\n"
" │\n"
" ▼ 15_import_review_feedback.py\n"
" │ 수정 의견 201개 → feedback_map으로 GPT에 전달 → 재생성\n"
" │\n"
" ▼ 18_contextual_retrieval.py\n"
" │ sibling 메뉴 컨텍스트로 식별문맥 생성 → embedding_text 강화\n"
" │\n"
" ▼ 02_build_vectordb.py + 06_rebuild_bm25.py\n"
" 인덱스 재구축 완료"
)
doc.add_page_break()
# ── 4. 핵심 모듈 ──────────────────────────────────────────────────────────
heading1(doc, "4. 핵심 모듈")
heading2(doc, "4-1. 검색 엔진 (core/search_engine.py)")
body(doc, "Weighted RRF 하이브리드 검색에 HyDE와 Cross-Encoder 리랭킹을 결합합니다.")
code_block(doc,
"사용자 쿼리\n"
" │\n"
" ▼ 쿼리 정규화 (조사·어미 제거)\n"
" │\n"
" ▼ HyDE (use_hyde=True 시)\n"
" │ GPT-4.1-mini → 가상 메뉴 설명 생성 → Dense 검색 벡터로 활용\n"
" │\n"
" ├─────────────────────┬─────────────────────\n"
" ▼ ▼\n"
"Dense 검색 BM25 검색\n"
"(ChromaDB, bge-m3) (동의어 확장 후)\n"
"전체 901개 대상 전체 901개 대상\n"
" │ │\n"
" └────────────────────┘\n"
" ▼\n"
" Weighted RRF 결합\n"
" score = Dense×1.5/(k+rank) + BM25×0.5/(k+rank) [k=60]\n"
" ▼\n"
" Search Count Additive Boost\n"
" score += 0.10 × (log1p(count)/log1p(MAX))²\n"
" ▼\n"
" Threshold 필터 (≥ 0.3) → 상위 10개 후보\n"
" ▼\n"
" Cross-Encoder 리랭킹 (use_reranker=True 시)\n"
" ▼\n"
" Top-N 결과 반환 (기본 5개)"
)
heading2(doc, "4-2. 임베더 (core/embedder.py)")
make_table(doc,
["항목", "내용"],
[
["모델", "BAAI/bge-m3"],
["크기", "~570MB"],
["특징", "100개 언어 지원, 한국어·영어 혼용 처리, MIRACL 벤치마크 SOTA"],
["배치 처리", "29배치, ~12분 (CPU 기준)"],
["임베딩 대상", "embedding_text 필드 (Contextual Retrieval 적용 시 【식별문맥】 prefix 포함)"],
],
col_widths_cm=[4.5, 12]
)
heading2(doc, "4-3. HyDE 모듈 (core/hyde.py)")
body(doc,
"Hypothetical Document Embedding(Gao et al. 2022) 기법을 적용합니다. "
"GPT-4.1-mini가 입력 쿼리를 메뉴 설명으로 변환하고, "
"이 가상 문서의 임베딩을 Dense 검색 벡터로 사용합니다.")
code_block(doc,
"# 쿼리 → GPT → 가상 메뉴 설명 → 임베딩 → Dense 검색\n"
"가상 문서 예시 (쿼리: \"주문 잘못 넣었어\"):\n"
" \"주문 내역을 확인하고 미체결 주문을 취소하거나 정정할 수 있습니다.\n"
" 체결 전 주문을 수정·취소하는 기능을 제공합니다.\""
)
make_table(doc,
["항목", "내용"],
[
["사용 모델", "gpt-4.1-mini (temperature=0.3)"],
["효과", "쿼리-문서 어휘 갭 해소, Acc@1 +9.8%p"],
["기본값", "use_hyde=True"],
],
col_widths_cm=[4.5, 12]
)
heading2(doc, "4-4. Contextual Retrieval (scripts/18_contextual_retrieval.py)")
body(doc,
"Anthropic 2024 기법을 적용하여 유사 메뉴 간 혼동을 감소시킵니다. "
"같은 카테고리 sibling 메뉴들을 GPT에 제공하면 "
"GPT가 해당 메뉴만의 고유 기능·차이점을 1~2문장으로 생성하고, "
"이를 embedding_text 앞에 prepend합니다.")
code_block(doc,
"# 예시 (해외주식 > 주문 > 잔고)\n"
"【식별문맥】 해외주식 주문 카테고리 내 잔고 메뉴는 보유 해외주식의\n"
"실시간 잔고와 수익 현황을 원화 및 외화 기준으로 확인하는 기능으로,\n"
"단순 주문 실행이나 미체결 내역 조회와 구별됩니다."
)
make_table(doc,
["항목", "내용"],
[
["처리 방식", "오프라인 1회 처리 (per-query 비용 없음)"],
["마커", "【식별문맥】 (재실행 시 기존 문맥 자동 교체)"],
["동시처리", "concurrency=15, 약 10분 소요"],
],
col_widths_cm=[4.5, 12]
)
heading2(doc, "4-5. 벡터 스토어 (core/vectorstore.py)")
code_block(doc,
"ChromaDB PersistentClient\n"
" 컬렉션: \"herogun_menus\"\n"
" 유사도: 코사인 (HNSW 인덱싱)\n"
" 저장 메타데이터:\n"
" - menu_name, menu_path, category\n"
" - search_count_norm # log1p 정규화\n"
" - keywords_text # 키워드 문자열"
)
heading2(doc, "4-6. BM25 인덱스 (core/bm25_index.py)")
body(doc, "Multi-field 가중치 인덱싱으로 키워드 정확 매칭 시 점수를 대폭 높입니다.")
code_block(doc, "BM25 문서 = keywords × 3 + menu_name × 2 + embedding_text × 1")
heading2(doc, "4-7. 리랭커 (core/reranker.py / core/cohere_reranker.py)")
body(doc,
"폴백 체인으로 동작합니다. COHERE_API_KEY가 설정되면 Cohere Rerank 4를 우선 사용하고, "
"미설정 또는 실패 시 로컬 bge-reranker-v2-m3로 자동 폴백합니다.")
make_table(doc,
["항목", "Cohere Rerank 4", "bge-reranker-v2-m3"],
[
["모델명", "rerank-v3.5", "BAAI/bge-reranker-v2-m3"],
["방식", "API (유료)", "로컬 (무료)"],
["API Key", "필요 (COHERE_API_KEY)", "불필요"],
["크기", "—", "~300MB"],
["특징", "SOTA 상용 리랭커", "bge-m3 동일 패밀리, 100개 언어"],
["활성화", ".env에 키 입력 시 자동", "Cohere 실패 시 자동 폴백"],
],
col_widths_cm=[4, 7, 5.5]
)
body(doc, "입력: RRF Top-10 후보 → (query, document) 쌍 → Cross-Encoder 재점수 → 재정렬")
heading2(doc, "4-8. 쿼리 확장기 (core/query_expander.py)")
body(doc, "BM25 전용 HTS 도메인 동의어 확장을 수행합니다. Dense 검색에는 미적용됩니다.")
code_block(doc,
"SYNONYM_MAP 예시:\n"
" \"취소\" → [\"취소\", \"정정\", \"주문변경\", \"미체결\", \"정정취소\"]\n"
" \"주문\" → [\"주문\", \"매수\", \"매도\", \"거래\", \"체결\"]\n"
" \"잔고\" → [\"잔고\", \"보유\", \"수량\", \"평가금액\", \"자산\"]\n"
" \"차트\" → [\"차트\", \"일봉\", \"주봉\", \"캔들\", \"그래프\"]\n"
" \"수익\" → [\"수익\", \"손익\", \"수익률\", \"평가손익\", \"누적손익\"]"
)
heading2(doc, "4-9. LLM 클라이언트 (core/llm_client.py)")
make_table(doc,
["항목", "내용"],
[
["모델", "Azure OpenAI gpt-4.1-mini"],
["엔드포인트", "kiwoom-ai3-prod-oai-korea-central.openai.azure.com"],
["temperature", "0.3 (설명 생성) / 0.1 (식별문맥)"],
["max_tokens", "1,500 (설명 생성) / 160 (식별문맥)"],
["배치 처리", "비동기 병렬, concurrency=10 (Vision 시 5)"],
["feedback_map", "{menu_id: feedback_text} — 현업 피드백 GPT 힌트 전달"],
],
col_widths_cm=[4.5, 12]
)
doc.add_page_break()
# ── 5. 검색 요청 상세 흐름 ───────────────────────────────────────────────
heading1(doc, "5. 검색 요청 상세 흐름")
steps = [
("① 사용자 입력 (Streamlit)",
"예: \"내 주식 얼마나 올랐어?\""),
("② 쿼리 정규화 (search_engine.py)",
"조사·어미 제거"),
("③ 카테고리 감지 (agent.py — analyze_query)",
"CATEGORY_RULES 키워드 2개 이상 매칭 시 필터 적용"),
("④ HyDE (use_hyde=True)",
"GPT-4.1-mini → 가상 메뉴 설명 생성. 가상 문서를 bge-m3로 임베딩 → Dense 검색 벡터로 사용"),
("⑤ Dense 검색 (ChromaDB, bge-m3)",
"가상 문서 벡터로 코사인 유사도 검색 (전체 901개). 카테고리 필터 적용"),
("⑥ BM25 검색 (bm25_index.py)",
"쿼리 동의어 확장 → BM25 점수 (전체 901개)"),
("⑦ Weighted RRF 결합",
"Dense 기여(×1.5) + BM25 기여(×0.5) → RRF 통합 점수 + Search Count Additive Boost"),
("⑧ Cross-Encoder 리랭킹 (use_reranker=True 시)",
"RRF Top-10 후보 → Cohere/bge-reranker → 최종 재정렬"),
("⑨ 품질 평가 (agent.py — evaluate_results)",
"quality_score = top1_sim × 0.7 + avg(top3_sim) × 0.3. 임계값 0.55 미달 시 → GPT 쿼리 재작성 (최대 2회)"),
("⑩ 결과 반환",
"Top-5, threshold ≥ 0.3"),
]
for step_title, step_body in steps:
p = doc.add_paragraph()
_para_space(p, before_pt=4, after_pt=3)
p.paragraph_format.left_indent = Cm(0.3)
run_t = p.add_run(step_title + " ")
run_t.bold = True
run_t.font.name = FONT_NAME
run_t.font.size = Pt(10.5)
run_t.font.color.rgb = _rgb(HEX_DARK_BLUE)
run_b = p.add_run(step_body)
run_b.font.name = FONT_NAME
run_b.font.size = Pt(10)
run_b.font.color.rgb = _rgb(HEX_DARK_GRAY)
doc.add_page_break()
# ── 6. LangGraph 에이전트 ────────────────────────────────────────────────
heading1(doc, "6. LangGraph 에이전트")
heading2(doc, "그래프 구조 (core/agent.py)")
code_block(doc,
"START\n"
" │\n"
" ▼\n"
"[analyze_query] 규칙 기반 (LLM 없음) — 카테고리·의도 감지\n"
" │\n"
" ▼\n"
"[search_menus] MenuSearchEngine.search() 호출\n"
" RRF 하이브리드 + HyDE + 리랭킹\n"
" │\n"
" ▼\n"
"[evaluate_results] quality_score 계산\n"
" │\n"
" ├── 만족(≥0.55) 또는 재시도 2회 초과\n"
" │ └──▶ [generate_response] ──▶ END\n"
" │\n"
" └── 불만족 + 재시도 < 2\n"
" └──▶ [rewrite_query] GPT-4.1-mini 쿼리 재작성\n"
" └── 루프백 ──▶ [search_menus]"
)
heading2(doc, "AgentState 구조")
make_table(doc,
["필드", "타입", "설명"],
[
["original_query", "str", "사용자 원본 입력 (불변)"],
["refined_query", "str", "현재 검색 쿼리 (재작성 시 갱신)"],
["detected_category", "str|None", "감지된 카테고리"],
["intent", "str", "감지된 의도"],
["search_results", "list", "검색 결과"],
["quality_score", "float", "검색 품질 점수 (0.0~1.0)"],
["retry_count", "int", "재시도 횟수 (최대 2)"],
["rewrite_history", "list", "쿼리 재작성 이력"],
["use_hyde", "bool", "HyDE 활성 여부 (기본 True)"],
["use_reranker", "bool", "리랭킹 활성 여부"],
["is_satisfactory", "bool", "품질 만족 여부"],
],
col_widths_cm=[4.5, 3, 9]
)
# ── 7. 주요 설정값 ────────────────────────────────────────────────────────
heading1(doc, "7. 주요 설정값")
body(doc, "파일: config.py")
make_table(doc,
["설정", "값", "설명"],
[
["EMBEDDING_MODEL_NAME", "BAAI/bge-m3", "다국어 임베딩 모델"],
["CHROMA_COLLECTION_NAME", "herogun_menus", "ChromaDB 컬렉션명"],
["LLM_MODEL", "gpt-4.1-mini", "Azure 배포명"],
["AZURE_API_VERSION", "2025-01-01-preview", "API 버전"],
["COHERE_API_KEY", ".env 설정", "Cohere Rerank 4 활성화 키"],
["DEFAULT_TOP_N", "5", "기본 검색 결과 수"],
["SIMILARITY_THRESHOLD", "0.3", "최소 유사도 임계값"],
["QUALITY_THRESHOLD", "0.55", "에이전트 재시도 임계값"],
["RRF_K", "60", "RRF 상수 (Cormack et al. 2009)"],
["W_DENSE", "1.5", "Dense 검색 RRF 가중치"],
["W_BM25", "0.5", "BM25 검색 RRF 가중치"],
["SEARCH_COUNT_BOOST_WEIGHT", "0.10", "인기도 부스트 최대 가중치"],
],
col_widths_cm=[5.5, 4, 7]
)
# ── 8. 프롬프트 구조 ─────────────────────────────────────────────────────
heading1(doc, "8. 프롬프트 구조")
body(doc, "파일: prompts/description_prompt.txt")
body(doc, "GPT가 각 메뉴에 대해 생성하는 필드:")
make_table(doc,
["필드", "내용", "용도"],
[
["function_desc", "메뉴 주요 기능 2~3문장", "현업 검토용 설명서"],
["action_types", "이 메뉴에서 할 수 있는 행위 목록", "의도 매핑 참고"],
["user_verbs", "사용자가 쓰는 구어체 동사", "BM25 확장 참고"],
["keywords", "핵심 검색어 (전문용어+일상어)", "BM25 가중치 필드"],
["related_concepts", "연관 금융 개념", "검색 커버리지 확보"],
["sample_queries", "실제 사용자 질의 8개 이상", "현업 검토용"],
["embedding_text", "벡터 검색 핵심 문서 (250~400자)", "Dense 검색 대상"],
],
col_widths_cm=[4.5, 7, 5]
)
heading2(doc, "동적 변수")
make_table(doc,
["변수", "설명"],
[
["{keywords_section}", "현업 제공 키워드 (있을 때만 포함)"],
["{search_count_section}", "인기 메뉴 안내 (10,000회 초과 시)"],
["{feedback_section}", "현업 검토 의견 (있을 때만 포함, 반드시 반영 지시)"],
],
col_widths_cm=[5, 11.5]
)
doc.add_page_break()
# ── 9. 데이터 구조 ────────────────────────────────────────────────────────
heading1(doc, "9. 데이터 구조")
heading2(doc, "data/raw/real_menus.json")
code_block(doc,
"{\n"
" \"menu_id\": \"SCR_1000\",\n"
" \"menu_name\": \"국내관심\",\n"
" \"menu_path\": \"국내주식 > 관심종목 > 국내관심\",\n"
" \"category\": \"국내주식\",\n"
" \"screen_num\": \"1000\",\n"
" \"search_count\": 12739646,\n"
" \"keywords\": [\"관심종목\", \"관심그룹\", \"즐겨찾기\"]\n"
"}"
)
heading2(doc, "data/generated/menu_descriptions.jsonl")
code_block(doc,
"{\n"
" \"menu_id\": \"SCR_1000\",\n"
" \"function_desc\": \"사용자가 설정한 관심종목과 관심그룹을 한눈에 조회...\",\n"
" \"action_types\": [\"조회\", \"설정\", \"수정\", \"관리\"],\n"
" \"user_verbs\": [\"보고 싶어\", \"확인해\", \"찾아줘\"],\n"
" \"keywords\": [\"관심종목\", \"관심그룹\", \"즐겨찾기\", \"시세\", \"변동률\"],\n"
" \"related_concepts\": [\"시세\", \"변동률\", \"수익률\"],\n"
" \"sample_queries\": [\"내 관심종목 보여줘\", \"관심그룹 어떻게 설정해\", \"...\"],\n"
" \"embedding_text\": \"【식별문맥】 국내주식 관심종목 카테고리에서...\\n\\n주식 투자할 때...\"\n"
"}\n"
"\n"
"※ embedding_text: Contextual Retrieval 적용 후 【식별문맥】 prefix prepend됨"
)
# ── 10. 성능 지표 ─────────────────────────────────────────────────────────
heading1(doc, "10. 성능 지표")
body(doc, "평가 기준: 100개 쿼리 정답셋 (유효 81개, 평가 제외 19개)")
make_table(doc,
["모드", "Acc@1", "Acc@3", "Acc@5"],
[
["bge-m3 기본 (Dense+BM25+RRF)", "27.2%", "53.1%", "67.9%"],
["+ HyDE", "37.0%", "64.2%", "75.3%"],
["+ HyDE + Rerank", "44.4%", "75.3%", "77.8%"],
["기본 대비 개선폭", "+17.3%p", "+22.2%p", "+9.9%p"],
],
col_widths_cm=[8, 3, 3, 3]
)
heading2(doc, "고도화 기법별 기여")
make_table(doc,
["기법", "주요 효과"],
[
["bge-m3 임베딩 교체", "다국어 이해력 향상, 한국어 구어체 처리 개선"],
["HyDE", "쿼리-문서 어휘 갭 해소 (+9.8%p Acc@1)"],
["Contextual Retrieval", "유사 메뉴명 간 혼동 감소 (HyDE와 결합 시 시너지)"],
["Cross-Encoder 리랭킹", "RRF 상위 후보 정밀 재정렬 (+7.4%p Acc@1)"],
["Weighted RRF (1.5:0.5)", "Dense 의미 검색 강화"],
["Search Count Boost", "인기 메뉴 우선 노출"],
],
col_widths_cm=[5.5, 11]
)
doc.add_page_break()
# ── 11. 웹 UI ─────────────────────────────────────────────────────────────
heading1(doc, "11. 웹 UI")
body(doc, "파일: app/streamlit_app.py")
code_block(doc,
"┌───────────────────────────────────────────────────────────────┐\n"
"│ 영웅문 S# AI 메뉴 검색 │\n"
"│ 자연어로 원하는 기능을 입력하면 관련 메뉴를 찾아드립니다. │\n"
"├──────────────────┬────────────────────────────────────────────┤\n"
"│ 검색 옵션 │ [검색창: 자연어 입력] │\n"
"│ │ │\n"
"│ 카테고리 필터 │ 빠른 검색 (12개 버튼, 2행): │\n"
"│ [전체 ▼] │ [내 주식 얼마야?] [주문 취소] ... │\n"
"│ │ [내 수익률 어때?] [달러 환전] ... │\n"
"│ 등록된 메뉴 수 │ │\n"
"│ 901 │ \"주문 취소\" 검색 결과 (5개) │\n"
"│ │ │\n"
"│ │ ▼ 1위 주문 취소/정정 94.2% │\n"
"│ │ 경로: 국내주식 > 주문 > ... │\n"
"│ │ ▼ 2위 미체결 주문조회 87.1% │\n"
"└──────────────────┴────────────────────────────────────────────┘"
)
heading2(doc, "주요 설정")
make_table(doc,
["설정", "값"],
[
["Top-N", "5개"],
["최소 유사도", "0.3"],
["HyDE", "활성 (use_hyde=True)"],
["리랭킹", "비활성 (use_reranker=False, 구어체 쿼리 정확도 우선)"],
["빠른 검색", "12개 (2행 × 6개)"],
],
col_widths_cm=[4.5, 12]
)
# ── 12. Quick Start ───────────────────────────────────────────────────────
heading1(doc, "12. Quick Start")
heading2(doc, "최초 환경 세팅")
code_block(doc,
"python -m venv .venv\n"
".venv\\Scripts\\pip install -r requirements.txt\n\n"
"# .env 파일 설정\n"
"AZURE_KEY=...\n"
"AZURE_GPT41_MINI_ENDPOINT=...\n"
"AZURE_GPT41_MINI_API_VERSION=2025-01-01-preview\n"
"COHERE_API_KEY= # 선택: Cohere Rerank 4 활성화"
)
heading2(doc, "데이터 파이프라인 (최초 1회)")
code_block(doc,
"# 메뉴 설명 생성\n"
".venv\\Scripts\\python.exe scripts/01_generate_descriptions.py\n\n"
"# 벡터 DB 구축 (~12분)\n"
".venv\\Scripts\\python.exe scripts/02_build_vectordb.py --reset\n\n"
"# BM25 인덱스 구축\n"
".venv\\Scripts\\python.exe scripts/06_rebuild_bm25.py"
)
heading2(doc, "고도화 파이프라인 (선택 실행)")
code_block(doc,
"# 현업 키워드 반영 + 이미지 기반 재생성\n"
".venv\\Scripts\\python.exe scripts/12_update_keywords.py\n"
".venv\\Scripts\\python.exe scripts/13_regen_with_images.py\n\n"
"# 현업 피드백 반영 재생성\n"
".venv\\Scripts\\python.exe scripts/15_import_review_feedback.py\n\n"
"# Contextual Retrieval 적용\n"
".venv\\Scripts\\python.exe scripts/18_contextual_retrieval.py\n\n"
"# 인덱스 재구축\n"
".venv\\Scripts\\python.exe scripts/02_build_vectordb.py --reset\n"
".venv\\Scripts\\python.exe scripts/06_rebuild_bm25.py"
)
heading2(doc, "검색 품질 평가")
code_block(doc,
"# 3-way 비교 평가 (bge-m3 / +HyDE / +HyDE+Rerank)\n"
".venv\\Scripts\\python.exe scripts/17_eval_comparison.py\n"
"# 결과: data/generated/Menusearch_100_answer.xlsx"
)
heading2(doc, "데모 앱 실행")
code_block(doc,
".venv\\Scripts\\streamlit.exe run app/streamlit_app.py\n"
"# 접속: http://localhost:8501\n"
"# 외부 공유: ngrok http 8501"
)
# ── 저장 ─────────────────────────────────────────────────────────────────
doc.save(OUTPUT_PATH)
print(f"저장 완료: {OUTPUT_PATH}")
if __name__ == "__main__":
build()