AI Agents · Lezione

Agenti Q&A su più documenti

Indicizzazione di un corpus documentale e risposta a domande su tutti i documenti

Lezione 3 di 413 passaggi

Agenti Q&A su più documenti è una lezione AI Agents gratuita su CoddyKit. Questa è la lezione 3 di 4. Puoi leggere la lezione completa qui gratuitamente — poi esercitati direttamente nel browser con un editor di codice integrato e un tutor IA disponibile 24/7. Fa parte del percorso di apprendimento AI Agents, e i tuoi progressi si sincronizzano tra il web e l'app CoddyKit. Il corso AI Agents include 4 lezioni in totale.

Panoramica delle domande e risposte su più documenti

Un agente per domande e risposte su più documenti risponde alle domande recuperando contenuti pertinenti da una raccolta di N documenti, sintetizzando una risposta e attribuendo ogni affermazione alla relativa fonte.

A differenza delle domande e risposte su un singolo documento, gli agenti che lavorano su più documenti devono gestire informazioni in conflitto tra le fonti e valutare quali documenti siano più pertinenti alla domanda.

Indicizzare più documenti

Prima di rispondere a qualsiasi domanda, tutti i documenti devono essere indicizzati: analizzati, suddivisi in segmenti, sottoposti a embedding e memorizzati in un database vettoriale. Ogni segmento viene memorizzato con metadati che lo collegano al documento di origine.

import chromadb
from chromadb.utils import embedding_functions
import os

client = chromadb.PersistentClient(path='./doc_index')
ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv('OPENAI_API_KEY'),
    model_name='text-embedding-3-small'
)
collection = client.get_or_create_collection('documents', embedding_function=ef)

def index_document(doc_id, doc_path, doc_title):
    # Parse and chunk
    chunks = pdf_to_chunks(doc_path, chunk_size=800, overlap=150)
    for i, chunk in enumerate(chunks):
        chunk_id = f'{doc_id}_chunk_{i}'
        collection.add(
            ids=[chunk_id],
            documents=[chunk['text']],
            metadatas=[{
                'doc_id': doc_id,
                'title': doc_title,
                'page': chunk['page'],
                'source_file': doc_path
            }]
        )
    print(f'Indexed {len(chunks)} chunks from: {doc_title}')

Recuperare i segmenti pertinenti

Quando riceve una domanda, interroghi il vector store per trovare i segmenti semanticamente più simili tra tutti i documenti indicizzati. Il parametro n_results controlla quanti segmenti recuperare.

def retrieve_relevant_chunks(question, n_results=8):
    results = collection.query(
        query_texts=[question],
        n_results=n_results,
        include=['documents', 'metadatas', 'distances']
    )

    chunks = []
    for i in range(len(results['documents'][0])):
        chunks.append({
            'text':      results['documents'][0][i],
            'metadata':  results['metadatas'][0][i],
            'distance':  results['distances'][0][i],
            'relevance': 1 - results['distances'][0][i]  # cosine similarity proxy
        })

    # Sort by relevance
    chunks.sort(key=lambda x: x['relevance'], reverse=True)
    return chunks

Attribuire le fonti nel prompt

Quando passa i segmenti recuperati all'LLM, assegni a ogni segmento l'etichetta della fonte documentale. L'LLM potrà quindi citare le fonti utilizzando riferimenti numerati nella risposta.

def format_chunks_for_prompt(chunks, max_chars=4000):
    sections = []
    used_chars = 0

    for i, chunk in enumerate(chunks, 1):
        meta = chunk['metadata']
        header = f"[Source {i}: {meta['title']}, page {meta.get('page', '?')}]"
        content = chunk['text'][:600]
        entry = f'{header}\n{content}'

        if used_chars + len(entry) > max_chars:
            break

        sections.append(entry)
        used_chars += len(entry)

    return '\n\n'.join(sections)

