AI_Menu_Search / scripts /20_build_query_index.py
Juhaha
feat: 쿼리 인덱스 ChromaDB→Qdrant 통일 + 도메인/상품 라우팅 보강
fab35a9
Raw
History Blame Contribute Delete
10 kB
"""
쿼리 인덱스 구축 스크립트
=========================
엑셀(MenuSearch_review_all_20260521.xlsx)에서 현업 검증 쿼리를 추출해
ChromaDB "herogun_queries" 컬렉션에 저장.
현업 작업 내용 반영:
- sample_queries(LLM 생성): 취소선 없는 것만 사용 (취소선 = 나쁜 쿼리 표시)
- Sample_queries(자체 생성): 현업이 직접 추가한 쿼리 (전부 사용)
- 미검토 메뉴(취소선/자체생성 없음): LLM 쿼리 전체 사용 (검토 안된 것)
소스 구분:
"human" - 현업 직접 작성 (자체 생성)
"llm_reviewed" - 현업이 취소선 검토 후 유지한 LLM 쿼리
"llm_unreviewed"- 미검토 메뉴의 LLM 쿼리 (노이즈 가능성 있음)
실행:
.venv/Scripts/python.exe scripts/20_build_query_index.py
.venv/Scripts/python.exe scripts/20_build_query_index.py --reset
.venv/Scripts/python.exe scripts/20_build_query_index.py --reviewed-only # 검토된 메뉴만
.venv/Scripts/python.exe scripts/20_build_query_index.py --stats
"""
import sys
import re
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from openpyxl import load_workbook
from openpyxl.cell.rich_text import CellRichText, TextBlock
import pandas as pd
from tqdm import tqdm
from core.embedder import MenuEmbedder
from core.query_vectorstore import QueryVectorStore
EXCEL_PATH = Path(__file__).parent.parent / "data" / "MenuSearch_review_all_20260525_edit.xlsx"
POOL_PATH = Path(__file__).parent.parent / "data" / "query_index_pool.json" # eval 제외 후 남은 풀
BATCH_SIZE = 64
TOP_N_CUTOFF = 200 # 현업 검토 범위: top200까지만 인덱싱 (201+ 미검토 → 노이즈 제외)
LLM_COL = 8 # sample_queries(LLM 생성)
HUMAN_COL = 9 # Sample_queries(자체 생성)
def parse_text_to_queries(text: str) -> list[str]:
"""번호 리스트 또는 줄바꿈으로 구분된 텍스트를 쿼리 목록으로 파싱"""
if not text or text.strip() in ("", "nan"):
return []
text = text.strip()
# 번호 리스트 "1. " "2) " 패턴
parts = re.split(r"\n?\s*\d+[.)]\s+", text)
if len(parts) > 1:
queries = [p.strip() for p in parts if p.strip()]
else:
queries = [p.strip() for p in re.split(r"\s*/\s*|\n", text) if p.strip()]
return [q for q in queries if len(q) >= 4 and not re.match(r"^(예:|N/A|없음|해당없음)", q)]
def extract_rich_text_parts(val) -> tuple[list[str], list[str]]:
"""
rich_text 셀에서 (유지 텍스트 목록, 취소선 텍스트 목록) 반환.
plain text면 (전체 쿼리 목록, []) 반환.
"""
if val is None:
return [], []
if isinstance(val, CellRichText):
kept_raw = []
removed_raw = []
for block in val:
if isinstance(block, TextBlock):
text = str(block.text)
if block.font and block.font.strike:
removed_raw.append(text)
else:
kept_raw.append(text)
elif isinstance(block, str):
kept_raw.append(block)
kept = parse_text_to_queries("".join(kept_raw))
removed = parse_text_to_queries("".join(removed_raw))
return kept, removed
else:
# 일반 텍스트
return parse_text_to_queries(str(val)), []
def load_queries_from_excel(excel_path: Path, reviewed_only: bool = False) -> list[dict]:
"""
엑셀에서 쿼리 추출.
Returns:
list of {
"query": str,
"menu_id": str,
"menu_name": str,
"menu_path": str,
"source": "human" | "llm_reviewed" | "llm_unreviewed",
}
"""
print(f"엑셀 로드: {excel_path}")
wb = load_workbook(excel_path, rich_text=True)
ws = wb["재생성메뉴검토"]
# pandas로 menu_id/name/path 읽기
df = pd.read_excel(excel_path, sheet_name="재생성메뉴검토", engine="openpyxl")
rows = []
counts = {"human": 0, "llm_top200": 0, "skipped_rank": 0, "removed": 0}
for row_idx in range(2, ws.max_row + 1):
rank = ws.cell(row_idx, 1).value
menu_id = ws.cell(row_idx, 2).value
menu_name = ws.cell(row_idx, 3).value
menu_path = ws.cell(row_idx, 4).value
if not menu_id or str(menu_id).strip() in ("", "nan"):
continue
# top200 이하만 사용 (현업 검토 범위)
try:
rank_int = int(rank)
except (TypeError, ValueError):
rank_int = 9999
if rank_int > TOP_N_CUTOFF:
counts["skipped_rank"] += 1
continue
menu_id = str(menu_id).strip()
menu_name = str(menu_name).strip() if menu_name else ""
menu_path = str(menu_path).strip() if menu_path else ""
# ── LLM 쿼리 (취소선 제외) ───────────────────────────────────────
val_llm = ws.cell(row_idx, LLM_COL).value
kept_llm, removed_llm = extract_rich_text_parts(val_llm)
counts["removed"] += len(removed_llm)
# ── 현업 자체 추가 쿼리 ─────────────────────────────────────────
val_human = ws.cell(row_idx, HUMAN_COL).value
human_queries, _ = extract_rich_text_parts(val_human)
if reviewed_only:
is_reviewed = (len(removed_llm) > 0) or (len(human_queries) > 0)
if not is_reviewed:
continue
for q in human_queries:
rows.append({
"query": q,
"menu_id": menu_id,
"menu_name": menu_name,
"menu_path": menu_path,
"source": "human",
})
counts["human"] += 1
for q in kept_llm:
rows.append({
"query": q,
"menu_id": menu_id,
"menu_name": menu_name,
"menu_path": menu_path,
"source": "llm_top200",
})
counts["llm_top200"] += 1
print(f"\n 추출 결과 (top{TOP_N_CUTOFF} 기준):")
print(f" 현업 직접 작성 (human): {counts['human']:>5}개")
print(f" LLM top200 (취소선 제외): {counts['llm_top200']:>5}개")
print(f" 취소선으로 제거됨: {counts['removed']:>5}개")
print(f" 201위 이하 스킵: {counts['skipped_rank']:>5}개 메뉴")
print(f" ──────────────────────────────────")
print(f" 인덱싱 총합: {len(rows):>5}개")
return rows
def build_query_index(rows: list[dict], reset: bool = False):
store = QueryVectorStore()
if reset:
store.reset()
print(f"\n기존 컬렉션 '{store.collection_name}' 초기화")
else:
store.ensure_collection()
embedder = MenuEmbedder()
print(f"\n임베딩 및 저장 ({len(rows):,}개)...")
for start in tqdm(range(0, len(rows), BATCH_SIZE), desc="저장"):
batch = rows[start:start + BATCH_SIZE]
embeddings = embedder.embed_batch([r["query"] for r in batch])
store.upsert_queries(batch, embeddings)
final = store.count()
print(f"\n완료 → 컬렉션 총 {final:,}개 (Qdrant, 영속화 자동)")
return store
def print_stats():
store = QueryVectorStore()
if not store.available:
print(f"컬렉션 '{store.collection_name}' 없음. 먼저 인덱스를 구축하세요.")
return
total = store.count()
print(f"\n[쿼리 인덱스 통계] 컬렉션: {store.collection_name}")
print(f" 총 쿼리 수: {total:,}")
# source 분포 (Qdrant scroll)
sources: dict[str, int] = {}
offset = None
while True:
points, offset = store.client.scroll(
collection_name=store.collection_name,
limit=1000, offset=offset, with_payload=True, with_vectors=False,
)
for p in points:
src = (p.payload or {}).get("source", "unknown")
sources[src] = sources.get(src, 0) + 1
if offset is None:
break
for src, cnt in sorted(sources.items()):
print(f" - {src}: {cnt:,}개")
def main():
parser = argparse.ArgumentParser(description="쿼리 인덱스 구축")
parser.add_argument("--reset", action="store_true", help="기존 컬렉션 삭제 후 재구축")
parser.add_argument("--reviewed-only", action="store_true", help="현업 검토 메뉴만 (미검토 LLM 제외)")
parser.add_argument("--from-pool", action="store_true", help="eval 제외 풀(query_index_pool.json) 사용")
parser.add_argument("--stats", action="store_true", help="통계만 출력")
args = parser.parse_args()
if args.stats:
print_stats()
return
if args.from_pool:
# eval300 제외한 나머지 풀 사용 (데이터 누수 방지)
import json
if not POOL_PATH.exists():
print(f"ERROR: {POOL_PATH} 없음. 먼저 scripts/21_build_eval300.py 실행하세요.")
return
with open(POOL_PATH, "r", encoding="utf-8") as f:
pool = json.load(f)
rows = [{
"query": x["query"],
"menu_id": x["menu_id"],
"menu_name": x["menu_name"],
"menu_path": x["menu_path"],
"source": "human" if x["source"] == "human" else "llm_top200",
} for x in pool]
print(f"eval 제외 풀: {len(rows):,}개")
else:
rows = load_queries_from_excel(EXCEL_PATH, reviewed_only=args.reviewed_only)
if not rows:
print("ERROR: 추출된 쿼리가 없습니다.")
return
build_query_index(rows, reset=args.reset)
print_stats()
if __name__ == "__main__":
main()