Salta el contingut

Pràctica 2 — Agent amb Memòria RAG

📋 Fitxa de la Pràctica

⏱️ Durada: 4 hores 🎯 RA: RA2 (CA 2.3, CA 2.4) 📊 Pes: 25% de la nota pràctica 🦜 LangChain 0.3 + ChromaDB 0.5

🎯 Objectius

  • Construir un pipeline RAG complet: ingestió → indexació → recuperació → generació
  • Integrar el RAG en un agent amb conversa multi-torn
  • Avaluar i millorar la qualitat del chunking
  • Implementar citació de fonts en les respostes

🔧 Configuració

mkdir practica2-agent-rag && cd practica2-agent-rag
python -m venv .venv && source .venv/bin/activate

pip install langchain==0.3.7 langchain-openai==0.2.6 \
            langchain-community==0.3.7 chromadb==0.5.18 \
            pypdf==4.3.1 python-dotenv==1.0.1

💻 Part 1: Pipeline RAG Bàsic (90 minuts)

"""
Pràctica 2 — Part 1: Pipeline RAG Complet
"""
import os
from pathlib import Path
from dotenv import load_dotenv
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

load_dotenv()

# ── CONFIGURABLE ──────────────────────────────────────────────
DOCS_DIR      = "./documents"       # Directori amb els PDFs/TXTs
CHROMA_DIR    = "./chroma_db"
CHUNK_SIZE    = 512
CHUNK_OVERLAP = 50

# ── FASE 1: INGESTIÓ ──────────────────────────────────────────

def ingerir_documents(docs_dir: str) -> list:
    """Carrega tots els documents del directori."""
    Path(docs_dir).mkdir(exist_ok=True)

    # Comprovar si hi ha documents
    files = list(Path(docs_dir).glob("**/*.pdf")) + \
            list(Path(docs_dir).glob("**/*.txt")) + \
            list(Path(docs_dir).glob("**/*.md"))

    if not files:
        print(f"⚠️  No s'han trobat documents a '{docs_dir}'")
        print("   Crea algun fitxer .pdf, .txt o .md per continuar")
        return []

    loader = DirectoryLoader(
        docs_dir,
        glob="**/*.{pdf,txt,md}",
        show_progress=True
    )
    documents = loader.load()
    print(f"✅ Carregats {len(documents)} documents ({len(files)} fitxers)")
    return documents

# ── FASE 2: CHUNKING ──────────────────────────────────────────

def fer_chunking(documents: list) -> list:
    """Divideix els documents en fragments."""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        length_function=len,
        separators=["\n\n", "\n", ". ", " ", ""]
    )
    chunks = splitter.split_documents(documents)

    # Estadístiques
    sizes = [len(c.page_content) for c in chunks]
    print(f"✅ {len(chunks)} chunks — "
          f"Mida mín/màx/mitja: {min(sizes)}/{max(sizes)}/{sum(sizes)//len(sizes)}")
    return chunks

# ── FASE 3 i 4: EMBEDDINGS + VECTOR STORE ─────────────────────

def crear_o_carregar_index(chunks: list = None) -> Chroma:
    """Crea un nou índex o en carrega un d'existent."""
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    if Path(CHROMA_DIR).exists() and chunks is None:
        print("📂 Carregant índex existent...")
        vs = Chroma(persist_directory=CHROMA_DIR, embedding_function=embeddings)
        print(f"✅ Índex carregat: {vs._collection.count()} vectors")
        return vs

    if not chunks:
        raise ValueError("Cal proporcionar chunks per crear un nou índex")

    print("🔧 Creant nou índex...")
    vs = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_DIR,
    )
    print(f"✅ Índex creat: {vs._collection.count()} vectors")
    return vs

# ── FASE 5: RAG CHAIN ─────────────────────────────────────────

RAG_PROMPT = PromptTemplate(
    template="""Ets un assistent expert. Respon la pregunta basant-te ÚNICAMENT
en el context proporcionat. Si la informació no hi és, respon
"No ho trobo a la documentació disponible."

Context:
{context}

Pregunta: {question}

Resposta (en català, amb detall i citant la font quan escau):""",
    input_variables=["context", "question"]
)

def crear_rag_chain(vectorstore: Chroma) -> RetrievalQA:
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    retriever = vectorstore.as_retriever(
        search_type="similarity",
        search_kwargs={"k": 4}
    )
    return RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=retriever,
        return_source_documents=True,
        chain_type_kwargs={"prompt": RAG_PROMPT}
    )

