peekabook-api / app /rag /query_transform_v2.py
lael
feat: initial deploy
3960e2b
Raw
History Blame Contribute Delete
11.3 kB
from __future__ import annotations
import json
import os
from dotenv import load_dotenv
from langchain_core.messages import 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 import GraphState, Phase, UserProfile
load_dotenv()
# โ”€โ”€ ๋ชจ๋“ˆ ๋ ˆ๋ฒจ ์ธ์Šคํ„ด์Šค (์ฒซ import ์‹œ ์ดˆ๊ธฐํ™”) โ”€โ”€
embedder = LocalEmbedder("BAAI/bge-m3")
db = QdrantDB(vector_size=1024)
llm = ChatOpenAI(model="gpt-4o-mini")
reranker = LocalReranker("BAAI/bge-reranker-v2-m3")
CATEGORY_LIST = [
"์†Œ์„ค", "๋Œ€ํ•™๊ต์žฌ/์ „๋ฌธ์„œ์ ", "์–ด๋ฆฐ์ด", "์ˆ˜ํ—˜์„œ/์ž๊ฒฉ์ฆ", "์‹œ/์—์„ธ์ด", "์ข…๊ต", "์œ ์•„", "๋งŒํ™”",
"์‚ฌํšŒ/์ •์น˜", "๊ฒฝ์ œ/๊ฒฝ์˜", "์ธ๋ฌธ", "์˜ˆ์ˆ /๋Œ€์ค‘๋ฌธํ™”", "๊ตญ์–ด/์™ธ๊ตญ์–ด", "๊ณ ๋“ฑํ•™๊ต ์ฐธ๊ณ ์„œ",
"์ž๊ธฐ๊ณ„๋ฐœ", "์ดˆ๋“ฑํ•™๊ต ์ฐธ๊ณ ์„œ", "๊ฑด๊ฐ•/์ทจ๋ฏธ", "์ปดํ“จํ„ฐ/IT", "์—ญ์‚ฌ", "์ž์—ฐ/๊ณผํ•™",
"์ฒญ์†Œ๋…„", "์ค‘ํ•™๊ต ์ฐธ๊ณ ์„œ", "๊ฐ€์ •/์š”๋ฆฌ", "์—ฌํ–‰", "์žก์ง€", "์ „์ง‘", "์™ธ๊ตญ๋„์„œ"
]
genre_prompt = ChatPromptTemplate.from_template("""
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์„ ๋ณด๊ณ  ์•„๋ž˜ ์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก์—์„œ ์ ํ•ฉํ•œ ๊ฒƒ์„ ์ตœ๋Œ€ 3๊ฐœ ์„ ํƒํ•˜์„ธ์š”.
๋ชฉ๋ก์— ์—†๋Š” ๊ฐ’์€ ์ ˆ๋Œ€ ๋ฐ˜ํ™˜ํ•˜์ง€ ๋งˆ์„ธ์š”.
์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก: {category_list}
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ: {summary}
JSON์œผ๋กœ๋งŒ ๋ฐ˜ํ™˜: {{"categories": ["์†Œ์„ค"]}}
""")
def extract_genre_node(state: GraphState) -> dict:
summary = state.get("summary", "")
chain = genre_prompt | llm
response = chain.invoke({"category_list": CATEGORY_LIST, "summary": summary})
try:
categories = json.loads(response.content)["categories"]
except (json.JSONDecodeError, KeyError):
categories = []
print(f"์ถ”์ถœ๋œ ์žฅ๋ฅด: {categories}")
return {"genre_filter": categories}
# โ”€โ”€ Query Transformation โ”€โ”€
step_back_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ AI ์–ด์‹œ์Šคํ„ดํŠธ์ž…๋‹ˆ๋‹ค.
์•„๋ž˜ ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์—์„œ ํ•œ ๋‹จ๊ณ„ ๋ฌผ๋Ÿฌ๋‚˜,
๋” ๋„“์€ ๋ฒ”์œ„์˜ ๋„์„œ๋ฅผ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” ์ผ๋ฐ˜์ ์ธ ์ฟผ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.
ํŠน์ • ์žฅ๋ฅด๋‚˜ ์กฐ๊ฑด์— ๊ตญํ•œ๋˜์ง€ ์•Š๊ณ  ๋…์„œ ๊ฒฝํ—˜๊ณผ ๋ชฉ์  ์ค‘์‹ฌ์œผ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
2๋ฌธ์žฅ ์ด๋‚ด๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ: {summary}
์ถœ๋ ฅ:
""")
rewrite_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ AI ์–ด์‹œ์Šคํ„ดํŠธ์ž…๋‹ˆ๋‹ค.
์•„๋ž˜ ๋…์„œ ๋ชฉ์ ์„ ๋„์„œ ๊ฒ€์ƒ‰์— ๋” ์ ํ•ฉํ•˜๊ณ  ๊ตฌ์ฒด์ ์ธ ๊ฒ€์ƒ‰์–ด๋กœ ์žฌ์ž‘์„ฑํ•˜์„ธ์š”.
์žฅ๋ฅด, ๋…์ž ์ˆ˜์ค€, ๋ถ„์œ„๊ธฐ ๋“ฑ ๊ฒ€์ƒ‰ ์ •ํ™•๋„๋ฅผ ๋†’์ผ ์ˆ˜ ์žˆ๋Š” ํ‘œํ˜„์„ ํฌํ•จํ•˜์„ธ์š”.
๋…์„œ ๋ชฉ์ : {step_back}
์žฌ์ž‘์„ฑ๋œ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ (๋‘ ๋ฌธ์žฅ ์ด๋‚ด๋กœ):
""")
decompose_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
์‚ฌ์šฉ์ž์˜ ๋…์„œ ์ทจํ–ฅ๊ณผ ์ƒํ™ฉ์„ ๊นŠ์ด ์ดํ•ดํ•˜์—ฌ, ๋ฒกํ„ฐ ์ž„๋ฒ ๋”ฉ ๊ฒ€์ƒ‰์— ์ตœ์ ํ™”๋œ ์„œ๋ธŒ์ฟผ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
์•„๋ž˜ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ๋ฅผ 2~4๊ฐœ์˜ ์„œ๋ธŒ์ฟผ๋ฆฌ๋กœ ๋ถ„ํ•ดํ•˜์„ธ์š”.
๊ฐ ์„œ๋ธŒ์ฟผ๋ฆฌ๋Š” ๋…๋ฆฝ์ ์ธ ๋ฌธ์žฅ์œผ๋กœ ์ž‘์„ฑํ•˜๋˜, ์ „์ฒด์ ์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋งฅ๋ฝ์ด ์ด์–ด์ง€๋„๋ก ํ•˜์„ธ์š”.
์›๋ž˜ ์ฟผ๋ฆฌ์˜ ์กฐ๊ฑด์„ ์ถฉ์‹คํžˆ ๋ฐ˜์˜ํ•˜๋ฉด์„œ, ์‚ฌ์šฉ์ž๊ฐ€ ๋ฏธ์ฒ˜ ์ƒ๊ฐํ•˜์ง€ ๋ชปํ–ˆ์„ ๊ด€๋ จ ๊ด€์ ์„ ํ•œ ๊ฐœ ํฌํ•จํ•˜์„ธ์š”.
[์ฃผ์˜]
- "์ด ์ค‘์—์„œ", "๊ทธ ์ค‘์—์„œ" ๊ฐ™์€ ์ฐธ์กฐ ํ‘œํ˜„์€ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”.
- ๋ฆฌ๋ทฐ, ํ‰์ , ์‚ฌ์šฉ์ž ์˜๊ฒฌ ๋“ฑ ๋„์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์™ธ์˜ ์ •๋ณด๋ฅผ ์š”์ฒญํ•˜์ง€ ๋งˆ์„ธ์š”.
- "์ถ”์ฒœ ๋„์„œ", "์ถ”์ฒœํ•ด์ฃผ์„ธ์š”", "ํฌํ•จํ•ด์ฃผ์„ธ์š”" ๊ฐ™์€ ํ‘œํ˜„์œผ๋กœ ๋๋‚ด์ง€ ๋งˆ์„ธ์š”.
๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ: {rewritten}
์ถœ๋ ฅ ํ˜•์‹ (๋ฒˆํ˜ธ์™€ ํ…์ŠคํŠธ๋งŒ, ๋‹ค๋ฅธ ํ…์ŠคํŠธ ์—†์ด):
1. [์„œ๋ธŒ์ฟผ๋ฆฌ 1]
2. [์„œ๋ธŒ์ฟผ๋ฆฌ 2]
3. [์„œ๋ธŒ์ฟผ๋ฆฌ 3]
""")
explain_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ AI ์–ด์‹œ์Šคํ„ดํŠธ์ž…๋‹ˆ๋‹ค.
์•„๋ž˜ ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ๊ณผ ์ฑ… ์†Œ๊ฐœ๋ฅผ ์ฝ๊ณ ,
์ด ์ฑ…์ด ์ด ์‚ฌ์šฉ์ž์—๊ฒŒ ์™œ ์ ํ•ฉํ•œ์ง€ ๋˜๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์€์ง€ 2๋ฌธ์žฅ์œผ๋กœ ๋ถ„์„ํ•˜์„ธ์š”.
[์ฃผ์˜]
- ์ฑ… ์†Œ๊ฐœ์— ์—†๋Š” ๋‚ด์šฉ์€ ์ ˆ๋Œ€ ์ง€์–ด๋‚ด์ง€ ๋งˆ์„ธ์š”.
- ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์˜ ๋…์„œ ๋ชฉ์ , ์„ ํ˜ธ ์žฅ๋ฅด, ๋…์„œ ์Šคํƒ€์ผ๊ณผ ์—ฐ๊ฒฐํ•ด์„œ ์ž‘์„ฑํ•˜์„ธ์š”.
[์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ]
{summary}
[์ฑ… ์†Œ๊ฐœ]
{book_intro}
๋ถ„์„:
""")
explain_chain = explain_prompt | llm
rag_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ๊ด€ ํ๋ ˆ์ดํ„ฐ AI์ž…๋‹ˆ๋‹ค.
[๊ทœ์น™]
- ๋ฐ˜๋“œ์‹œ [๊ฒ€์ƒ‰๋œ ๋„์„œ ๋ชฉ๋ก]์— ์žˆ๋Š” ์ฑ…๋งŒ ์ถ”์ฒœํ•˜์„ธ์š”.
- ๋ฐ˜๋“œ์‹œ JSON ํ˜•์‹์œผ๋กœ๋งŒ ๋‹ตํ•˜์„ธ์š”. ๋‹ค๋ฅธ ํ…์ŠคํŠธ๋Š” ์ ˆ๋Œ€ ํฌํ•จํ•˜์ง€ ๋งˆ์„ธ์š”.
- ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์„ ์ฐธ๊ณ ํ•ด์„œ ๊ฐ€์žฅ ์ ํ•ฉํ•œ ๋„์„œ 3๊ถŒ์„ ์ถ”์ฒœํ•˜์„ธ์š”.
- ์žฅ๋ฅด๋Š” ๋ฐ˜๋“œ์‹œ [๊ฒ€์ƒ‰๋œ ๋„์„œ ๋ชฉ๋ก]์˜ ์žฅ๋ฅด ๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.
[์ถ”์ฒœ ์ด์œ  ์ž‘์„ฑ ๊ทœ์น™]
- ๋ฐ˜๋“œ์‹œ [์‚ฌ์ „๋ถ„์„]๊ณผ [์†Œ๊ฐœ]์— ๋‚˜์˜จ ๊ตฌ์ฒด์ ์ธ ๋‚ด์šฉ์„ ๊ทผ๊ฑฐ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
- ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์˜ ์–ด๋–ค ๋ถ€๋ถ„(๋…์„œ ๋ชฉ์ , ์„ ํ˜ธ ์žฅ๋ฅด, ๋…์„œ ์Šคํƒ€์ผ)๊ณผ ์—ฐ๊ฒฐ๋˜๋Š”์ง€ ๋ช…์‹œํ•˜์„ธ์š”.
- [์†Œ๊ฐœ]์™€ [์‚ฌ์ „๋ถ„์„]์— ์—†๋Š” ๋‚ด์šฉ์„ ์ž„์˜๋กœ ์ง€์–ด๋‚ด์ง€ ๋งˆ์„ธ์š”.
- 2~3๋ฌธ์žฅ์œผ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
[์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ]
{summary}
[๊ฒ€์ƒ‰๋œ ๋„์„œ ๋ชฉ๋ก]
{context}
[์ถœ๋ ฅ ํ˜•์‹]
[
{{"title": "์ฑ… ์ œ๋ชฉ", "author": "์ €์ž", "isbn": "ISBN๋ฒˆํ˜ธ", "cover_url": "ํ‘œ์ง€URL", "book_intro": "์ฑ… ์†Œ๊ฐœ", "cate_depth1": "์žฅ๋ฅด", "reason": "์ถ”์ฒœ ์ด์œ  2~3๋ฌธ์žฅ"}},
{{"title": "์ฑ… ์ œ๋ชฉ", "author": "์ €์ž", "isbn": "ISBN๋ฒˆํ˜ธ", "cover_url": "ํ‘œ์ง€URL", "book_intro": "์ฑ… ์†Œ๊ฐœ", "cate_depth1": "์žฅ๋ฅด", "reason": "์ถ”์ฒœ ์ด์œ  2~3๋ฌธ์žฅ"}},
{{"title": "์ฑ… ์ œ๋ชฉ", "author": "์ €์ž", "isbn": "ISBN๋ฒˆํ˜ธ", "cover_url": "ํ‘œ์ง€URL", "book_intro": "์ฑ… ์†Œ๊ฐœ", "cate_depth1": "์žฅ๋ฅด", "reason": "์ถ”์ฒœ ์ด์œ  2~3๋ฌธ์žฅ"}}
]
""")
def step_back_query(summary: str) -> str:
chain = step_back_prompt | llm
return chain.invoke({"summary": summary}).content.strip()
def rewrite_query(step_back: str) -> str:
chain = rewrite_prompt | llm
return chain.invoke({"step_back": step_back}).content.strip()
def decompose_query(rewritten: str) -> list:
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
def get_chained_queries(summary: str) -> dict:
step_back = step_back_query(summary)
print(f" [Step-back] : {step_back}")
rewritten = rewrite_query(summary)
print(f" [Rewritten] : {rewritten}")
sub_queries = decompose_query(rewritten)
print(f" [Sub-queries]: {sub_queries}")
return {
"step_back": step_back,
"rewritten": rewritten,
"sub_queries": sub_queries,
"all": [step_back, rewritten] + sub_queries,
}
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)]
def query_transform_rag_node(state: GraphState) -> dict:
summary = state.get("summary", "")
reflection = state.get("reflection", "")
categories = state.get("genre_filter", [])
user_profile_query = " ".join(filter(None, [summary, reflection]))
print("\n[Query Transformations]")
queries = get_chained_queries(user_profile_query)
all_queries = queries["all"]
query_filter = None
if categories:
query_filter = Filter(
must=[FieldCondition(key="cate_depth1", match=MatchAny(any=categories))]
)
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.3,
)
else:
results = db.search(QDRANT_COLLECTION_NAME, query_vector, limit=5, threshold=0.3)
all_results.append(results)
print(query)
print(results)
# 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("---------------")
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"),
"cover_url": p.get("cover_url", ""),
}
for p in reranked_payloads[:5]
]
return {"retrieved_books": retrieved_books}
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}
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]))
if not books:
msg = "๊ฒ€์ƒ‰๋œ ๋„์„œ๊ฐ€ ์—†์–ด ์ถ”์ฒœ์„ ์ œ๊ณตํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
print(f"\n[rag_llm_node] {msg}")
return {
"messages": [AIMessage(content=msg)],
"recommendations": [],
}
context = "\n\n".join([
f"ISBN: {b['isbn']}\n"
f"์ œ๋ชฉ: {b['title']}\n"
f"์ €์ž: {b['author']}\n"
f"์žฅ๋ฅด: {b['cate_depth1']}\n"
f"ํ‘œ์ง€URL: {b.get('cover_url', '')}\n"
f"์†Œ๊ฐœ: {b['book_intro'][:300]}\n"
f"์‚ฌ์ „๋ถ„์„: {b.get('analysis', '')}"
for b in books
])
chain = rag_prompt | llm
response = chain.invoke({"context": context, "summary": user_profile_query})
print(f"\n[rag_llm_node ์ถœ๋ ฅ]\n{response.content}\n")
try:
recommendations = json.loads(response.content)
except json.JSONDecodeError:
recommendations = response.content
return {
"messages": [AIMessage(content=response.content)],
"recommendations": recommendations,
}