k-curator / src /rag.py
wangihong's picture
chore: GitHub-readiness 재검토 정리
433f312
"""
사이 (SAI): RAG 챗 파이프라인
- 검색: Chroma + e5-small (build_index.py와 동일 모델)
- 생성: OpenAI gpt-4o-mini
- 톤 모드: adult(성인) / kid(어린이) / foreign(외국인용 영어)
사용법:
python src/rag.py "조선시대 잔치 그림을 보여줘"
python src/rag.py "기영회도가 뭐야?" --mode kid
python src/rag.py "Tell me about the moon jar" --mode foreign
python src/rag.py "..." --k 5 --no-stream
"""
import argparse
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from build_index import (
CHROMA_DIR,
COLLECTION,
EMBEDDING_MODEL,
QUERY_PREFIX,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
LLM_MODEL = "gpt-4o-mini"
DEFAULT_K = 5
SYSTEM_PROMPTS = {
"adult": (
"당신은 '사이(SAI)' 라는 이름의 박물관 도슨트로, 작품과 관람객 사이를 잇습니다. "
"국립중앙박물관 큐레이터의 해설만을 자료로 삼아 답변합니다. "
"성인 일반 관람객 대상으로, 예의 있고 차분한 한국어 존댓말로 설명합니다. "
"원전 큐레이터 해설을 충실히 인용하되, 자연스럽게 풀어쓰세요. "
"답변 마지막에 '— 참고: <작품명> (큐레이터: 이름)' 형식으로 출처를 1~3개 표기하세요. "
"제공된 자료에 없는 내용은 절대 지어내지 말고, 모르면 모른다고 답하세요."
),
"kid": (
"당신은 박물관에 처음 온 초등학생에게 작품을 설명해주는 친절한 도슨트 '사이'입니다. "
"쉬운 단어, 짧은 문장, '~예요/~해요' 말투를 쓰세요. "
"어려운 한자어는 풀어쓰고, 비유를 들어주세요(예: '아주 큰 항아리예요. 보름달처럼 둥글어요'). "
"답변 마지막에 '— 알려준 사람: <작품명>의 큐레이터' 형식으로 출처를 표기하세요. "
"자료에 없는 내용은 지어내지 말고, '그 부분은 잘 모르겠어요'라고 답하세요."
),
"foreign": (
"You are 'SAI' (사이, meaning 'in-between'), a museum docent that bridges visitors and Korean art. "
"Answer in clear, natural English for an international visitor with no Korean background, "
"grounded only in National Museum of Korea curators' commentary provided as context. "
"Briefly transliterate or translate Korean terms when first introduced "
"(e.g., 'Giyeonghoedo (耆英會圖, painting of an elders' gathering)'). "
"End with '— Sources: <work title> (curator: name)' for 1-3 cited works. "
"Never invent facts beyond the provided sources; say so if information is missing."
),
}
def search_full(query: str, k: int) -> list[dict]:
"""search.py와 비슷하지만 청크 전문(全文)을 함께 반환."""
from sentence_transformers import SentenceTransformer
import chromadb
model = SentenceTransformer(EMBEDDING_MODEL)
emb = model.encode([QUERY_PREFIX + query], normalize_embeddings=True)[0]
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
coll = client.get_collection(COLLECTION)
res = coll.query(
query_embeddings=[emb.tolist()],
n_results=k,
include=["documents", "metadatas", "distances"],
)
return [
{
"score": 1.0 - dist,
"text": doc,
"metadata": meta,
}
for doc, meta, dist in zip(
res["documents"][0], res["metadatas"][0], res["distances"][0]
)
]
def format_context(hits: list[dict]) -> str:
"""검색 결과를 LLM 컨텍스트로 직렬화."""
blocks = []
for i, h in enumerate(hits, 1):
meta = h["metadata"]
title = meta.get("title", "")
if meta.get("subtitle"):
title = f"{title} - {meta['subtitle']}"
curator = meta.get("curator") or "?"
section = meta.get("section") or "(intro)"
period = meta.get("period") or ""
header = f"[자료 {i}] {title} / 섹션: {section} / 큐레이터: {curator}"
if period:
header += f" / 시대: {period}"
blocks.append(f"{header}\n{h['text']}")
return "\n\n---\n\n".join(blocks)
def build_user_prompt(query: str, hits: list[dict]) -> str:
context = format_context(hits)
return (
"다음은 국립중앙박물관 큐레이터들이 작성한 작품 해설 자료입니다.\n"
"이 자료만 참고해서 사용자의 질문에 답하세요.\n\n"
f"=== 자료 시작 ===\n{context}\n=== 자료 끝 ===\n\n"
f"[사용자 질문]\n{query}"
)
def chat(query: str, mode: str, k: int, stream: bool) -> str:
load_dotenv(PROJECT_ROOT / ".env")
if not os.getenv("OPENAI_API_KEY"):
print(
"[rag] OPENAI_API_KEY가 .env에 없습니다. "
"OPENAI_API_KEY=sk-... 한 줄을 추가해주세요.",
file=sys.stderr,
)
sys.exit(2)
from openai import OpenAI
hits = search_full(query, k)
print(f"[rag] retrieved {len(hits)}건 "
f"(top score={hits[0]['score']:.3f}, "
f"low={hits[-1]['score']:.3f})")
for i, h in enumerate(hits, 1):
meta = h["metadata"]
print(f" {i}. {meta.get('title','')[:35]:35s} "
f"sec={meta.get('section','')[:20]:20s} "
f"score={h['score']:.3f}")
print()
user_prompt = build_user_prompt(query, hits)
system_prompt = SYSTEM_PROMPTS[mode]
client = OpenAI()
if stream:
full = []
s = client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
stream=True,
)
for ev in s:
delta = ev.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
full.append(delta)
print()
return "".join(full)
else:
resp = client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
)
text = resp.choices[0].message.content
print(text)
return text
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("query", help="질문 (한국어 또는 영어)")
ap.add_argument(
"--mode",
choices=list(SYSTEM_PROMPTS.keys()),
default="adult",
help="톤 모드 (adult|kid|foreign)",
)
ap.add_argument("--k", type=int, default=DEFAULT_K, help="검색 top-k")
ap.add_argument(
"--no-stream", action="store_true", help="스트리밍 출력 끄기"
)
args = ap.parse_args()
chat(args.query, args.mode, args.k, stream=not args.no_stream)
return 0
if __name__ == "__main__":
sys.exit(main())