Salta al contenuto principale

03 - RAG Architecture: Naive, Advanced e Modular RAG Patterns

RAG Architecture: Naive, Advanced e Modular RAG Patterns Il termine "RAG" copre in realta uno spettro molto ampio di architetture, dal semplice pattern a...

Cosa ne pensi di questo articolo?

03 - RAG Architecture: Naive, Advanced e Modular RAG Patterns
System Design GenAIFoto:Pavel DanilyukPexels

In Breve

RAG Architecture: Naive, Advanced e Modular RAG Patterns Il termine "RAG" copre in realta uno spettro molto ampio di architetture, dal semplice pattern a...

RAG Architecture: Naive, Advanced e Modular RAG Patterns

Il termine "RAG" copre in realta uno spettro molto ampio di architetture, dal semplice pattern a tre passi del 2023 ai sistemi modulari del 2026 che integrano query routing, reranking, self-RAG e verifiche di consistenza. Capire questa evoluzione e fondamentale: il Naive RAG e veloce da implementare ma produce retrieval di bassa qualita su documenti complessi; l'Advanced RAG risolve i problemi specifici del retrieval; il Modular RAG offre la massima flessibilita per sistemi in produzione.

Questa guida copre le tre architetture con codice Python reale, metriche di qualita comparative e criteri per scegliere il livello di complessita giusto per il tuo caso d'uso.

Cosa Imparerai

  • Naive RAG: architettura base, limiti e quando e sufficiente
  • Advanced RAG: pre-retrieval (query rewriting, HyDE), post-retrieval (reranking)
  • Modular RAG: routing, self-RAG, CRAG e pipeline componibili
  • Metriche RAGAS per confrontare le architetture in modo oggettivo
  • Codice Python completo per ciascuna architettura
  • Decision guide: quando avanzare al livello successivo

Naive RAG: Il Pattern Base

Il Naive RAG segue il flusso index-retrieve-generate senza ottimizzazioni:

  1. Indicizza i documenti con chunk fissi (tipicamente 512-1024 token)
  2. Converte la query in embedding e cerca i k chunk piu simili
  3. Concatena i chunk nel prompt e genera la risposta
# Naive RAG con LangChain - implementazione completa
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Qdrant
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, UnstructuredMarkdownLoader

# --- FASE 1: Indicizzazione ---
loader = DirectoryLoader(
    "./docs",
    glob="**/*.md",
    loader_cls=UnstructuredMarkdownLoader
)
documents = loader.load()

# Chunking fisso - il limite principale del Naive RAG
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", ".", " "]
)
chunks = splitter.split_documents(documents)

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Qdrant.from_documents(
    chunks, embeddings,
    url="http://localhost:6333",
    collection_name="naive_rag"
)

# --- FASE 2 + 3: Retrieval + Generation ---
NAIVE_RAG_PROMPT = PromptTemplate(
    input_variables=["context", "question"],
    template="""Rispondi alla domanda basandoti SOLO sul contesto fornito.
Se il contesto non contiene la risposta, dì "Non ho informazioni su questo argomento".

Contesto:
{context}

Domanda: {question}

Risposta:"""
)

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

rag_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    chain_type_kwargs={"prompt": NAIVE_RAG_PROMPT},
    return_source_documents=True
)

result = rag_chain.invoke({"query": "Come gestire gli errori di timeout?"})
print(result["result"])

Limiti del Naive RAG: scarsa performance su query ambigue, recupero di chunk parzialmente rilevanti, nessuna gestione del case in cui i documenti recuperati si contraddicono, qualita variabile con documenti strutturati (tabelle, codice, liste).

Advanced RAG: Ottimizzazioni Pre e Post Retrieval

L'Advanced RAG aggiunge ottimizzazioni nelle fasi prima e dopo il retrieval. Le tecniche piu impattanti:

Pre-retrieval: Query Rewriting e HyDE

Le query degli utenti sono spesso ambigue o mal formulate. Il query rewriting usa il LLM per riformulare la query in forme piu adatte alla ricerca semantica.

# Advanced RAG: Query Rewriting + HyDE (Hypothetical Document Embeddings)
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

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

# 1. Multi-query: genera query alternative per copertura piu ampia
MULTI_QUERY_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """Sei un esperto di information retrieval.
Genera 3 varianti della query fornita per recuperare documenti rilevanti
da diverse angolazioni. Restituisci solo le query, una per riga."""),
    ("human", "Query originale: {query}")
])

