Spaces:
Running
Running
| """ | |
| # query_transform_main.py | |
| query_transform_v5 κΈ°λ° + explain_node + rag_llm_node μΆκ°. | |
| μΉ λ°λͺ¨ μ΅μ’ μλ΅ μμ±κΉμ§ ν¬ν¨ν μ 체 RAG νμ΄νλΌμΈ. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import pandas as pd | |
| from dotenv import load_dotenv | |
| from langchain_core.messages import AIMessage | |
| 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_ORIGINAL = False | |
| USE_STEP_BACK = False | |
| USE_REWRITE = False | |
| USE_DECOMPOSE = False | |
| USE_HYDE = True | |
| # ββ κ²μ κ²°κ³Ό ν¬κΈ° ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SEARCH_LIMIT = 10 # μΏΌλ¦¬λΉ Qdrant κ²μ κ²°κ³Ό μ | |
| RETRIEVE_TOP_N = 10 # 리λνΉ ν μ΅μ’ λ°ν μ | |
| # ββ μ΄κΈ°ν ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _csv_path = os.path.join(os.path.dirname(__file__), "../../data/aladin_category.csv") | |
| if not os.path.exists(_csv_path): | |
| _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. HyDE (Hypothetical Document Embedding) | |
| _hyde_prompt = ChatPromptTemplate.from_template(""" | |
| λΉμ μ λμ νλ μ΄ν°μ λλ€. | |
| μλ μ¬μ©μ νλ‘νμΌμ μ½κ³ , μ΄ μ¬μ©μμκ² μ£Όμ μ λ΄μ© λ©΄μμ μλ²½νκ² λ§λ | |
| λμμ μκ°κΈ(book_intro)μ΄ μ΄λ»κ² μ°μ¬μμμ§ μμ±νμΈμ. | |
| [κ·μΉ] | |
| - μ€μ μΆνμ¬ μνμ΄λ λμ μκ°μ λμ¬ λ²ν 문체μ μ΄νλ₯Ό μ¬μ©νμΈμ. | |
| - μ μλͺ , μ± μ λͺ©μ λ§λ€μ§ λ§μΈμ. λ΄μ©κ³Ό μ£Όμ λ§ λ¬μ¬νμΈμ. | |
| - μ¬μ©μμ λ μ λͺ©μ κ³Ό μ νΈ μ₯λ₯΄μ μ§μ€νμΈμ. | |
| - 200μ λ΄μΈλ‘ μμ±νμΈμ. | |
| μ¬μ©μ νλ‘νμΌ: {summary} | |
| κ°μ λμ μκ°: | |
| """) | |
| def generate_hypothetical_doc(summary: str, llm) -> str: | |
| return (_hyde_prompt | llm).invoke({"summary": summary}).content.strip() | |
| # ## 5. 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() | |
| ] | |
| # ## 6. Chained Pipeline | |
| def get_chained_queries(user_profile_query: str, llm, | |
| use_original: bool = True, | |
| use_step_back: bool = True, | |
| use_rewrite: bool = True, | |
| use_decompose: bool = True, | |
| use_hyde: bool = False) -> dict: | |
| all_queries = [] | |
| hypothetical_doc = "" | |
| if use_original: | |
| all_queries.append(user_profile_query) | |
| step_back = step_back_query(user_profile_query, llm) if use_step_back else "" | |
| 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) | |
| if use_hyde: | |
| hypothetical_doc = generate_hypothetical_doc(user_profile_query, llm) | |
| print(f" [HyDE] : {hypothetical_doc[:80]}...") | |
| all_queries.append(hypothetical_doc) | |
| return { | |
| "step_back": step_back, | |
| "rewritten": rewritten, | |
| "sub_queries": sub_queries, | |
| "hypothetical_doc": hypothetical_doc, | |
| "all": all_queries, | |
| } | |
| # ## 7. 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)] | |
| # ## 8. 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]") | |
| queries = get_chained_queries( | |
| user_profile_query, llm, | |
| use_original=USE_ORIGINAL, | |
| use_step_back=USE_STEP_BACK, | |
| use_rewrite=USE_REWRITE, | |
| use_decompose=USE_DECOMPOSE, | |
| use_hyde=USE_HYDE, | |
| ) | |
| all_queries = queries["all"] or [user_profile_query] | |
| hypothetical_doc = queries.get("hypothetical_doc", "") | |
| query_transforms = { | |
| "original": user_profile_query, | |
| "step_back": queries.get("step_back", "") if USE_STEP_BACK else "", | |
| "rewritten": queries.get("rewritten", "") if USE_REWRITE else "", | |
| "sub_queries": queries.get("sub_queries", []), | |
| "all": all_queries, | |
| } | |
| 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, "query_transforms": query_transforms, "hypothetical_doc": hypothetical_doc} | |
| # ## 9. Explain λ Έλ β κ° λμμ λν μ¬μ λΆμ | |
| 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.get("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} | |
| # ## 10. RAG LLM λ Έλ β μΆμ² μ΄μ μμ± | |
| 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λ¬Έμ₯"}} | |
| ] | |
| """) | |
| def rag_llm_node(state: GraphState) -> dict: | |
| summary = state.get("summary", "") | |
| reflection = state.get("reflection", "") | |
| books = state.get("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.get('book_intro') or '')[:300]}\n" | |
| f"μ¬μ λΆμ: {b.get('analysis', '')}" | |
| for b in books[:3] | |
| ]) | |
| response = (rag_prompt | llm).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, | |
| } | |