Spaces:
Running
Running
| """ | |
| HyDE RAG (Hypothetical Document Embedding) v1 | |
| κΈ°μ‘΄ 쿼리 λ³ν(step-back / rewrite / decompose) λμ , | |
| LLMμ΄ μ¬μ©μ νλ‘νμΌμ λ³΄κ³ 'μ΄μμ μΈ λμμ μκ°κΈ(book_intro)'μ μ§μ μμ±ν©λλ€. | |
| μμ±λ κ°μ μκ°κΈμ μλ² λ©νμ¬ λ²‘ν° DBλ₯Ό κ²μνλ©΄, | |
| μ€μ book_intro 문체μ μλ―Έμ μΌλ‘ λ κ°κΉκΈ° λλ¬Έμ κ²μ νμ§μ΄ ν₯μμ κΈ°λν©λλ€. | |
| graph.py νΈν: | |
| from app.rag.query_transform_hyde import ( | |
| extract_genre_node, query_transform_rag_node, explain_node, rag_llm_node | |
| ) | |
| μ ν μ€λ§ λ°κΎΈλ©΄ κΈ°μ‘΄ graph.py λ Έλ ꡬμ±μ κ·Έλλ‘ μ¬μ©ν μ μμ΅λλ€. | |
| """ | |
| 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 | |
| # explain_node / rag_llm_node λ κΈ°μ‘΄ λͺ¨λμμ κ·Έλλ‘ κ°μ Έμ΅λλ€. | |
| from app.rag.query_transform import explain_node, rag_llm_node # noqa: F401 | |
| load_dotenv() | |
| # ββ μ€μ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # κ°μ μκ°κΈμ μ¬λ¬ κ°λμμ μμ±ν΄ κ²μ λ€μμ± ν보 | |
| # κ°λλ³λ‘ λ³λ μλ² λ© β Qdrant κ²μ β RRF λ³ν© | |
| SEARCH_LIMIT = 10 # κ°λλ³ Qdrant κ²μ κ²°κ³Ό μ | |
| RETRIEVE_TOP_N = 10 # 리λνΉ ν μ΅μ’ λ°ν μ | |
| # ββ μΉ΄ν κ³ λ¦¬ νΈλ¦¬ (v5μ λμΌ) βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _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", temperature=0.7) | |
| reranker = LocalReranker("BAAI/bge-reranker-v2-m3") | |
| # ββ 1. μ₯λ₯΄ μΆμΆ λ Έλ (v5μ λμΌ) βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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. HyDE κ°μ μκ°κΈ μμ± ν둬ννΈ βββββββββββββββββββββββββββββββββββββββββ | |
| # | |
| # κ° ν둬ννΈλ μ¬μ©μ νλ‘νμΌμ μλ‘ λ€λ₯Έ μΈ‘λ©΄μ κ°μ‘°νμ¬ | |
| # μλ―Έμ μΌλ‘ λ€μν μλ² λ© λ²‘ν°λ₯Ό λ§λ€μ΄λ λλ€. | |
| # (νλμ νλ‘ν β μ¬λ¬ κ²μ λ²‘ν° β RRF λ³ν©) | |
| hyde_content_prompt = ChatPromptTemplate.from_template(""" | |
| λΉμ μ λμ νλ μ΄ν°μ λλ€. | |
| μλ μ¬μ©μ νλ‘νμΌμ μ½κ³ , μ΄ μ¬μ©μμκ² μ£Όμ μ λ΄μ© λ©΄μμ μλ²½νκ² λ§λ | |
| λμμ μκ°κΈ(book_intro)μ΄ μ΄λ»κ² μ°μ¬μμμ§ μμ±νμΈμ. | |
| [κ·μΉ] | |
| - μ€μ μΆνμ¬ μνμ΄λ λμ μκ°μ λμ¬ λ²ν 문체μ μ΄νλ₯Ό μ¬μ©νμΈμ. | |
| - μ μλͺ , μ± μ λͺ©μ λ§λ€μ§ λ§μΈμ. λ΄μ©κ³Ό μ£Όμ λ§ λ¬μ¬νμΈμ. | |
| - μ¬μ©μμ λ μ λͺ©μ κ³Ό μ νΈ μ₯λ₯΄μ μ§μ€νμΈμ. | |
| - 200μ λ΄μΈλ‘ μμ±νμΈμ. | |
| μ¬μ©μ νλ‘νμΌ: {summary} | |
| κ°μ λμ μκ° (μ£Όμ /λ΄μ© μΈ‘λ©΄): | |
| """) | |
| hyde_style_prompt = ChatPromptTemplate.from_template(""" | |
| λΉμ μ λμ νλ μ΄ν°μ λλ€. | |
| μλ μ¬μ©μ νλ‘νμΌμ μ½κ³ , μ΄ μ¬μ©μμ λ μ μ€νμΌκ³Ό λμ΄λ μ νΈμ λ± λ§λ | |
| λμμ μκ°κΈ(book_intro)μ΄ μ΄λ»κ² μ°μ¬μμμ§ μμ±νμΈμ. | |
| [κ·μΉ] | |
| - μ€μ μΆνμ¬ μνμ΄λ λμ μκ°μ λμ¬ λ²ν 문체μ μ΄νλ₯Ό μ¬μ©νμΈμ. | |
| - μ μλͺ , μ± μ λͺ©μ λ§λ€μ§ λ§μΈμ. μμ λ°©μ, ꡬμ±, λμ΄λλ§ λ¬μ¬νμΈμ. | |
| - μ¬μ©μμ λ μ μ€νμΌ(μλ, κΉμ΄, νμ)κ³Ό λμ΄λ μ νΈμ μ§μ€νμΈμ. | |
| - 200μ λ΄μΈλ‘ μμ±νμΈμ. | |
| μ¬μ©μ νλ‘νμΌ: {summary} | |
| κ°μ λμ μκ° (λ μ μ€νμΌ/λμ΄λ μΈ‘λ©΄): | |
| """) | |
| hyde_context_prompt = ChatPromptTemplate.from_template(""" | |
| λΉμ μ λμ νλ μ΄ν°μ λλ€. | |
| μλ μ¬μ©μ νλ‘νμΌμ μ½κ³ , μ΄ μ¬μ©μμ νμ¬ μν©κ³Ό κ°μ μ 곡λͺ νλ | |
| λμμ μκ°κΈ(book_intro)μ΄ μ΄λ»κ² μ°μ¬μμμ§ μμ±νμΈμ. | |
| [κ·μΉ] | |
| - μ€μ μΆνμ¬ μνμ΄λ λμ μκ°μ λμ¬ λ²ν 문체μ μ΄νλ₯Ό μ¬μ©νμΈμ. | |
| - μ μλͺ , μ± μ λͺ©μ λ§λ€μ§ λ§μΈμ. λ μμκ² μ£Όλ κ°μ μ /μ€μ©μ κ°μΉλ§ λ¬μ¬νμΈμ. | |
| - μ¬μ©μμ νμ¬ μν©(κ°μ μν, μΆμ λ§₯λ½)κ³Ό λ μμμ μ»κ³ μΆμ κ²μ μ§μ€νμΈμ. | |
| - 200μ λ΄μΈλ‘ μμ±νμΈμ. | |
| μ¬μ©μ νλ‘νμΌ: {summary} | |
| κ°μ λμ μκ° (μν©/κ°μ± μΈ‘λ©΄): | |
| """) | |
| _HYDE_PROMPTS = [ | |
| ("content", hyde_content_prompt), | |
| # ("style", hyde_style_prompt), | |
| # ("context", hyde_context_prompt), | |
| ] | |
| def generate_hypothetical_docs(summary: str) -> list[tuple[str, str]]: | |
| """μ¬μ©μ νλ‘νμΌ β [(κ°λ μ΄λ¦, κ°μ book_intro), ...] μμ±.""" | |
| results = [] | |
| for angle, prompt in _HYDE_PROMPTS: | |
| hypo = (prompt | llm).invoke({"summary": summary}).content.strip() | |
| print(f" [HyDE/{angle}] {hypo[:80]}...") | |
| results.append((angle, hypo)) | |
| return results | |
| # ββ 3. 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)] | |
| # ββ 4. HyDE 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") # state_v3 μ΄μμμ μ¬μ© | |
| user_profile_query = " ".join(filter(None, [summary, reflection])) | |
| print("\n[HyDE] κ°μ λμ μκ°κΈ μμ± μ€...") | |
| hypo_docs = generate_hypothetical_docs(user_profile_query) | |
| 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))] | |
| ) | |
| elif categories: | |
| # state_v3 λ―Έμ¬μ© νκ²½ (κΈ°λ³Έ state.py) β cate_depth1 νν° fallback | |
| query_filter = Filter( | |
| must=[FieldCondition(key="cate_depth1", match=MatchAny(any=categories))] | |
| ) | |
| all_results = [] | |
| for angle, hypo_text in hypo_docs: | |
| query_vector = embedder.embed(hypo_text) | |
| 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, | |
| ) | |
| print(f" [HyDE/{angle}] κ²μ κ²°κ³Ό: {len(results)}건") | |
| 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"), | |
| "cate_depth1": p.get("cate_depth1"), | |
| # v5 νλ (μμΌλ©΄ ν¬ν¨) | |
| "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] | |
| ] | |
| hypothetical_doc = hypo_docs[0][1] if hypo_docs else "" | |
| print(f"\n[HyDE] μ΅μ’ κ²μ κ²°κ³Ό: {len(retrieved_books)}κΆ") | |
| return {"retrieved_books": retrieved_books, "hypothetical_doc": hypothetical_doc} | |