QA_PROMPT = '''Answer the question based on the provided document excerpts.
Cite sources as [Source N]. If sources conflict, mention both views.

{context}

Question: {question}
Answer:'''

def answer_question(question):
    chunks = retrieve_relevant_chunks(question, n_results=6)
    context = format_chunks_for_prompt(chunks)
    return llm_call(QA_PROMPT.format(context=context, question=question))

Ragionamento tra documenti

Per alcune domande è necessario sintetizzare informazioni provenienti da più documenti, non soltanto trovare un singolo segmento corrispondente. Ad esempio: «Quale dei tre contratti prevede la penale più bassa?»

Utilizzi un approccio in due passaggi: recuperi i segmenti pertinenti da ciascun documento, quindi chieda all'LLM di confrontarli e sintetizzarli.

def cross_document_compare(question, doc_ids):
    # Retrieve best chunks per document
    per_doc_chunks = {}
    for doc_id in doc_ids:
        results = collection.query(
            query_texts=[question],
            n_results=3,
            where={'doc_id': {'$eq': doc_id}}  # filter by document
        )
        if results['documents'][0]:
            per_doc_chunks[doc_id] = results['documents'][0]

    # Format with document labels
    context_parts = []
    for doc_id, texts in per_doc_chunks.items():
        doc_label = f'Document {doc_id}'
        combined = ' '.join(texts[:2])[:600]
        context_parts.append(f'== {doc_label} ==\n{combined}')

    comparison_context = '\n\n'.join(context_parts)
    return llm_call(f'Compare these documents to answer: {question}\n\n{comparison_context}')

Gestire le informazioni in conflitto

Documenti diversi possono riportare fatti in conflitto: un contratto indica che il pagamento è dovuto entro 30 giorni, mentre un altro indica 60 giorni. L'agente deve rilevare e mettere in evidenza questi conflitti, invece di sceglierne uno in modo silenzioso.

CONFLICT_PROMPT = '''You are analyzing multiple document sources.
Some may contain conflicting information.

For each factual claim you make:
1. Cite the source document
2. If another source contradicts it, explicitly note the conflict
3. Indicate which source you believe is more authoritative, if possible

Document excerpts:
{context}

Question: {question}

Answer (with conflict notes where applicable):'''

def answer_with_conflict_detection(question):
    chunks = retrieve_relevant_chunks(question, n_results=8)
    context = format_chunks_for_prompt(chunks)
    return llm_call(CONFLICT_PROMPT.format(
        context=context, question=question
    ))

Filtraggio in base alla soglia di pertinenza

Non tutti i segmenti recuperati sono realmente pertinenti: la similarità vettoriale comporta un compromesso tra richiamo e precisione. Imposti una soglia minima di pertinenza per escludere i segmenti con corrispondenza debole, che potrebbero indurre in errore l'LLM.

MIN_RELEVANCE = 0.72  # cosine similarity threshold

def retrieve_above_threshold(question, n_results=10, threshold=MIN_RELEVANCE):
    chunks = retrieve_relevant_chunks(question, n_results=n_results)
    relevant = [c for c in chunks if c['relevance'] >= threshold]

    print(f'Retrieved: {len(chunks)}, Above threshold: {len(relevant)}')

    if not relevant:
        # Fallback: use top 3 even if below threshold
        return chunks[:3]

    return relevant

def answer_with_threshold(question):
    chunks = retrieve_above_threshold(question)
    if not chunks:
        return 'I could not find relevant information in the indexed documents.'
    context = format_chunks_for_prompt(chunks)
    return llm_call(QA_PROMPT.format(context=context, question=question))

Generare citazioni delle fonti

Dopo aver generato una risposta, estragga i documenti fonte citati e li restituisca come elenco strutturato. In questo modo aiuta gli utenti a trovare i documenti originali per verificarli.

import re

