peekabook-api / app /rag /query_transform_hyde.py
lael
feat: initial deploy
3960e2b
Raw
History Blame Contribute Delete
11.5 kB
"""
HyDE RAG (Hypothetical Document Embedding) v1
κΈ°μ‘΄ 쿼리 λ³€ν™˜(step-back / rewrite / decompose) λŒ€μ‹ ,
LLM이 μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ 보고 '이상적인 λ„μ„œμ˜ μ†Œκ°œκΈ€(book_intro)'을 직접 μƒμ„±ν•©λ‹ˆλ‹€.
μƒμ„±λœ 가상 μ†Œκ°œκΈ€μ„ μž„λ² λ”©ν•˜μ—¬ 벑터 DBλ₯Ό κ²€μƒ‰ν•˜λ©΄,
μ‹€μ œ book_intro 문체와 의미적으둜 더 가깝기 λ•Œλ¬Έμ— 검색 ν’ˆμ§ˆμ΄ ν–₯상을 κΈ°λŒ€ν•©λ‹ˆλ‹€.
graph.py ν˜Έν™˜:
from app.rag.query_transform_hyde import (
extract_genre_node, query_transform_rag_node, explain_node, rag_llm_node
)
μœ„ ν•œ μ€„λ§Œ λ°”κΎΈλ©΄ κΈ°μ‘΄ graph.py λ…Έλ“œ ꡬ성을 κ·ΈλŒ€λ‘œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
"""
from __future__ import annotations
import json
import os
import pandas as pd
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from qdrant_client.models import Filter, FieldCondition, MatchAny
from app.config import QDRANT_COLLECTION_NAME
from app.db.qdrant import QdrantDB
from app.embedding.embedder import LocalEmbedder
from app.reranking.reranker import LocalReranker
from app.state.state_v3 import GraphState
# explain_node / rag_llm_node λŠ” κΈ°μ‘΄ λͺ¨λ“ˆμ—μ„œ κ·ΈλŒ€λ‘œ κ°€μ Έμ˜΅λ‹ˆλ‹€.
from app.rag.query_transform import explain_node, rag_llm_node # noqa: F401
load_dotenv()
# ── μ„€μ • ──────────────────────────────────────────────────────────────────────
# 가상 μ†Œκ°œκΈ€μ„ μ—¬λŸ¬ κ°λ„μ—μ„œ 생성해 검색 λ‹€μ–‘μ„± 확보
# κ°λ„λ³„λ‘œ 별도 μž„λ² λ”© β†’ Qdrant 검색 β†’ RRF 병합
SEARCH_LIMIT = 10 # 각도별 Qdrant 검색 κ²°κ³Ό 수
RETRIEVE_TOP_N = 10 # λ¦¬λž­ν‚Ή ν›„ μ΅œμ’… λ°˜ν™˜ 수
# ── μΉ΄ν…Œκ³ λ¦¬ 트리 (v5와 동일) ─────────────────────────────────────────────────
_csv_path = os.path.join(
os.path.dirname(__file__),
"../../../research/src/rag/query_transformations/aladin_category.csv",
)
_df = pd.read_csv(_csv_path)
CATEGORY_TREE = (
_df.groupby("category_large")["category_medium"]
.apply(lambda x: sorted(x.unique().tolist()))
.to_dict()
)
CATEGORY_LARGE_LIST = sorted(CATEGORY_TREE.keys())
# ── 곡유 μΈμŠ€ν„΄μŠ€ ─────────────────────────────────────────────────────────────
embedder = LocalEmbedder("BAAI/bge-m3")
db = QdrantDB(vector_size=1024)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
reranker = LocalReranker("BAAI/bge-reranker-v2-m3")
# ── 1. μž₯λ₯΄ μΆ”μΆœ λ…Έλ“œ (v5와 동일) ─────────────────────────────────────────────
top_genre_prompt = ChatPromptTemplate.from_template("""
μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ 보고 μ•„λž˜ λŒ€λΆ„λ₯˜ λͺ©λ‘μ—μ„œ μ ν•©ν•œ 것을 μ΅œλŒ€ 2개 μ„ νƒν•˜μ„Έμš”.
λͺ©λ‘μ— μ—†λŠ” 값은 μ ˆλŒ€ λ°˜ν™˜ν•˜μ§€ λ§ˆμ„Έμš”.
λŒ€λΆ„λ₯˜ λͺ©λ‘: {large_list}
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ: {summary}
JSON으둜만 λ°˜ν™˜: {{"categories": ["μ†Œμ„€/μ‹œ/희곑"]}}
""")
medium_genre_prompt = ChatPromptTemplate.from_template("""
μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ 보고 μ•„λž˜ 쀑뢄λ₯˜ λͺ©λ‘μ—μ„œ μ ν•©ν•œ 것을 μ΅œλŒ€ 3개 μ„ νƒν•˜μ„Έμš”.
λͺ©λ‘μ— μ—†λŠ” 값은 μ ˆλŒ€ λ°˜ν™˜ν•˜μ§€ λ§ˆμ„Έμš”.
쀑뢄λ₯˜ λͺ©λ‘: {medium_list}
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ: {summary}
JSON으둜만 λ°˜ν™˜: {{"categories": ["ν•œκ΅­μ†Œμ„€"]}}
""")
def extract_genre_node(state: GraphState) -> dict:
summary = state.get("summary", "")
top_resp = (top_genre_prompt | llm).invoke({
"large_list": CATEGORY_LARGE_LIST,
"summary": summary,
})
try:
top_cats = json.loads(top_resp.content)["categories"]
except (json.JSONDecodeError, KeyError):
top_cats = []
if not top_cats:
print("[Genre] λŒ€λΆ„λ₯˜ μΆ”μΆœ μ‹€νŒ¨ β†’ ν•„ν„° μ—†μŒ")
return {"genre_filter": [], "genre_level": "none"}
medium_candidates = []
for cat in top_cats:
medium_candidates.extend(CATEGORY_TREE.get(cat, []))
if not medium_candidates:
print(f"[Genre] λŒ€λΆ„λ₯˜ fallback: {top_cats}")
return {"genre_filter": top_cats, "genre_level": "large"}
medium_resp = (medium_genre_prompt | llm).invoke({
"medium_list": medium_candidates,
"summary": summary,
})
try:
medium_cats = json.loads(medium_resp.content)["categories"]
except (json.JSONDecodeError, KeyError):
medium_cats = []
if not medium_cats:
print(f"[Genre] 쀑뢄λ₯˜ μΆ”μΆœ μ‹€νŒ¨ β†’ λŒ€λΆ„λ₯˜ fallback: {top_cats}")
return {"genre_filter": top_cats, "genre_level": "large"}
print(f"[Genre] λŒ€λΆ„λ₯˜: {top_cats} β†’ 쀑뢄λ₯˜: {medium_cats}")
return {"genre_filter": medium_cats, "genre_level": "medium"}
# ── 2. HyDE 가상 μ†Œκ°œκΈ€ 생성 ν”„λ‘¬ν”„νŠΈ ─────────────────────────────────────────
#
# 각 ν”„λ‘¬ν”„νŠΈλŠ” μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ˜ μ„œλ‘œ λ‹€λ₯Έ 츑면을 κ°•μ‘°ν•˜μ—¬
# 의미적으둜 λ‹€μ–‘ν•œ μž„λ² λ”© 벑터λ₯Ό λ§Œλ“€μ–΄λƒ…λ‹ˆλ‹€.
# (ν•˜λ‚˜μ˜ ν”„λ‘œν•„ β†’ μ—¬λŸ¬ 검색 벑터 β†’ RRF 병합)
hyde_content_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ νλ ˆμ΄ν„°μž…λ‹ˆλ‹€.
μ•„λž˜ μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ 읽고, 이 μ‚¬μš©μžμ—κ²Œ μ£Όμ œμ™€ λ‚΄μš© λ©΄μ—μ„œ μ™„λ²½ν•˜κ²Œ λ§žλŠ”
λ„μ„œμ˜ μ†Œκ°œκΈ€(book_intro)이 μ–΄λ–»κ²Œ μ“°μ—¬μžˆμ„μ§€ μž‘μ„±ν•˜μ„Έμš”.
[κ·œμΉ™]
- μ‹€μ œ μΆœνŒμ‚¬ μ„œν‰μ΄λ‚˜ λ„μ„œ μ†Œκ°œμ— λ‚˜μ˜¬ λ²•ν•œ 문체와 μ–΄νœ˜λ₯Ό μ‚¬μš©ν•˜μ„Έμš”.
- μ €μžλͺ…, μ±… 제λͺ©μ€ λ§Œλ“€μ§€ λ§ˆμ„Έμš”. λ‚΄μš©κ³Ό 주제만 λ¬˜μ‚¬ν•˜μ„Έμš”.
- μ‚¬μš©μžμ˜ λ…μ„œ λͺ©μ κ³Ό μ„ ν˜Έ μž₯λ₯΄μ— μ§‘μ€‘ν•˜μ„Έμš”.
- 200자 λ‚΄μ™Έλ‘œ μž‘μ„±ν•˜μ„Έμš”.
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ: {summary}
가상 λ„μ„œ μ†Œκ°œ (주제/λ‚΄μš© μΈ‘λ©΄):
""")
hyde_style_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ νλ ˆμ΄ν„°μž…λ‹ˆλ‹€.
μ•„λž˜ μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ 읽고, 이 μ‚¬μš©μžμ˜ λ…μ„œ μŠ€νƒ€μΌκ³Ό λ‚œμ΄λ„ μ„ ν˜Έμ— λ”± λ§žλŠ”
λ„μ„œμ˜ μ†Œκ°œκΈ€(book_intro)이 μ–΄λ–»κ²Œ μ“°μ—¬μžˆμ„μ§€ μž‘μ„±ν•˜μ„Έμš”.
[κ·œμΉ™]
- μ‹€μ œ μΆœνŒμ‚¬ μ„œν‰μ΄λ‚˜ λ„μ„œ μ†Œκ°œμ— λ‚˜μ˜¬ λ²•ν•œ 문체와 μ–΄νœ˜λ₯Ό μ‚¬μš©ν•˜μ„Έμš”.
- μ €μžλͺ…, μ±… 제λͺ©μ€ λ§Œλ“€μ§€ λ§ˆμ„Έμš”. μ„œμˆ  방식, ꡬ성, λ‚œμ΄λ„λ§Œ λ¬˜μ‚¬ν•˜μ„Έμš”.
- μ‚¬μš©μžμ˜ λ…μ„œ μŠ€νƒ€μΌ(속도, 깊이, ν˜•μ‹)κ³Ό λ‚œμ΄λ„ μ„ ν˜Έμ— μ§‘μ€‘ν•˜μ„Έμš”.
- 200자 λ‚΄μ™Έλ‘œ μž‘μ„±ν•˜μ„Έμš”.
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ: {summary}
가상 λ„μ„œ μ†Œκ°œ (λ…μ„œ μŠ€νƒ€μΌ/λ‚œμ΄λ„ μΈ‘λ©΄):
""")
hyde_context_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ νλ ˆμ΄ν„°μž…λ‹ˆλ‹€.
μ•„λž˜ μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ 읽고, 이 μ‚¬μš©μžμ˜ ν˜„μž¬ 상황과 감정에 곡λͺ…ν•˜λŠ”
λ„μ„œμ˜ μ†Œκ°œκΈ€(book_intro)이 μ–΄λ–»κ²Œ μ“°μ—¬μžˆμ„μ§€ μž‘μ„±ν•˜μ„Έμš”.
[κ·œμΉ™]
- μ‹€μ œ μΆœνŒμ‚¬ μ„œν‰μ΄λ‚˜ λ„μ„œ μ†Œκ°œμ— λ‚˜μ˜¬ λ²•ν•œ 문체와 μ–΄νœ˜λ₯Ό μ‚¬μš©ν•˜μ„Έμš”.
- μ €μžλͺ…, μ±… 제λͺ©μ€ λ§Œλ“€μ§€ λ§ˆμ„Έμš”. λ…μžμ—κ²Œ μ£ΌλŠ” 감정적/μ‹€μš©μ  κ°€μΉ˜λ§Œ λ¬˜μ‚¬ν•˜μ„Έμš”.
- μ‚¬μš©μžμ˜ ν˜„μž¬ 상황(감정 μƒνƒœ, μ‚Άμ˜ λ§₯락)κ³Ό λ…μ„œμ—μ„œ μ–»κ³  싢은 것에 μ§‘μ€‘ν•˜μ„Έμš”.
- 200자 λ‚΄μ™Έλ‘œ μž‘μ„±ν•˜μ„Έμš”.
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ: {summary}
가상 λ„μ„œ μ†Œκ°œ (상황/감성 μΈ‘λ©΄):
""")
_HYDE_PROMPTS = [
("content", hyde_content_prompt),
# ("style", hyde_style_prompt),
# ("context", hyde_context_prompt),
]
def generate_hypothetical_docs(summary: str) -> list[tuple[str, str]]:
"""μ‚¬μš©μž ν”„λ‘œνŒŒμΌ β†’ [(각도 이름, 가상 book_intro), ...] 생성."""
results = []
for angle, prompt in _HYDE_PROMPTS:
hypo = (prompt | llm).invoke({"summary": summary}).content.strip()
print(f" [HyDE/{angle}] {hypo[:80]}...")
results.append((angle, hypo))
return results
# ── 3. RRF ────────────────────────────────────────────────────────────────────
def reciprocal_rank_fusion(results_list: list, k: int = 60) -> list:
scores, payloads = {}, {}
for results in results_list:
for rank, r in enumerate(results):
isbn = r.payload.get("isbn", "")
if isbn:
scores[isbn] = scores.get(isbn, 0) + 1 / (k + rank + 1)
payloads[isbn] = r.payload
return [payloads[isbn] for isbn, _ in sorted(scores.items(), key=lambda x: x[1], reverse=True)]
# ── 4. HyDE RAG λ…Έλ“œ ──────────────────────────────────────────────────────────
def query_transform_rag_node(state: GraphState) -> dict:
summary = state.get("summary", "")
reflection = state.get("reflection", "")
categories = state.get("genre_filter", [])
genre_level = state.get("genre_level", "none") # state_v3 μ΄μƒμ—μ„œ μ‚¬μš©
user_profile_query = " ".join(filter(None, [summary, reflection]))
print("\n[HyDE] 가상 λ„μ„œ μ†Œκ°œκΈ€ 생성 쀑...")
hypo_docs = generate_hypothetical_docs(user_profile_query)
field_map = {"large": "category_large", "medium": "category_medium"}
query_filter = None
if categories and genre_level in field_map:
query_filter = Filter(
must=[FieldCondition(key=field_map[genre_level], match=MatchAny(any=categories))]
)
elif categories:
# state_v3 λ―Έμ‚¬μš© ν™˜κ²½ (κΈ°λ³Έ state.py) β†’ cate_depth1 ν•„ν„° fallback
query_filter = Filter(
must=[FieldCondition(key="cate_depth1", match=MatchAny(any=categories))]
)
all_results = []
for angle, hypo_text in hypo_docs:
query_vector = embedder.embed(hypo_text)
if query_filter:
results = db.search_with_filter(
QDRANT_COLLECTION_NAME, query_vector,
query_filter=query_filter, limit=SEARCH_LIMIT, threshold=0.5,
)
else:
results = db.search(
QDRANT_COLLECTION_NAME, query_vector,
limit=SEARCH_LIMIT, threshold=0.5,
)
print(f" [HyDE/{angle}] 검색 κ²°κ³Ό: {len(results)}건")
all_results.append(results)
merged_payloads = reciprocal_rank_fusion(all_results)
reranked_payloads = reranker.rerank(query=user_profile_query, books=merged_payloads)
retrieved_books = [
{
"isbn": p.get("isbn"),
"title": p.get("title"),
"author": p.get("author"),
"book_intro": p.get("book_intro"),
"cate_depth1": p.get("cate_depth1"),
# v5 ν•„λ“œ (있으면 포함)
"category_large": p.get("category_large", ""),
"category_medium": p.get("category_medium", ""),
"cover_url": p.get("cover_url", ""),
}
for p in reranked_payloads[:RETRIEVE_TOP_N]
]
hypothetical_doc = hypo_docs[0][1] if hypo_docs else ""
print(f"\n[HyDE] μ΅œμ’… 검색 κ²°κ³Ό: {len(retrieved_books)}ꢌ")
return {"retrieved_books": retrieved_books, "hypothetical_doc": hypothetical_doc}