Spaces:
Running
Running
| """ | |
| # Simple RAG with Filtering + Query Transformations (v4) | |
| v3 λλΉ λ³κ²½: | |
| - λͺ¨λ λ 벨 νλκ·Έλ‘ query transformation μ‘°ν© μ μ΄ κ°λ₯ | |
| USE_STEP_BACK, USE_REWRITE, USE_DECOMPOSE | |
| - run_simulationμμ monkey-patchλ‘ μ€νλ³ μ‘°ν© μ§μ | |
| """ | |
| 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() | |
| # ββ Query Transformation νλκ·Έ βββββββββββββββββββββββββββββββββββββββββββββββ | |
| USE_STEP_BACK = True | |
| USE_REWRITE = True | |
| USE_DECOMPOSE = True | |
| # ββ μ΄κΈ°ν ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _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. μ₯λ₯΄ μΆμΆ λ Έλ | |
| 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. 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 λ Έλ | |
| top_genre_prompt_v2 = ChatPromptTemplate.from_template(""" | |
| μ¬μ©μ νλ‘νμΌμ λ³΄κ³ μλ λλΆλ₯ λͺ©λ‘μμ κ°μ₯ μ ν©ν κ²μ λ°λμ 2κ° μ ννμΈμ. | |
| λͺ©λ‘μ μλ κ°μ μ λ λ°ννμ§ λ§μΈμ. | |
| λλΆλ₯ λͺ©λ‘: {large_list} | |
| μ¬μ©μ νλ‘νμΌ: {summary} | |
| JSONμΌλ‘λ§ λ°ν: {{"categories": ["μμ€/μ/ν¬κ³‘", "κ²½μ κ²½μ"]}} | |
| """) | |
| medium_genre_prompt_v2 = ChatPromptTemplate.from_template(""" | |
| μ¬μ©μ νλ‘νμΌμ λ³΄κ³ μλ μ€λΆλ₯ λͺ©λ‘μμ κ°μ₯ μ ν©ν κ²μ λ°λμ 3κ° μ ννμΈμ. | |
| λͺ©λ‘μ μλ κ°μ μ λ λ°ννμ§ λ§μΈμ. | |
| μ€λΆλ₯ λͺ©λ‘: {medium_list} | |
| μ¬μ©μ νλ‘νμΌ: {summary} | |
| JSONμΌλ‘λ§ λ°ν: {{"categories": ["νκ΅μμ€", "μΈκ΅μμ€", "κ²½μ μΌλ°"]}} | |
| """) | |
| def extract_genre_node_v2(state: GraphState) -> dict: | |
| """λλΆλ₯ 2κ°, μ€λΆλ₯ 3κ°λ₯Ό λ°λμ μ ννλλ‘ κ°μ ν λ²μ .""" | |
| 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"} | |
| # ## 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=5, threshold=0.5, | |
| ) | |
| else: | |
| results = db.search(QDRANT_COLLECTION_NAME, query_vector, limit=5, 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[:3] | |
| ] | |
| return {"retrieved_books": retrieved_books} | |