def preguntar(chain: RetrievalQA, pregunta: str):
    """Fa una pregunta i mostra resposta + fonts."""
    print(f"\n🔍 Pregunta: {pregunta}")
    result = chain.invoke({"query": pregunta})
    print(f"\n💬 Resposta:\n{result['result']}")
    print("\n📚 Fonts:")
    seen = set()
    for doc in result['source_documents']:
        src = doc.metadata.get('source', 'desconegut')
        page = doc.metadata.get('page', '?')
        key = f"{src}:{page}"
        if key not in seen:
            seen.add(key)
            print(f"  • {Path(src).name}, pàg. {page}: {doc.page_content[:80]}...")
    print("-" * 60)

# ── MAIN ──────────────────────────────────────────────────────

if __name__ == "__main__":
    # Ingestió
    docs  = ingerir_documents(DOCS_DIR)
    if not docs:
        print("\nCrea alguns documents a ./documents/ i torna a executar")
        exit(0)

    chunks = fer_chunking(docs)
    vs     = crear_o_carregar_index(chunks)
    chain  = crear_rag_chain(vs)

    # Preguntes de prova
    preguntes = [
        "Quins temes cobreix aquest document?",
        "Quin és el concepte més important explicat?",
    ]
    for p in preguntes:
        preguntar(chain, p)

💻 Part 2: Agent RAG amb Memòria (90 minuts)

"""
Pràctica 2 — Part 2: Agent conversacional amb RAG + Memòria
"""
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain.tools import tool
from langchain.memory import ConversationSummaryBufferMemory
from langchain.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

# Assumint que vs i chain estan definits de la Part 1

@tool
def buscar_documentacio(pregunta: str) -> str:
    """
    Busca informació a la base de documents indexada.
    Usa aquesta eina per a qualsevol pregunta sobre el contingut
    dels documents. Retorna la resposta amb les fonts.
    """
    result = chain.invoke({"query": pregunta})
    fonts = [doc.metadata.get('source', '?') for doc in result['source_documents']]
    return f"Resposta: {result['result']}\nFonts: {set(fonts)}"

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=1000,
    memory_key="chat_history",
    return_messages=True
)

prompt = ChatPromptTemplate.from_messages([
    ("system", "Ets un assistent que respon preguntes basant-se en documentació indexada. "
               "Usa SEMPRE l'eina buscar_documentacio per a preguntes de contingut. "
               "Respon en català."),
    ("placeholder", "{chat_history}"),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

agent = create_tool_calling_agent(llm, [buscar_documentacio], prompt)
executor = AgentExecutor(
    agent=agent, tools=[buscar_documentacio],
    memory=memory, verbose=True, max_iterations=5
)

# Conversa multi-torn
print(executor.invoke({"input": "Quins temes cobreix la documentació?"})["output"])
print(executor.invoke({"input": "Explica el primer tema amb més detall"})["output"])
print(executor.invoke({"input": "Hi ha exemples pràctics sobre això?"})["output"])

🏆 Part 3: Comparació de Configuracions (60 minuts)

Experimenta amb les variables de chunking i documenta els resultats:

"""
Pràctica 2 — Part 3: Experiment de Chunking
"""
CONFIGURACIONS = [
    {"chunk_size": 256,  "overlap": 0},
    {"chunk_size": 512,  "overlap": 50},   # ← Referència
    {"chunk_size": 1024, "overlap": 100},
    {"chunk_size": 2048, "overlap": 200},
]

PREGUNTES_TEST = [
    "Explica el concepte principal del document",
    "Quins passos cal seguir per...",  # Adaptar al teu document
]

resultats = {}
for config in CONFIGURACIONS:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=config["chunk_size"],
        chunk_overlap=config["overlap"]
    )
    chunks = splitter.split_documents(docs)
    vs_test = Chroma.from_documents(chunks, embeddings,
                                     collection_name=f"test_{config['chunk_size']}")
    chain_test = crear_rag_chain(vs_test)

    key = f"chunk={config['chunk_size']},overlap={config['overlap']}"
    resultats[key] = []
    for p in PREGUNTES_TEST:
        r = chain_test.invoke({"query": p})
        resultats[key].append({
            "pregunta": p,
            "resposta": r["result"][:200],
            "num_fonts": len(r["source_documents"])
        })

# Guardar resultats per a l'informe
import json
with open("resultats_chunking.json", "w", encoding="utf-8") as f:
    json.dump(resultats, f, ensure_ascii=False, indent=2)
print("✅ Resultats guardats a resultats_chunking.json")

📊 Criteris d'Avaluació

Criteri Pes Indicadors
Pipeline RAG complet i funcional 30% Ingestió, chunking, embeddings, vector store
Qualitat de les respostes 25% Rellevància, citació de fonts, no al·lucinar
Agent conversacional 20% Memòria entre torns, ús correcte de l'eina RAG
Experiment de chunking 15% Compara ≥3 configuracions, conclusions documentades
Codi i documentació 10% README, comentaris, gestió d'errors

Entrega

ZIP sense .venv ni .env amb nom P2_NomCognom_AgentsIA.zip. Inclou els documents usats (o un README indicant d'on obtenir-los).