def extract_citations(answer_text, chunks):
    # Find all [Source N] references in the answer
    cited_nums = set(int(m) for m in re.findall(r'\[Source (\d+)\]', answer_text))

    citations = []
    for num in sorted(cited_nums):
        idx = num - 1
        if idx < len(chunks):
            meta = chunks[idx]['metadata']
            citations.append({
                'source_num': num,
                'title':     meta.get('title', 'Unknown'),
                'page':      meta.get('page', 'N/A'),
                'file':      meta.get('source_file', '')
            })

    return citations

def answer_with_citations(question):
    chunks = retrieve_above_threshold(question)
    context = format_chunks_for_prompt(chunks)
    answer = llm_call(QA_PROMPT.format(context=context, question=question))
    citations = extract_citations(answer, chunks)
    return {'answer': answer, 'citations': citations}

Riordinamento con un cross-encoder

Il recupero vettoriale iniziale utilizza bi-encoder (veloci e approssimativi). Un cross-encoder riordina i risultati principali assegnando un punteggio a ogni coppia (query, segmento) nel suo insieme: è più accurato, ma più lento. Questo approccio in due fasi migliora la qualità della risposta finale.

# pip install sentence-transformers
from sentence_transformers import CrossEncoder

reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def rerank_chunks(question, chunks, top_k=4):
    # Score each chunk against the question
    pairs = [(question, c['text']) for c in chunks]
    scores = reranker.predict(pairs)

    # Attach scores and re-sort
    scored_chunks = list(zip(scores, chunks))
    scored_chunks.sort(key=lambda x: x[0], reverse=True)

    top_chunks = [chunk for _, chunk in scored_chunks[:top_k]]
    print(f'Re-ranked {len(chunks)} chunks -> kept top {top_k}')
    return top_chunks

def answer_with_reranking(question):
    # Retrieve more initially
    initial_chunks = retrieve_relevant_chunks(question, n_results=12)
    # Re-rank for precision
    top_chunks = rerank_chunks(question, initial_chunks, top_k=4)
    context = format_chunks_for_prompt(top_chunks)
    return llm_call(QA_PROMPT.format(context=context, question=question))

Filtraggio dei metadati a livello di documento

Quando l'utente specifica un documento particolare o un intervallo di date, filtri a livello di metadati prima della ricerca tramite embedding. In questo modo impedisce che documenti irrilevanti contaminino i risultati.

def retrieve_filtered(question, filters=None, n_results=8):
    query_kwargs = {
        'query_texts': [question],
        'n_results': n_results,
        'include': ['documents', 'metadatas', 'distances']
    }

    # ChromaDB metadata filters
    # Example: {'doc_id': 'contract_2024', 'year': {'$gte': 2023}}
    if filters:
        query_kwargs['where'] = filters

    results = collection.query(**query_kwargs)
    return [
        {'text': t, 'metadata': m, 'relevance': 1 - d}
        for t, m, d in zip(
            results['documents'][0],
            results['metadatas'][0],
            results['distances'][0]
        )
    ]

# Example usage
chunks = retrieve_filtered(
    'What are the payment terms?',
    filters={'doc_id': {'$in': ['contract_a', 'contract_b']}}
)

Aggiornare l'indice con nuovi documenti

Le raccolte di documenti cambiano nel tempo: vengono aggiunti nuovi file e aggiornati quelli esistenti. La pipeline di indicizzazione deve supportare gli aggiornamenti incrementali: aggiungere i nuovi documenti, reindicizzare quelli aggiornati e rimuovere quelli eliminati.

import os
import hashlib

# Track indexed documents by file hash
index_registry = {}  # {filepath: {hash, doc_id, indexed_at}}

def file_hash(filepath):
    with open(filepath, 'rb') as f:
        return hashlib.md5(f.read()).hexdigest()