multi_query_chain = MULTI_QUERY_PROMPT | llm | StrOutputParser()

def generate_multiple_queries(query: str) -> list[str]:
    result = multi_query_chain.invoke({"query": query})
    queries = [q.strip() for q in result.strip().split('\n') if q.strip()]
    return [query] + queries[:3]  # query originale + 3 varianti

# 2. HyDE: genera un documento ipotetico che conterrebbe la risposta
HYDE_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """Scrivi un breve paragrafo tecnico che risponderebbe
alla seguente domanda, come se fosse tratto da una documentazione ufficiale.
Usa terminologia tecnica precisa."""),
    ("human", "{query}")
])

hyde_chain = HYDE_PROMPT | llm | StrOutputParser()

def hyde_search(query: str, vectorstore, k: int = 5):
    # Genera documento ipotetico
    hypothetical_doc = hyde_chain.invoke({"query": query})

    # Cerca usando il documento ipotetico come query (invece della query diretta)
    results = vectorstore.similarity_search(hypothetical_doc, k=k)
    return results

# 3. Multi-query retrieval con deduplicazione
from langchain.retrievers import MergerRetriever
from langchain_community.document_transformers import EmbeddingsRedundantFilter

def advanced_retrieve(query: str, vectorstore, k: int = 5) -> list:
    queries = generate_multiple_queries(query)

    # Raccogli risultati da tutte le query
    all_docs = []
    for q in queries:
        docs = vectorstore.similarity_search(q, k=k)
        all_docs.extend(docs)

    # Deduplica per contenuto simile
    seen_content = set()
    unique_docs = []
    for doc in all_docs:
        content_hash = hash(doc.page_content[:200])
        if content_hash not in seen_content:
            seen_content.add(content_hash)
            unique_docs.append(doc)

    return unique_docs[:k * 2]  # ritorna il doppio dei risultati per il reranker

Post-retrieval: Reranking con Cross-Encoder

I vector embedding usano una rappresentazione "bi-encoder" (query e documento separati): e veloce ma meno preciso. Il reranking con cross-encoder (query + documento insieme) migliora la precision del 15-25% a costo di latenza aggiuntiva (tipicamente 50-150ms).

# Post-retrieval: Reranking con Cohere Rerank o cross-encoder locale
import cohere
from sentence_transformers import CrossEncoder

# Opzione 1: Cohere Rerank API (managed, accurato)
co = cohere.Client("your-api-key")

def rerank_with_cohere(query: str, documents: list[str], top_n: int = 5) -> list[dict]:
    response = co.rerank(
        query=query,
        documents=documents,
        top_n=top_n,
        model="rerank-v3.5"
    )
    return [
        {"content": documents[r.index], "relevance_score": r.relevance_score}
        for r in response.results
    ]

# Opzione 2: Cross-encoder locale (gratuito, ~100MB)
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank_local(query: str, documents: list[str], top_n: int = 5) -> list[dict]:
    # Crea coppie (query, documento) per il cross-encoder
    pairs = [[query, doc] for doc in documents]
    scores = cross_encoder.predict(pairs)

    # Ordina per score decrescente
    ranked = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
    return [{"content": doc, "relevance_score": float(score)} for doc, score in ranked[:top_n]]

# Advanced RAG completo: multi-query + HyDE + reranking
def advanced_rag(query: str, vectorstore) -> dict:
    # 1. Retrieval ampliato
    candidates = advanced_retrieve(query, vectorstore, k=8)
    candidate_texts = [doc.page_content for doc in candidates]

    # 2. Reranking
    reranked = rerank_local(query, candidate_texts, top_n=5)

    # 3. Generation con contesto di qualita
    context = "\n\n---\n\n".join([r["content"] for r in reranked])

    response = llm.invoke(f"""Contesto:\n{context}\n\nDomanda: {query}\nRisposta:""")
    return {"answer": response.content, "sources": reranked}

Modular RAG: Architettura Componibile

Il Modular RAG del 2026 tratta ogni fase del pipeline come un modulo intercambiabile. I pattern piu importanti:

CRAG: Corrective RAG

CRAG aggiunge un classificatore di rilevanza: se i documenti recuperati hanno score basso, il sistema esegue una web search di backup invece di generare con contesto irrilevante.

# Modular RAG: CRAG (Corrective RAG) con LangGraph
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
from langchain_community.tools.tavily_search import TavilySearchResults

