Q&A-Agenten für mehrere Dokumente
Ein Dokumentenkorpus indexieren und Fragen über alle Dokumente hinweg beantworten.
Q&A-Agenten für mehrere Dokumente ist eine kostenlose AI Agents-Lektion auf CoddyKit. Dies ist Lektion 3 von 4. Du kannst die komplette Lektion unten kostenlos lesen – dann übst du sie direkt im Browser mit einem integrierten Code-Editor und einem KI-Tutor rund um die Uhr. Sie ist Teil des AI Agents-Lernpfads, und dein Fortschritt wird über Web und CoddyKit-App synchronisiert. Der AI Agents-Kurs umfasst insgesamt 4 Lektionen.
Überblick über Multi-Dokument-Q&A
Ein Multi-Dokument-Q&A-Agent beantwortet Fragen, indem er relevante Inhalte aus einer Sammlung von N Dokumenten abruft, eine Antwort synthetisiert und jede Aussage ihrer Quelle zuordnet.
Im Gegensatz zu Q&A über ein einzelnes Dokument müssen Multi-Dokument-Agents mit widersprüchlichen Informationen aus verschiedenen Quellen umgehen und abwägen, welche Dokumente für die Frage am relevantesten sind.
Mehrere Dokumente indexieren
Bevor Fragen beantwortet werden können, müssen alle Dokumente indexiert werden: Sie werden geparst, in Chunks aufgeteilt, eingebettet und in einer Vektordatenbank gespeichert. Jeder Chunk wird mit Metadaten gespeichert, die auf das Quelldokument zurückverweisen.
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}')Relevante Chunks abrufen
Fragen Sie bei einer Anfrage den Vektorspeicher nach den semantisch ähnlichsten Chunks aus allen indexierten Dokumenten ab. Der Parameter n_results legt fest, wie viele Chunks abgerufen werden.
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 chunksQuellenangaben im Prompt
Kennzeichnen Sie jeden abgerufenen Chunk mit seiner Dokumentquelle, wenn Sie ihn an das LLM übergeben. Das LLM kann die Quellen anschließend mithilfe nummerierter Verweise in der Antwort angeben.
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))Dokumentübergreifendes Schlussfolgern
Einige Fragen erfordern die Synthese von Informationen aus mehreren Dokumenten und nicht nur das Auffinden eines einzelnen passenden Chunks. Beispiel: „Welcher der drei Verträge enthält die niedrigste Vertragsstrafe?“
Verwenden Sie einen Ansatz in zwei Schritten: Rufen Sie relevante Chunks aus jedem Dokument ab und bitten Sie anschließend das LLM, diese zu vergleichen und daraus eine gemeinsame Antwort zu synthetisieren.
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}')Mit widersprüchlichen Informationen umgehen
Verschiedene Dokumente können widersprüchliche Fakten enthalten — in einem Vertrag ist die Zahlung innerhalb von 30 Tagen fällig, in einem anderen innerhalb von 60 Tagen. Der Agent muss solche Konflikte erkennen und offenlegen, statt stillschweigend eine der Angaben auszuwählen.
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
))Filtern anhand eines Relevanzschwellwerts
Nicht alle abgerufenen Chunks sind tatsächlich relevant — bei der Vektorähnlichkeit besteht ein Zielkonflikt zwischen Recall und Precision. Legen Sie einen minimalen Relevanzschwellwert fest, um schwach passende Chunks auszuschließen, die das LLM in die Irre führen könnten.
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))Quellenzitate erzeugen
Ermitteln Sie nach der Erstellung einer Antwort, welche Quelldokumente zitiert wurden, und geben Sie sie als strukturierte Liste zurück. So können Benutzer die Originaldokumente zur Überprüfung leichter finden.
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}Re-Ranking mit einem Cross-Encoder
Beim initialen Vektor-Retrieval werden Bi-Encoder verwendet (schnell, näherungsweise). Ein Cross-Encoder ordnet die besten Ergebnisse neu, indem er jedes Paar aus (Anfrage, Chunk) gemeinsam bewertet — genauer, aber langsamer. Dieser zweistufige Ansatz verbessert die Qualität der finalen Antwort.
# 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))Filtern von Metadaten auf Dokumentebene
Wenn der Benutzer ein bestimmtes Dokument oder einen Datumsbereich angibt, filtern Sie auf Metadatenebene, bevor Sie die Embedding-Suche durchführen. Dadurch wird verhindert, dass irrelevante Dokumente die Ergebnisse verfälschen.
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']}}
)Den Index mit neuen Dokumenten aktualisieren
Dokumentsammlungen verändern sich im Laufe der Zeit — neue Dateien kommen hinzu, ältere werden aktualisiert. Die Indexierungspipeline muss inkrementelle Aktualisierungen unterstützen: neue Dokumente hinzufügen, aktualisierte Dokumente neu indexieren und gelöschte Dokumente entfernen.
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')Wissensüberprüfung
Was soll der Relevanzschwellwert in einem Multi-Dokument-Q&A-System mit Retrieval-Augmented Generation verhindern?
Zusammenfassung: Multi-Dokument-Q&A-Agents
Multi-Dokument-Q&A: Alle Dokumente indexieren (parsen → in Chunks aufteilen → einbetten → mit Metadaten speichern) → relevante Chunks aus allen Dokumenten abrufen → mit Quellenangaben formatieren → Antwort mit Zitaten synthetisieren.
Fortgeschrittene Techniken: dokumentübergreifender Vergleich für vergleichende Fragen, Prompting zur Konflikterkennung, Filterung anhand eines Relevanzschwellwerts, Re-Ranking mit einem Cross-Encoder für höhere Precision und Metadatenfilter, um Anfragen auf bestimmte Dokumente oder Datumsbereiche zu begrenzen.
Häufig gestellte Fragen
Ist die Lektion „Q&A-Agenten für mehrere Dokumente“ kostenlos?
Ja — der vollständige Text von „Q&A-Agenten für mehrere Dokumente“ ist hier im Web kostenlos zu lesen. Um sie interaktiv zu üben (integrierter Code-Editor und 24/7 KI-Tutor) und den Rest des AI Agents-Kurses freizuschalten, upgrade auf CoddyKit PRO. Der AI Agents-Kurs umfasst insgesamt 4 Lektionen.
Was lerne ich in „Q&A-Agenten für mehrere Dokumente“?
Ein Dokumentenkorpus indexieren und Fragen über alle Dokumente hinweg beantworten. Du übst AI Agents mit praktischem Code, den du direkt im Browser ausführst, und ein 24/7 KI-Tutor beantwortet deine Fragen während du die Lektion bearbeitest.
Brauche ich Erfahrung, um AI Agents zu starten?
Keine Vorkenntnisse erforderlich. AI Agents auf CoddyKit ist für Anfänger bis fortgeschrittene Lernende strukturiert, sodass du hier starten oder von Anfang an beginnen und in deinem eigenen Tempo voranschreiten kannst. Dies ist Lektion 3 von 4.
Wie lange dauert die Lektion „Q&A-Agenten für mehrere Dokumente“?
Die meisten CoddyKit-Lektionen dauern etwa 5–10 Minuten. Jede ist kompakt und interaktiv, sodass du stetig Fortschritte machst und genau dort weitermachst, wo du aufgehört hast – im Web und in der App.
Kann ich in dieser AI Agents-Lektion Code schreiben und ausführen?
Ja. Jede AI Agents-Lektion enthält einen integrierten Code-Editor, sodass du echten Code direkt in deinem Browser schreibst und ausführst und sofort KI-Feedback erhältst — ohne lokale Einrichtung erforderlich.
Alle Lektionen in diesem Kurs
- PDFs mit PyMuPDF und pdfplumber parsen
- OCR für gescannte Dokumente
- Q&A-Agenten für mehrere Dokumente
- Dokumente klassifizieren und weiterleiten