def index_if_new_or_changed(filepath, title):
    fhash = file_hash(filepath)
    existing = index_registry.get(filepath)

    if existing and existing['hash'] == fhash:
        print(f'Skipping unchanged: {title}')
        return existing['doc_id']

    if existing:
        # Remove old chunks from vector store
        collection.delete(where={'doc_id': {'': existing['doc_id']}})
        print(f'Re-indexing updated: {title}')
    else:
        print(f'Indexing new: {title}')

    doc_id = hashlib.md5(filepath.encode()).hexdigest()[:8]
    index_document(doc_id, filepath, title)
    index_registry[filepath] = {'hash': fhash, 'doc_id': doc_id}
    return doc_id

def sync_document_directory(directory):
    pdf_files = [f for f in os.listdir(directory) if f.endswith('.pdf')]
    for fname in pdf_files:
        fpath = os.path.join(directory, fname)
        title = fname.replace('.pdf', '').replace('_', ' ').title()
        index_if_new_or_changed(fpath, title)
    print(f'Sync complete: {len(pdf_files)} files processed')

Verifica delle conoscenze

In un sistema di domande e risposte su più documenti che utilizza la generazione aumentata dal recupero, che cosa impedisce la soglia di pertinenza?

Riepilogo: agenti per domande e risposte su più documenti

Domande e risposte su più documenti: indicizzare tutti i documenti (analizzare → suddividere in segmenti → eseguire l'embedding → memorizzare con i metadati) → recuperare i segmenti pertinenti da tutti i documenti → formattare con le etichette delle fonti → sintetizzare la risposta con le citazioni.

Tecniche avanzate: confronto tra documenti per le domande comparative, richieste di rilevamento dei conflitti, filtraggio in base alla soglia di pertinenza, riordinamento con cross-encoder per aumentare la precisione e filtraggio dei metadati per limitare le query a documenti o intervalli di date specifici.

Gratis per iniziare

Impara AI Agents con un tutor IA — gratis

Scrivi ed esegui vero codice nel tuo browser, ricevi aiuto istantaneo da un tutor IA disponibile 24/7, e riprendi da dove hai lasciato sul web o nell'app.

Corsi
60
Lezioni
239

Domande Frequenti

La lezione «Agenti Q&A su più documenti» è gratuita?

Sì — il testo completo di «Agenti Q&A su più documenti» è gratuito qui sul web. Per esercitarvi in modo interattivo (un editor di codice integrato e un tutor IA 24/7) e sbloccare il resto del corso AI Agents, passa a CoddyKit PRO. Il corso AI Agents include 4 lezioni in totale.

Cosa imparerò in «Agenti Q&A su più documenti»?

Indicizzazione di un corpus documentale e risposta a domande su tutti i documenti Eserciti AI Agents con codice pratico che esegui direttamente nel browser, e un tutor IA 24/7 risponde alle tue domande mentre lavori sulla lezione.

Ho bisogno di esperienza per iniziare AI Agents?

Non è richiesta alcuna esperienza precedente. AI Agents su CoddyKit è strutturato per principianti e studenti avanzati, quindi puoi iniziare da qui o dall'inizio e procedere al tuo ritmo. Questa è la lezione 3 di 4.

Quanto tempo richiede la lezione «Agenti Q&A su più documenti»?

La maggior parte delle lezioni CoddyKit richiede circa 5–10 minuti. Ogni lezione è breve e interattiva, quindi fai progressi costanti e riprendi esattamente da dove hai lasciato su web e app.

Posso scrivere ed eseguire codice in questa lezione AI Agents?

Sì. Ogni lezione AI Agents include un editor di codice integrato, quindi scrivi ed esegui codice reale direttamente nel tuo browser e ricevi feedback istantaneo dall'IA — nessuna configurazione locale necessaria.

Tutte le lezioni di questo corso

  1. Parsing dei PDF con PyMuPDF e pdfplumber
  2. OCR per documenti scansionati
  3. Agenti Q&A su più documenti
  4. Classificazione e instradamento dei documenti
← Torna a AI Agents