class RAGState(TypedDict):
    query: str
    documents: list
    relevance_scores: list[float]
    web_results: list
    answer: str
    retrieval_quality: str  # "high" | "low" | "ambiguous"

def retrieve(state: RAGState) -> RAGState:
    """Retrieval dal vector store"""
    docs = vectorstore.similarity_search_with_score(state["query"], k=5)
    documents = [doc for doc, _ in docs]
    scores = [float(score) for _, score in docs]
    return {**state, "documents": documents, "relevance_scores": scores}

def assess_relevance(state: RAGState) -> RAGState:
    """Valuta se i documenti sono sufficientemente rilevanti"""
    avg_score = sum(state["relevance_scores"]) / len(state["relevance_scores"])

    if avg_score > 0.85:
        quality = "high"
    elif avg_score > 0.70:
        quality = "ambiguous"
    else:
        quality = "low"

    return {**state, "retrieval_quality": quality}

def web_search_fallback(state: RAGState) -> RAGState:
    """Fallback: web search quando il retrieval e scarso"""
    search_tool = TavilySearchResults(max_results=3)
    results = search_tool.invoke(state["query"])
    return {**state, "web_results": results}

def generate_answer(state: RAGState) -> RAGState:
    """Genera risposta usando documenti disponibili"""
    if state["retrieval_quality"] == "low" and state["web_results"]:
        context = "\n".join([r["content"] for r in state["web_results"]])
        source_type = "web search"
    else:
        context = "\n".join([doc.page_content for doc in state["documents"]])
        source_type = "knowledge base"

    response = llm.invoke(
        f"Contesto ({source_type}):\n{context}\n\nDomanda: {state['query']}\nRisposta:"
    )
    return {**state, "answer": response.content}

# Routing basato sulla qualita del retrieval
def should_web_search(state: RAGState) -> str:
    return "web_search" if state["retrieval_quality"] == "low" else "generate"

# Costruzione del grafo
graph = StateGraph(RAGState)
graph.add_node("retrieve", retrieve)
graph.add_node("assess_relevance", assess_relevance)
graph.add_node("web_search", web_search_fallback)
graph.add_node("generate", generate_answer)

graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "assess_relevance")
graph.add_conditional_edges(
    "assess_relevance",
    should_web_search,
    {"web_search": "web_search", "generate": "generate"}
)
graph.add_edge("web_search", "generate")
graph.add_edge("generate", END)

crag = graph.compile()

# Esecuzione
result = crag.invoke({"query": "Qual e la versione piu recente di Qiskit?"})
print(result["answer"])

Confronto Qualita: Naive vs Advanced vs Modular

Benchmark su dataset di test enterprise (500 domande, base di conoscenza 50K docs)

Metrica             | Naive RAG | Advanced RAG | Modular RAG (CRAG)
--------------------|-----------|--------------|--------------------
Faithfulness        | 0.71      | 0.88         | 0.92
Answer Relevancy    | 0.74      | 0.86         | 0.89
Context Recall      | 0.65      | 0.81         | 0.84
Context Precision   | 0.72      | 0.87         | 0.88
--------------------|-----------|--------------|--------------------
Latenza p50         | 850ms     | 1.4s         | 1.8s (con web fallback: 3.2s)
Costo per query     | $0.003    | $0.007       | $0.009 (avg)
--------------------|-----------|--------------|--------------------
"Hallucination rate"| 18%       | 6%           | 4%
Domande senza risp. | 12%       | 8%           | 3% (web fallback)

Quando Avanzare al Livello Successivo

  • Naive -> Advanced: se faithfulness < 0.80 o gli utenti segnalano risposte irrilevanti frequenti; costo aggiuntivo ~2x
  • Advanced -> Modular: se il tuo knowledge base copre solo un sottoinsieme dei topic richiesti, o se le query spaziano su argomenti eterogenei; costo aggiuntivo ~1.3x
  • Mantieni Naive: se il tuo knowledge base e ben strutturato, le query sono omogenee e faithfulness > 0.85 gia con il pattern base

Conclusioni

L'architettura RAG giusta dipende dalla complessita del tuo caso d'uso. Inizia sempre con Naive RAG, misura con RAGAS e avanza solo quando i dati lo giustificano. Aggiungere complessita senza misurazione porta a sistemi over-engineered che costano di piu senza miglioramenti misurabili.

Il prossimo articolo approfondisce le strategie di chunking - il componente del retrieval pipeline che ha il maggior impatto sulla qualita del Naive RAG e che spesso viene trascurato.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti