peekabook-api / app /rag /query_transform_v3.py
lael
feat: initial deploy
3960e2b
Raw
History Blame Contribute Delete
17.9 kB
"""
# Simple RAG with Filtering + Query Transformations
## 적용 기법
- **Metadata Filtering** : category_large / category_medium 기반 2단계 μž₯λ₯΄ ν•„ν„°
- **Query Transformations** : Sub-query Decomposition β†’ μ„œλΈŒμΏΌλ¦¬λ³„ 검색결합
"""
from __future__ import annotations
import json
import os
import pandas as pd
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
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, Phase, UserProfile
load_dotenv()
# ## 0. μ„€μΉ˜ 및 μ΄ˆκΈ°ν™”
# μΉ΄ν…Œκ³ λ¦¬ 트리 λ‘œλ“œ
_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")
reranker = LocalReranker("BAAI/bge-reranker-v2-m3")
# ## 1. μž₯λ₯΄ μΆ”μΆœ λ…Έλ“œ β€” 2단계 계측 μΆ”μΆœ (λŒ€λΆ„λ₯˜ β†’ 쀑뢄λ₯˜)
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", "")
# ── 1단계: λŒ€λΆ„λ₯˜ (30개) ──────────────────────────────────
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"}
# ── 2단계: 쀑뢄λ₯˜ (ν•΄λ‹Ή λŒ€λΆ„λ₯˜ ν•˜μœ„λ§Œ) ───────────────────
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. Step-back Prompting
# **μž…λ ₯**: 원본 μ‚¬μš©μž ν”„λ‘œνŒŒμΌ
# **좜λ ₯**: μΆ”μƒν™”λœ λ…μ„œ λͺ©μ 
# **λͺ©μ **: ꡬ체적인 ν”„λ‘œνŒŒμΌμ—μ„œ ν•œ 단계 λ¬ΌλŸ¬λ‚˜ λ…μž νƒ€κ²ŸΒ·λ…μ„œ λͺ©μ μ„ 좔상화 β†’ μ˜λ„ νŒŒμ•… 문제 ν•΄κ²°
step_back_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ μΆ”μ²œ μ‹œμŠ€ν…œμ˜ 검색 쿼리 μ „λ¬Έκ°€μž…λ‹ˆλ‹€.
μ•„λž˜ μ‚¬μš©μžμ˜ 원본 μ§ˆλ¬Έμ—μ„œ ν•œ 단계 λ¬ΌλŸ¬λ‚˜,
이 μ‚¬μš©μžκ°€ 근본적으둜 μ–΄λ–€ μ’…λ₯˜μ˜ λ…μ„œ κ²½ν—˜μ„ μ›ν•˜λŠ”μ§€λ₯Ό ν¬μ°©ν•˜λŠ” μƒμœ„ μ§ˆλ¬Έμ„ μƒμ„±ν•˜μ„Έμš”.
[κ·œμΉ™]
- μ‚¬μš©μžκ°€ μ–ΈκΈ‰ν•œ ꡬ체적인 μž₯λ₯΄λͺ…, μ±… 제λͺ©, 쑰건을 κ·ΈλŒ€λ‘œ λ°˜λ³΅ν•˜μ§€ λ§ˆμ„Έμš”.
- λŒ€μ‹ , κ·Έ 쑰건듀이 κ°€λ¦¬ν‚€λŠ” 더 넓은 λ…μ„œ μš•κ΅¬λ‚˜ λ„μ„œ μœ ν˜•μ˜ 본질적 νŠΉμ„±μ„ ν‘œν˜„ν•˜μ„Έμš”.
- λ„μ„œ μ†Œκ°œκΈ€(book_intro)에 μ‹€μ œλ‘œ λ“±μž₯ν•  λ²•ν•œ μ„œμˆ  ν‘œν˜„μ„ μ‚¬μš©ν•˜μ„Έμš”.
- 3λ¬Έμž₯ μ΄λ‚΄λ‘œ μž‘μ„±ν•˜μ„Έμš”.
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ: {summary}
좜λ ₯:
""")
def step_back_query(summary: str, llm) -> str:
"""
μ‚¬μš©μž ν”„λ‘œνŒŒμΌ β†’ μΆ”μƒν™”λœ λ…μ„œ λͺ©μ 
Args:
summary : 원본 μ‚¬μš©μž ν”„λ‘œνŒŒμΌ
llm : LLM μΈμŠ€ν„΄μŠ€
Returns:
str: μΆ”μƒν™”λœ λ…μ„œ λͺ©μ 
"""
chain = step_back_prompt | llm
return chain.invoke({"summary": summary}).content.strip()
# ## 3. Query Rewriting
# **μž…λ ₯**: summary κ²°κ³Ό (μΆ”μƒν™”λœ λ…μ„œ λͺ©μ )
# **좜λ ₯**: 검색에 μ΅œμ ν™”λœ ꡬ체적인 쿼리
# **λͺ©μ **: μΆ”μƒν™”λœ μ˜λ„λ₯Ό λ„μ„œ 검색에 더 μ ν•©ν•œ ν‘œν˜„μœΌλ‘œ ꡬ체화
rewrite_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ μΆ”μ²œ μ‹œμŠ€ν…œμ˜ 검색 쿼리 μ „λ¬Έκ°€μž…λ‹ˆλ‹€.
μ•„λž˜ μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ„ λ°”νƒ•μœΌλ‘œ, 벑터 검색에 μ ν•©ν•œ λ„μ„œ 검색 쿼리λ₯Ό μž‘μ„±ν•˜μ„Έμš”.
[κ·œμΉ™]
- λ„μ„œ μ†Œκ°œκΈ€(book_intro)μ΄λ‚˜ μΆœνŒμ‚¬ μ„œν‰μ— μ‹€μ œλ‘œ λ“±μž₯ν•  λ²•ν•œ μ–΄νœ˜μ™€ ν‘œν˜„μ„ μ‚¬μš©ν•˜μ„Έμš”.
- μž₯λ₯΄, 주제 μ˜μ—­, μ„œμˆ  방식, λŒ€μƒ λ…μžμΈ΅ λ“± λ„μ„œ 메타데이터와 맀칭될 수 μžˆλŠ” 쑰건을 ν¬ν•¨ν•˜μ„Έμš”.
- μ‚¬μš©μžκ°€ μ–ΈκΈ‰ν•œ κΈ°μ‘΄ λ„μ„œκ°€ μžˆλ‹€λ©΄, κ·Έ λ„μ„œμ˜ 핡심 νŠΉμ„±(μ„œμˆ  방식, 주제 λ²”μœ„)을 λ°˜μ˜ν•˜μ„Έμš”.
- 3λ¬Έμž₯ μ΄λ‚΄λ‘œ μž‘μ„±ν•˜μ„Έμš”.
λ…μ„œ λͺ©μ : {summary}
μž¬μž‘μ„±λœ 검색 쿼리 (두 λ¬Έμž₯ μ΄λ‚΄λ‘œ):
""")
def rewrite_query(summary: str, llm) -> str:
"""
μΆ”μƒν™”λœ λ…μ„œ λͺ©μ  β†’ 검색에 μ΅œμ ν™”λœ ꡬ체적 쿼리
Args:
summary : step_back_query()의 결과
llm : LLM μΈμŠ€ν„΄μŠ€
Returns:
str: μž¬μž‘μ„±λœ 검색 쿼리
"""
chain = rewrite_prompt | llm
return chain.invoke({"summary": summary}).content.strip()
# ## 4. Sub-query Decomposition
# **μž…λ ₯**: rewritten κ²°κ³Ό (κ΅¬μ²΄ν™”λœ 쿼리)
# **좜λ ₯**: 쑰건 λ‹¨μœ„λ‘œ λΆ„ν•΄λœ μ„œλΈŒμΏΌλ¦¬ 리슀트
# **λͺ©μ **: 닀쀑 쑰건을 λΆ„ν•΄ν•˜μ—¬ 각각 검색 β†’ RRF κ²°ν•©μœΌλ‘œ 쑰건 만쑱λ₯  ν–₯상
decompose_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ μΆ”μ²œ μ‹œμŠ€ν…œμ˜ 검색 쿼리 μ „λ¬Έκ°€μž…λ‹ˆλ‹€.
μ•„λž˜ 검색 쿼리λ₯Ό 2~4개의 μ„œλΈŒμΏΌλ¦¬λ‘œ λΆ„ν•΄ν•˜μ„Έμš”.
[핡심 원칙]
- 각 μ„œλΈŒμΏΌλ¦¬λŠ” μ„œλ‘œ λ‹€λ₯Έ 독립적 검색 츑면을 닀뀄야 ν•©λ‹ˆλ‹€.
λ™μΌν•œ 의미λ₯Ό λ‹€λ₯Έ ν‘œν˜„μœΌλ‘œ λ°˜λ³΅ν•˜λŠ” 것은 μ„œλΈŒμΏΌλ¦¬κ°€ μ•„λ‹™λ‹ˆλ‹€.
- λΆ„ν•΄ κΈ°μ€€ μ˜ˆμ‹œ: 주제/μž₯λ₯΄ μΈ‘λ©΄, μ„œμˆ  방식/ꡬ쑰 μΈ‘λ©΄, μœ μ‚¬ λ„μ„œ νŠΉμ„± μΈ‘λ©΄, λŒ€μƒ λ…μž 상황 μΈ‘λ©΄
- 각 μ„œλΈŒμΏΌλ¦¬λŠ” λ…λ¦½μ μœΌλ‘œ κ²€μƒ‰ν–ˆμ„ λ•Œ μ„œλ‘œ λ‹€λ₯Έ 후보 λ„μ„œκ΅°μ„ λ°˜ν™˜ν•  수 μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€.
[μž‘μ„± κ·œμΉ™]
- λ„μ„œ μ†Œκ°œκΈ€(book_intro)에 λ“±μž₯ν•  λ²•ν•œ μ–΄νœ˜λ₯Ό μ‚¬μš©ν•˜μ„Έμš”.
- "이 μ€‘μ—μ„œ", "κ·Έ μ€‘μ—μ„œ" 같은 μ°Έμ‘° ν‘œν˜„μ€ μ‚¬μš©ν•˜μ§€ λ§ˆμ„Έμš”.
- "μΆ”μ²œν•΄μ£Όμ„Έμš”", "μ•Œκ³  μ‹ΆμŠ΅λ‹ˆλ‹€" 같은 μš”μ²­ν˜• 쒅결은 μ‚¬μš©ν•˜μ§€ λ§ˆμ„Έμš”.
- 리뷰, 평점 λ“± λ„μ„œ μ†Œκ°œκΈ€ μ™Έμ˜ 정보λ₯Ό μš”μ²­ν•˜μ§€ λ§ˆμ„Έμš”.
검색 쿼리: {rewritten}
좜λ ₯ ν˜•μ‹ (λ²ˆν˜Έμ™€ ν…μŠ€νŠΈλ§Œ, λ‹€λ₯Έ ν…μŠ€νŠΈ 없이):
1. [μ„œλΈŒμΏΌλ¦¬ 1]
2. [μ„œλΈŒμΏΌλ¦¬ 2]
3. [μ„œλΈŒμΏΌλ¦¬ 3]
""")
def decompose_query(rewritten: str, llm) -> list:
"""
κ΅¬μ²΄ν™”λœ 쿼리 β†’ μ„œλΈŒμΏΌλ¦¬ 리슀트
Args:
rewritten : rewrite_query()의 결과
llm : LLM μΈμŠ€ν„΄μŠ€
Returns:
List[str]: μ„œλΈŒμΏΌλ¦¬ 리슀트
"""
chain = decompose_prompt | llm
response = chain.invoke({"rewritten": rewritten}).content
sub_queries = [
q.strip().lstrip("1234567890. ")
for q in response.split("\n")
if q.strip() and q.strip()[0].isdigit()
]
return sub_queries
# ## 5. Chained Pipeline
# μ„Έ 단계λ₯Ό μˆœμ„œλŒ€λ‘œ μ΄μ–΄μ„œ μ‹€ν–‰ν•˜λŠ” 메인 **ν•¨μˆ˜**
def get_chained_queries(user_profile_query: str, llm,
use_step_back: bool = True,
use_rewrite: bool = True,
use_decompose: bool = True) -> dict:
"""
μ„Έ κ°€μ§€ λ³€ν™˜μ„ μˆœμ„œλŒ€λ‘œ μ΄μ–΄μ„œ μ‹€ν–‰
흐름: summary β†’ step_back
흐름: summary β†’ rewritten β†’ sub_queries
Args:
summary : 원본 μ‚¬μš©μž ν”„λ‘œνŒŒμΌ
llm : LLM μΈμŠ€ν„΄μŠ€
use_step_back : Step-back Prompting μ‚¬μš© μ—¬λΆ€ (κΈ°λ³Έ True)
use_rewrite : Query Rewriting μ‚¬μš© μ—¬λΆ€ (κΈ°λ³Έ True)
use_decompose : Sub-query Decomposition μ‚¬μš© μ—¬λΆ€ (κΈ°λ³Έ True)
Returns:
{
'step_back' : str, # Step-back κ²°κ³Ό
'rewritten' : str, # Rewriting κ²°κ³Ό
'sub_queries': List[str], # Decomposition κ²°κ³Ό
'all' : List[str], # RRF에 λ„˜κΈΈ 전체 쿼리 λͺ©λ‘
}
"""
# Step-back β†’ μ˜λ„ 좔상화
step_back = step_back_query(user_profile_query, llm) if use_step_back else user_profile_query
print(f" [Step-back] : {step_back}")
# Rewriting β†’ μΆ”μƒν™”λœ μ˜λ„λ₯Ό ꡬ체적 κ²€μƒ‰μ–΄λ‘œ
rewritten = rewrite_query(user_profile_query, llm) if use_rewrite else user_profile_query
print(f" [Rewritten] : {rewritten}")
# Decomposition β†’ ꡬ체적 쿼리λ₯Ό 쑰건 λ‹¨μœ„λ‘œ λΆ„ν•΄
sub_queries = decompose_query(rewritten, llm) if use_decompose else []
print(f" [Sub-queries]: {sub_queries}")
# 전체 쿼리 λͺ©λ‘ (RRF μž…λ ₯용)
all_queries = [step_back, rewritten] + sub_queries
return {
"step_back": step_back,
"rewritten": rewritten,
"sub_queries": sub_queries,
"all": all_queries,
}
# ## 8. RRF ν•¨μˆ˜ μ •μ˜
def reciprocal_rank_fusion(results_list: list, k: int = 60) -> list:
"""μ—¬λŸ¬ 검색 κ²°κ³Όλ₯Ό RRF둜 κ²°ν•©. Returns: payload λ”•μ…”λ„ˆλ¦¬ 리슀트 (점수 λ‚΄λ¦Όμ°¨μˆœ)"""
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)]
# ## 9. Query Transform 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")
print("---------------------")
print("summary:", summary)
print("reflection:", reflection)
print("---------------------")
# summary + reflection κ²°ν•©
user_profile_query = " ".join(filter(None, [summary, reflection]))
# ── 1. Query Transformations ────────────────────────────
print("\n[Query Transformations]")
queries = get_chained_queries(user_profile_query, llm)
all_queries = queries["all"]
# ── 2. μž₯λ₯΄ ν•„ν„° ꡬ성 ────────────────────────────────────
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))]
)
# ── 3. 쿼리별 검색 ──────────────────────────────────────
all_results = []
for query in all_queries:
query_vector = embedder.embed(query)
if query_filter:
results = db.search_with_filter(
QDRANT_COLLECTION_NAME, query_vector,
query_filter=query_filter, limit=5, threshold=0.5,
)
else:
results = db.search(QDRANT_COLLECTION_NAME, query_vector, limit=5, threshold=0.5)
all_results.append(results)
# print(query)
# for r in results:
# print(f"score: {r.score}")
# print(f"title: {r.payload.get('title')}")
# print(f"book_intro: {r.payload.get('book_intro')}")
# print("---------------")
# ── 4. RRF κ²°ν•©
merged_payloads = reciprocal_rank_fusion(all_results)
# ── 5. Reranker Model 적용
reranked_payloads = reranker.rerank(query=user_profile_query, books=merged_payloads)
# ── 6. κ²°κ³Ό 정리
retrieved_books = [
{
"isbn": p.get("isbn"),
"title": p.get("title"),
"author": p.get("author"),
"book_intro": p.get("book_intro"),
"category_large": p.get("category_large"),
"category_medium": p.get("category_medium"),
"cover_url": p.get("cover_url", ""),
}
for p in reranked_payloads[:3]
]
# print(f"\nμ΅œμ’… 검색 κ²°κ³Ό: {len(retrieved_books)}ꢌ")
return {"retrieved_books": retrieved_books}
# ## 10. Explain λ…Έλ“œ
# 각 검색 λ„μ„œμ— λŒ€ν•΄ `summary + reflection + book_intro` 기반으둜 사전 뢄석을 생성.
# `rag_llm_node`κ°€ 이 뢄석을 근거둜 μΆ”μ²œ 이유λ₯Ό μž‘μ„±ν•˜λ„λ‘ 함.
explain_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œ μΆ”μ²œ μ‹œμŠ€ν…œμ˜ AI μ–΄μ‹œμŠ€ν„΄νŠΈμž…λ‹ˆλ‹€.
μ•„λž˜ μ‚¬μš©μž ν”„λ‘œνŒŒμΌκ³Ό μ±… μ†Œκ°œλ₯Ό 읽고,
이 책이 이 μ‚¬μš©μžμ—κ²Œ μ™œ μ ν•©ν•œμ§€ λ˜λŠ” μ ν•©ν•˜μ§€ μ•Šμ€μ§€ 2λ¬Έμž₯으둜 λΆ„μ„ν•˜μ„Έμš”.
[주의]
- μ±… μ†Œκ°œμ— μ—†λŠ” λ‚΄μš©μ€ μ ˆλŒ€ μ§€μ–΄λ‚΄μ§€ λ§ˆμ„Έμš”.
- μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ˜ λ…μ„œ λͺ©μ , μ„ ν˜Έ μž₯λ₯΄, λ…μ„œ μŠ€νƒ€μΌκ³Ό μ—°κ²°ν•΄μ„œ μž‘μ„±ν•˜μ„Έμš”.
[μ‚¬μš©μž ν”„λ‘œνŒŒμΌ]
{summary}
[μ±… μ†Œκ°œ]
{book_intro}
뢄석:
""")
explain_chain = explain_prompt | llm
def explain_node(state: GraphState) -> dict:
summary = state.get("summary", "")
reflection = state.get("reflection", "")
books = state["retrieved_books"]
user_profile_query = " ".join(filter(None, [summary, reflection]))
for book in books:
analysis = explain_chain.invoke({
"summary": user_profile_query,
"book_intro": book.get("book_intro", ""),
}).content
book["analysis"] = analysis
print(f" [뢄석] {book.get('title')} β†’ {analysis[:60]}...")
return {"retrieved_books": books}
# ## 11. LLM λ…Έλ“œ
def rag_llm_node(state: GraphState) -> dict:
summary = state.get("summary", "")
reflection = state.get("reflection", "")
books = state["retrieved_books"]
user_profile_query = " ".join(filter(None, [summary, reflection]))
context = "\n\n".join([
f"ISBN: {b['isbn']}\n"
f"제λͺ©: {b['title']}\n"
f"μ €μž: {b['author']}\n"
f"μž₯λ₯΄: {b.get('category_medium') or b.get('category_large', '')}\n"
f"ν‘œμ§€URL: {b.get('cover_url', '')}\n"
f"μ†Œκ°œ: {b['book_intro'][:300]}\n"
f"사전뢄석: {b.get('analysis', '')}"
for b in books
])
rag_prompt = ChatPromptTemplate.from_template("""
당신은 λ„μ„œκ΄€ νλ ˆμ΄ν„° AIμž…λ‹ˆλ‹€.
[κ·œμΉ™]
- [κ²€μƒ‰λœ λ„μ„œ λͺ©λ‘]의 3ꢌ 전뢀에 λŒ€ν•΄ μΆ”μ²œ 이유λ₯Ό μž‘μ„±ν•˜μ„Έμš”. μ œμ™Έν•˜κ±°λ‚˜ λ‹€λ₯Έ μ±…μœΌλ‘œ λŒ€μ²΄ν•˜μ§€ λ§ˆμ„Έμš”.
- λ°˜λ“œμ‹œ JSON ν˜•μ‹μœΌλ‘œλ§Œ λ‹΅ν•˜μ„Έμš”. λ‹€λ₯Έ ν…μŠ€νŠΈλŠ” μ ˆλŒ€ ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”.
- μž₯λ₯΄λŠ” λ°˜λ“œμ‹œ [κ²€μƒ‰λœ λ„μ„œ λͺ©λ‘]의 μž₯λ₯΄ 값을 κ·ΈλŒ€λ‘œ μ‚¬μš©ν•˜μ„Έμš”.
[μΆ”μ²œ 이유 μž‘μ„± κ·œμΉ™]
- λ°˜λ“œμ‹œ [사전뢄석]κ³Ό [μ†Œκ°œ]에 λ‚˜μ˜¨ ꡬ체적인 λ‚΄μš©μ„ 근거둜 μž‘μ„±ν•˜μ„Έμš”.
- μ‚¬μš©μž ν”„λ‘œνŒŒμΌμ˜ μ–΄λ–€ λΆ€λΆ„(λ…μ„œ λͺ©μ , μ„ ν˜Έ μž₯λ₯΄, λ…μ„œ μŠ€νƒ€μΌ)κ³Ό μ—°κ²°λ˜λŠ”μ§€ λͺ…μ‹œν•˜μ„Έμš”.
- [μ†Œκ°œ]와 [사전뢄석]에 μ—†λŠ” λ‚΄μš©μ„ μž„μ˜λ‘œ μ§€μ–΄λ‚΄μ§€ λ§ˆμ„Έμš”.
- 2~3λ¬Έμž₯으둜 μž‘μ„±ν•˜μ„Έμš”.
[μ‚¬μš©μž ν”„λ‘œνŒŒμΌ]
{summary}
[κ²€μƒ‰λœ λ„μ„œ λͺ©λ‘]
{context}
[좜λ ₯ ν˜•μ‹]
[
{{"title": "μ±… 제λͺ©", "author": "μ €μž", "isbn": "ISBN번호", "cover_url": "ν‘œμ§€URL", "book_intro": "μ±… μ†Œκ°œ", "category_medium": "μž₯λ₯΄", "reason": "μΆ”μ²œ 이유 2~3λ¬Έμž₯"}},
{{"title": "μ±… 제λͺ©", "author": "μ €μž", "isbn": "ISBN번호", "cover_url": "ν‘œμ§€URL", "book_intro": "μ±… μ†Œκ°œ", "category_medium": "μž₯λ₯΄", "reason": "μΆ”μ²œ 이유 2~3λ¬Έμž₯"}},
{{"title": "μ±… 제λͺ©", "author": "μ €μž", "isbn": "ISBN번호", "cover_url": "ν‘œμ§€URL", "book_intro": "μ±… μ†Œκ°œ", "category_medium": "μž₯λ₯΄", "reason": "μΆ”μ²œ 이유 2~3λ¬Έμž₯"}}
]
""")
chain = rag_prompt | llm
response = chain.invoke({"context": context, "summary": user_profile_query})
try:
recommendations = json.loads(response.content)
except json.JSONDecodeError:
recommendations = response.content
return {
"messages": [AIMessage(content=response.content)],
"recommendations": recommendations,
}