Spaces:
Running
Running
| # -*- 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() | |