peekabook-api / app /rag /query_transform_v6.py
lael
feat: initial deploy
3960e2b
Raw
History Blame Contribute Delete
11.6 kB
"""
# Simple RAG with Filtering + Query Transformations (v6)
v5 ๋Œ€๋น„ ๋ณ€๊ฒฝ:
- extract_genre_node_v2 ๋„์ž…: ๋Œ€๋ถ„๋ฅ˜ 2๊ฐœ, ์ค‘๋ถ„๋ฅ˜ 3๊ฐœ๋ฅผ "๋ฐ˜๋“œ์‹œ" ์„ ํƒํ•˜๋„๋ก ๊ฐ•์ œ
(v5์˜ "์ตœ๋Œ€ N๊ฐœ" โ†’ "๋ฐ˜๋“œ์‹œ N๊ฐœ"๋กœ ํ”„๋กฌํ”„ํŠธ ๋ณ€๊ฒฝ)
์žฅ๋ฅด ํ•„ํ„ฐ๊ฐ€ ํ•ญ์ƒ ์ค‘๋ถ„๋ฅ˜ ๋ ˆ๋ฒจ์—์„œ ํ™•์ •๋˜๋ฏ€๋กœ fallback ๋ถ„๊ธฐ ๋ฐœ์ƒ ๋นˆ๋„ ๊ฐ์†Œ
"""
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
load_dotenv()
# โ”€โ”€ Query Transformation ํ”Œ๋ž˜๊ทธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
USE_STEP_BACK = True
USE_REWRITE = True
USE_DECOMPOSE = True
# โ”€โ”€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํฌ๊ธฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
SEARCH_LIMIT = 10 # ์ฟผ๋ฆฌ๋‹น Qdrant ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์ˆ˜
RETRIEVE_TOP_N = 10 # ๋ฆฌ๋žญํ‚น ํ›„ ์ตœ์ข… ๋ฐ˜ํ™˜ ์ˆ˜
# โ”€โ”€ ์ดˆ๊ธฐํ™” โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_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. ์žฅ๋ฅด ์ถ”์ถœ ๋…ธ๋“œ v2 โ€” ํ•„์ˆ˜ ์„ ํƒ
top_genre_prompt_v2 = ChatPromptTemplate.from_template("""
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์„ ๋ณด๊ณ  ์•„๋ž˜ ๋Œ€๋ถ„๋ฅ˜ ๋ชฉ๋ก์—์„œ ๊ฐ€์žฅ ์ ํ•ฉํ•œ ๊ฒƒ์„ ๋ฐ˜๋“œ์‹œ 3๊ฐœ ์„ ํƒํ•˜์„ธ์š”.
๋ชฉ๋ก์— ์—†๋Š” ๊ฐ’์€ ์ ˆ๋Œ€ ๋ฐ˜ํ™˜ํ•˜์ง€ ๋งˆ์„ธ์š”.
๋Œ€๋ถ„๋ฅ˜ ๋ชฉ๋ก: {large_list}
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ: {summary}
JSON์œผ๋กœ๋งŒ ๋ฐ˜ํ™˜: {{"categories": ["์†Œ์„ค/์‹œ/ํฌ๊ณก", "๊ฒฝ์ œ๊ฒฝ์˜", "์ž๊ธฐ๊ณ„๋ฐœ"]}}
""")
medium_genre_prompt_v2 = ChatPromptTemplate.from_template("""
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์„ ๋ณด๊ณ  ์•„๋ž˜ ์ค‘๋ถ„๋ฅ˜ ๋ชฉ๋ก์—์„œ ๊ฐ€์žฅ ์ ํ•ฉํ•œ ๊ฒƒ์„ ๋ฐ˜๋“œ์‹œ 5๊ฐœ ์„ ํƒํ•˜์„ธ์š”.
๋ชฉ๋ก์— ์—†๋Š” ๊ฐ’์€ ์ ˆ๋Œ€ ๋ฐ˜ํ™˜ํ•˜์ง€ ๋งˆ์„ธ์š”.
์ค‘๋ถ„๋ฅ˜ ๋ชฉ๋ก: {medium_list}
์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ: {summary}
JSON์œผ๋กœ๋งŒ ๋ฐ˜ํ™˜: {{"categories": ["ํ•œ๊ตญ์†Œ์„ค", "์™ธ๊ตญ์†Œ์„ค", "๊ฒฝ์ œ์ผ๋ฐ˜", "์ž๊ธฐ๊ณ„๋ฐœ", "ํ•œ๊ตญ์—์„ธ์ด"]}}
""")
def extract_genre_node_v2(state: GraphState) -> dict:
summary = state.get("summary", "")
top_resp = (top_genre_prompt_v2 | 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 v2] ๋Œ€๋ถ„๋ฅ˜ ์ถ”์ถœ ์‹คํŒจ โ†’ ํ•„ํ„ฐ ์—†์Œ")
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 v2] ๋Œ€๋ถ„๋ฅ˜ fallback: {top_cats}")
return {"genre_filter": top_cats, "genre_level": "large"}
medium_resp = (medium_genre_prompt_v2 | 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 v2] ์ค‘๋ถ„๋ฅ˜ ์ถ”์ถœ ์‹คํŒจ โ†’ ๋Œ€๋ถ„๋ฅ˜ fallback: {top_cats}")
return {"genre_filter": top_cats, "genre_level": "large"}
print(f"[Genre v2] ๋Œ€๋ถ„๋ฅ˜: {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:
return (step_back_prompt | llm).invoke({"summary": summary}).content.strip()
# ## 3. Query Rewriting
rewrite_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
์•„๋ž˜ ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์„ ๋ฐ”ํƒ•์œผ๋กœ, ๋ฒกํ„ฐ ๊ฒ€์ƒ‰์— ์ ํ•ฉํ•œ ๋„์„œ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”.
[๊ทœ์น™]
- ๋„์„œ ์†Œ๊ฐœ๊ธ€(book_intro)์ด๋‚˜ ์ถœํŒ์‚ฌ ์„œํ‰์— ์‹ค์ œ๋กœ ๋“ฑ์žฅํ•  ๋ฒ•ํ•œ ์–ดํœ˜์™€ ํ‘œํ˜„์„ ์‚ฌ์šฉํ•˜์„ธ์š”.
- ์žฅ๋ฅด, ์ฃผ์ œ ์˜์—ญ, ์„œ์ˆ  ๋ฐฉ์‹, ๋Œ€์ƒ ๋…์ž์ธต ๋“ฑ ๋„์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ๋งค์นญ๋  ์ˆ˜ ์žˆ๋Š” ์กฐ๊ฑด์„ ํฌํ•จํ•˜์„ธ์š”.
- ์‚ฌ์šฉ์ž๊ฐ€ ์–ธ๊ธ‰ํ•œ ๊ธฐ์กด ๋„์„œ๊ฐ€ ์žˆ๋‹ค๋ฉด, ๊ทธ ๋„์„œ์˜ ํ•ต์‹ฌ ํŠน์„ฑ(์„œ์ˆ  ๋ฐฉ์‹, ์ฃผ์ œ ๋ฒ”์œ„)์„ ๋ฐ˜์˜ํ•˜์„ธ์š”.
- 3๋ฌธ์žฅ ์ด๋‚ด๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
๋…์„œ ๋ชฉ์ : {summary}
์žฌ์ž‘์„ฑ๋œ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ (๋‘ ๋ฌธ์žฅ ์ด๋‚ด๋กœ):
""")
def rewrite_query(summary: str, llm) -> str:
return (rewrite_prompt | llm).invoke({"summary": summary}).content.strip()
# ## 4. Sub-query Decomposition
decompose_prompt = ChatPromptTemplate.from_template("""
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์‹œ์Šคํ…œ์˜ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค.
์•„๋ž˜ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ๋ฅผ 2~4๊ฐœ์˜ ์„œ๋ธŒ์ฟผ๋ฆฌ๋กœ ๋ถ„ํ•ดํ•˜์„ธ์š”.
[ํ•ต์‹ฌ ์›์น™]
- ๊ฐ ์„œ๋ธŒ์ฟผ๋ฆฌ๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ๋…๋ฆฝ์  ๊ฒ€์ƒ‰ ์ธก๋ฉด์„ ๋‹ค๋ค„์•ผ ํ•ฉ๋‹ˆ๋‹ค.
๋™์ผํ•œ ์˜๋ฏธ๋ฅผ ๋‹ค๋ฅธ ํ‘œํ˜„์œผ๋กœ ๋ฐ˜๋ณตํ•˜๋Š” ๊ฒƒ์€ ์„œ๋ธŒ์ฟผ๋ฆฌ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.
- ๋ถ„ํ•ด ๊ธฐ์ค€ ์˜ˆ์‹œ: ์ฃผ์ œ/์žฅ๋ฅด ์ธก๋ฉด, ์„œ์ˆ  ๋ฐฉ์‹/๊ตฌ์กฐ ์ธก๋ฉด, ์œ ์‚ฌ ๋„์„œ ํŠน์„ฑ ์ธก๋ฉด, ๋Œ€์ƒ ๋…์ž ์ƒํ™ฉ ์ธก๋ฉด
- ๊ฐ ์„œ๋ธŒ์ฟผ๋ฆฌ๋Š” ๋…๋ฆฝ์ ์œผ๋กœ ๊ฒ€์ƒ‰ํ–ˆ์„ ๋•Œ ์„œ๋กœ ๋‹ค๋ฅธ ํ›„๋ณด ๋„์„œ๊ตฐ์„ ๋ฐ˜ํ™˜ํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
[์ž‘์„ฑ ๊ทœ์น™]
- ๋„์„œ ์†Œ๊ฐœ๊ธ€(book_intro)์— ๋“ฑ์žฅํ•  ๋ฒ•ํ•œ ์–ดํœ˜๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.
- "์ด ์ค‘์—์„œ", "๊ทธ ์ค‘์—์„œ" ๊ฐ™์€ ์ฐธ์กฐ ํ‘œํ˜„์€ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”.
- "์ถ”์ฒœํ•ด์ฃผ์„ธ์š”", "์•Œ๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค" ๊ฐ™์€ ์š”์ฒญํ˜• ์ข…๊ฒฐ์€ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”.
- ๋ฆฌ๋ทฐ, ํ‰์  ๋“ฑ ๋„์„œ ์†Œ๊ฐœ๊ธ€ ์™ธ์˜ ์ •๋ณด๋ฅผ ์š”์ฒญํ•˜์ง€ ๋งˆ์„ธ์š”.
๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ: {rewritten}
์ถœ๋ ฅ ํ˜•์‹ (๋ฒˆํ˜ธ์™€ ํ…์ŠคํŠธ๋งŒ, ๋‹ค๋ฅธ ํ…์ŠคํŠธ ์—†์ด):
1. [์„œ๋ธŒ์ฟผ๋ฆฌ 1]
2. [์„œ๋ธŒ์ฟผ๋ฆฌ 2]
3. [์„œ๋ธŒ์ฟผ๋ฆฌ 3]
""")
def decompose_query(rewritten: str, llm) -> list:
response = (decompose_prompt | llm).invoke({"rewritten": rewritten}).content
return [
q.strip().lstrip("1234567890. ")
for q in response.split("\n")
if q.strip() and q.strip()[0].isdigit()
]
# ## 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:
all_queries = []
step_back = step_back_query(user_profile_query, llm) if use_step_back else user_profile_query
print(f" [Step-back] : {step_back}")
if use_step_back:
all_queries.append(step_back)
rewritten = rewrite_query(user_profile_query, llm) if use_rewrite else user_profile_query
print(f" [Rewritten] : {rewritten}")
if use_rewrite:
all_queries.append(rewritten)
sub_queries = decompose_query(rewritten, llm) if use_decompose else []
print(f" [Sub-queries]: {sub_queries}")
all_queries.extend(sub_queries)
return {
"step_back": step_back,
"rewritten": rewritten,
"sub_queries": sub_queries,
"all": all_queries,
}
# ## 6. 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)]
# ## 7. 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("---------------------")
user_profile_query = " ".join(filter(None, [summary, reflection]))
print("\n[Query Transformations]")
if not (USE_STEP_BACK or USE_REWRITE or USE_DECOMPOSE):
print(" [๋ณ€ํ™˜ ์—†์Œ] ์›๋ณธ ์ฟผ๋ฆฌ๋งŒ ์‚ฌ์šฉ")
all_queries = [user_profile_query]
else:
queries = get_chained_queries(
user_profile_query, llm,
use_step_back=USE_STEP_BACK,
use_rewrite=USE_REWRITE,
use_decompose=USE_DECOMPOSE,
)
all_queries = queries["all"]
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))]
)
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=SEARCH_LIMIT, threshold=0.5,
)
else:
results = db.search(QDRANT_COLLECTION_NAME, query_vector, limit=SEARCH_LIMIT, threshold=0.5)
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"),
"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]
]
print(f"\n[RAG] ์ตœ์ข… ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ: {len(retrieved_books)}๊ถŒ")
return {"retrieved_books": retrieved_books}