Keine Erklärungen. Nur der minimale Code, um ein funktionsfähiges RAG zu erstellen.
Der minimale Code für eine funktionierende RAG-App – ohne unnötige Erklärungen: Schlüsselwerte, Abhängigkeiten, ein Embedding-Modell, Qdrant zur Verwaltung von Vektoren sowie Gemini zur Erstellung der Antworten.
Quellen: No stories. Just the minimal code to build a working RAG application
Nutzen Sie dies als für Operator zugängliche Neuformulierung der Ideen aus „Keine Geschichten. Nur der minimale Code, um eine funktionierende RAG-Anwendung zu erstellen“: klare Phasen, geordnete Codeabschnitte sowie Wiederherstellungshinweise, die auch bei Übergaben erhalten bleiben. Die Überblicksphase funktioniert am besten, wenn sie als messbarer Rahmen betrachtet wird. Erfassen Sie vor der Erweiterung des Umfangs ein „goldenes“ Transkript, einen Fehlerfall sowie die Notizen zur Rücksetzung. Protokollieren Sie neben den funktionalen Ergebnissen auch die Dauer sowie die Kosten für Tokens oder Abfragen. Eine frühzeitige Sichtbarkeit der Kosten verhindert überraschende Rechnungen, wenn der Prozess von einer Demo in gemeinsame Umgebungen übergeht.
Schritt 0: Schlüsselwerte
In der Phase „Schlüssel Schritt 0“ sollten Eingaben, der Verantwortliche für den Schritt sowie die Abbruchkriterien definiert werden, bevor der Code geändert wird. Die Operator sollten in der Lage sein, den Schritt von einem bekannten Checkpoint aus erneut auszuführen, ohne auf versteckte Zustände schließen zu müssen. Die Konfiguration sollte außerhalb des Anwendungscode gespeichert werden. Umgebungsdateien, Geheimdatenspeicher sowie Feature-Flags sollten an einem Ort zusammengefasst sein, den die Operator überprüfen können, ohne den gesamten Ablauf durchzulesen. Zitieren Sie die Passagen, die tatsächlich die Antwort untermauern. Ohne Zitate können die Operator nicht zwischen Halluzinationen und Lücken in der Indizierung unterscheiden.
export QDRANT_CLUSTER_ENDPOINT="YOUR-CLUSTER.cloud.qdrant.io"
export QDRANT_API_KEY="..."
export GEMINI_API_KEY="..."
Schritt 1: Abhängigkeiten
In der Phase „Abhängigkeiten Schritt 1“ sollten die Eingaben, der Verantwortliche für den Schritt sowie die Abbruchkriterien definiert werden, bevor der Code geändert wird. Die Operator sollten in der Lage sein, den Schritt von einem bekannten Checkpoint aus erneut auszuführen, ohne auf versteckten Zuständen schließen zu müssen. Dokumentieren Sie sowohl den erfolgreichen Ablauf als auch den Notfallweg gemeinsam. Wiederholungsversuche, menschliche Überprüfungen sowie die Handhabung von Fehlern gehören zum Produkt selbst und nicht zu späteren Optimierungen. Zitieren Sie die Passagen, die tatsächlich die Antwort untermauern – ohne Zitate können die Operator nicht zwischen Halluzinationen und Lücken in der Indizierung unterscheiden.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>1.17.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j</artifactId></dependency>
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-document-parser-apache-tika</artifactId></dependency>
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId></dependency>
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-qdrant</artifactId></dependency>
<!-- NEW: Gemini via the Google GenAI SDK -->
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-google-genai</artifactId></dependency>
<!-- PDF text + page rendering, OCR, logging -->
<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>3.0.5</version></dependency>
<dependency><groupId>net.sourceforge.tess4j</groupId><artifactId>tess4j</artifactId><version>5.14.0</version></dependency>
<dependency><groupId>org.slf4j</groupId><artifactId>slf4j-simple</artifactId><version>2.0.16</version></dependency>
</dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-google-genai</artifactId>
<version>1.17.2-beta27</version>
</dependency>
Schritt 2: Das Embedding-Modell
Zur Embedding-Phase in Schritt 2 sollten die Eingaben, der Verantwortliche für den Schritt sowie die Abbruchkriterien vor dem Ändern des Codes definiert werden. Die Operator sollten in der Lage sein, den Schritt von einem bekannten Checkpoint aus erneut auszuführen, ohne auf versteckte Zustände schließen zu müssen. Bevorzugen Sie kleine, testbare Einheiten vor umfangreichen Skripten. Wenn ein Schritt fehlschlägt, sollte der Fehler auf eine einzige Verantwortung verweisen und nicht auf ein verworrenes Ablaufverfahren. Wählen Sie bei dem nächsten Schritt, der aus Code oder einem Toolaufruf besteht, strukturierte Ausgaben mit Schema-Validierung statt freier Prosa. Zur Embedding-Phase in Schritt 2 sollten die Eingaben, der Verantwortliche für den Schritt sowie die Abbruchkriterien vor dem Ändern des Codes definiert werden. Die Operator sollten in der Lage sein, den Schritt von einem bekannten Checkpoint aus erneut auszuführen, ohne auf versteckte Zustände schließen zu müssen. Erhalten Sie neben den funktionalen Ergebnissen auch Aufzeichnungen der Laufzeiten sowie der Kosten für Tokens oder Abfragen. Eine frühzeitige Sichtbarkeit der Kosten verhindert überraschende Rechnungen, wenn der Ablauf von einer Demo in gemeinsame Umgebungen übergeht.
static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
int vectorDimension = embeddingModel.dimension(); // 384
Schritt 3: Verbindung zu einem gehosteten Qdrant-System herstellen
Beim Bearbeiten des Schritts 3 „Verbindung herstellen“ sollten Sie zunächst die Anforderungen aufschreiben: erforderliche Eingaben, Signal für Erfolg sowie das Vorgehen bei teilweisen Fehlern. Diese Checkliste sorgt dafür, dass spätere Codeänderungen transparent bleiben. Bewahren Sie die Konfiguration außerhalb des Anwendungscode auf. Umgebungsdateien, Geheimdatenspeicher und Feature-Flags sollten an einem Ort gesammelt sein, den Betreiber ohne das Durchlesen des gesamten Systems überprüfen können. Messen Sie die Trefferquote anhand einer festgelegten Fragestellung, bevor Sie die Prompte anpassen. Eine Änderung der Prompts behebt in den meisten Fällen nicht ein schwaches Suchverhalten.
static QdrantClient openQdrant() {
return new QdrantClient(
QdrantGrpcClient.newBuilder(qdrantHost, 6334, true) // true = TLS, for Qdrant Cloud
.withApiKey(qdrantApiKey)
.build());
}
Schritt 4: Die Sammlung erstellen
Beim Bearbeiten von Schritt 4 „Phase erstellen“, notieren Sie zunächst den Vertrag: erforderliche Eingaben, Erfolgsignal sowie das Vorgehen bei teilweisen Fehlern. Diese Checkliste sorgt dafür, dass spätere Codeänderungen transparent bleiben. Dokumentieren Sie gemeinsam den erfolgreichen Ablauf sowie den Notfallweg. Wiederholte Versuche, menschliche Überprüfungen und die Handhabung von Fehlern gehören zum Produkt selbst, nicht zu späteren Optimierungen. Messen Sie die Trefferquote anhand eines festgelegten Fragekatalogs, bevor Sie die Anfragen anpassen – ein häufiges Wechseln der Anfragen behebt selten eine schwache Suchfunktion.
static void ensureCollection(QdrantClient qdrant, int vectorDimension) throws Exception {
if (qdrant.collectionExistsAsync(collectionName).get()) {
qdrant.deleteCollectionAsync(collectionName).get();
}
qdrant.createCollectionAsync(
collectionName,
VectorParams.newBuilder()
.setDistance(Distance.Cosine)
.setSize(vectorDimension)
.build()
).get();
}
Schritt 5: Embedding-Speicher erstellen
Beim Bearbeiten von Schritt 5 „Phase der Erstellung“ sollten Sie zunächst den Vertrag aufschreiben: erforderliche Eingaben, Erfolgsignal sowie das Vorgehen bei teilweisen Fehlern. Diese Checkliste sorgt dafür, dass spätere Codeänderungen transparent bleiben. Ziehen Sie kleine, testbare Einheiten vor großen Skripten vor. Wenn ein Schritt fehlschlägt, sollte der Fehler auf eine einzige Verantwortung verweisen und nicht auf ein verworrenes Ablaufschema. Messen Sie die Trefferquote anhand eines festgelegten Fragekatalogs, bevor Sie die Anfragen anpassen. Eine häufige Änderung der Anfragen behebt selten ein schwaches Suchsystem. Beim Bearbeiten von Schritt 5 „Phase der Erstellung“ sollten Sie zunächst den Vertrag aufschreiben: erforderliche Eingaben, Erfolgssignal sowie das Vorgehen bei teilweisen Fehlern. Diese Checkliste sorgt dafür, dass spätere Codeänderungen transparent bleiben. Notieren Sie die Laufzeiten sowie die Kosten pro Token oder Abfrage neben den funktionalen Ergebnissen. Eine frühzeitige Sichtbarkeit der Kosten verhindert überraschende Rechnungen, wenn der Prozess von einer Demo in gemeinsame Umgebungen übergeht.
static EmbeddingStore<TextSegment> buildStore() {
var builder = QdrantEmbeddingStore.builder()
.host(qdrantHost)
.port(qdrantPort)
.useTls(qdrantUseTls)
.collectionName(collectionName);
if (qdrantApiKey != null && !qdrantApiKey.isBlank()) {
builder.apiKey(qdrantApiKey);
}
return builder.build();
}
Schritt 6: Lesen Sie die PDF-Seite Seite für Seite
Schritt 6 „Lesen der Phase“ funktioniert am besten, wenn er als messbare Ebene betrachtet wird. Erfassen Sie einen erfolgreichen Transkriptbeispiel, einen Fehlerfall sowie die Rollback-Anmerkung, bevor Sie den Umfang erweitern. Bewahren Sie die Konfiguration außerhalb des Anwendungscode auf. Umgebungsdateien, Geheimdatenspeicher und Feature-Flags sollten an einem Ort gesammelt sein, den Betreiber überprüfen können, ohne den gesamten Ablauf durchzulesen. Trennen Sie die Chunking-Strategie von der Abrufstrategie. Ein Änderung in einer sollte nicht dazu führen, dass die andere neu geschrieben werden muss, wenn sich die Qualitätsmetriken ändern.
for (int pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
stripper.setStartPage(pageNumber);
stripper.setEndPage(pageNumber); // read just this one page
String text = stripper.getText(pdf).strip();
String extraction = "text";
if (text.length() < MIN_TEXT_CHARS_PER_PAGE) {
if (renderer == null) renderer = new PDFRenderer(pdf);
text = ocrPage(renderer, pageNumber - 1).strip(); // the page is a scan
extraction = "ocr";
}
rawText[pageNumber - 1] = text;
method[pageNumber - 1] = extraction;
}
Schritt 7: Wiederholte Überschriften und Fußzeilen entfernen
Die in Schritt 7 beschriebene wiederholte Vorgehensweise funktioniert am besten, wenn sie als messbare Oberfläche betrachtet wird. Erfassen Sie einen erfolgreichen Fall, einen Fehlerfall sowie die Notizen zur Rücksetzung, bevor Sie den Umfang erweitern. Dokumentieren Sie gleichzeitig den erfolgreichen Ablauf und den Wiederherstellungsprozess. Versuche, menschliche Überprüfungen sowie die Handhabung von Fehlern gehören zum Produkt selbst und nicht zu späteren Optimierungen. Trennen Sie die Strategie zur Aufteilung in Blöcke von der Strategie zum Abrufen. Ein Änderungsbedarf bei einer dieser Strategien sollte nicht dazu führen, dass die andere neu geschrieben werden muss, wenn sich die Qualitätsmetriken ändern.
Set<String> boilerplate = detectRepeatedLines(rawText); // lines on >= 60% of pages
String cleaned = removeBoilerplate(raw, boilerplate); // also drops "Page 3 of 5"
Schritt 8: Taggen Sie jede Seite mit ihrer Herkunft
The Step 8 Tag every stage funktioniert am besten, wenn jede Phase als messbarer Bereich betrachtet wird. Erfassen Sie vor der Erweiterung des Umfangs ein „goldenes“ Transkript, einen Fehlerfall sowie eine Notiz zur Rücksetzung. Ziehen Sie kleine, testbare Einheiten vor umfangreichen Skripten vor. Wenn ein Schritt fehlschlägt, sollte der Fehler auf eine einzige Verantwortung verweisen und nicht auf ein verworrenes Ablaufverfahren. Trennen Sie die Aufteilungspolitik von der Abrufpolitik. Eine Änderung sollte nicht dazu führen, dass die andere neu geschrieben werden muss, wenn sich die Qualitätsmetriken ändern. The Step 8 Tag every stage funktioniert am besten, wenn jede Phase als messbarer Bereich betrachtet wird. Erfassen Sie vor der Erweiterung des Umfangs ein „goldenes“ Transkript, einen Fehlerfall sowie eine Notiz zur Rücksetzung. Erhalten Sie neben den funktionalen Ergebnissen auch Aufzeichnungen zu Laufzeiten sowie Kosten für Tokens oder Abfragen. Eine frühzeitige Sichtbarkeit der Kosten verhindert überraschende Rechnungen, wenn der Prozess von einer Demo in gemeinsame Umgebungen übergeht.
Metadata metadata = new Metadata();
metadata.put("source", file.getFileName().toString());
metadata.put("page", pageNumber);
metadata.put("total_pages", totalPages);
metadata.put("extraction", method[pageNumber - 1]);
pages.add(Document.from(cleaned, metadata));
Schritt 9: Die Seiten in Blöcke aufteilen
DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
List<TextSegment> segments = new ArrayList<>();
for (Document document : documents) {
segments.addAll(splitter.split(document));
}
Schritt 10: Einbetten und in Chargen speichern
for (int start = 0; start < segments.size(); start += EMBED_BATCH_SIZE) {
List<TextSegment> batch = segments.subList(start, Math.min(start + EMBED_BATCH_SIZE, segments.size()));
List<Embedding> vectors = embeddingModel.embedAll(batch).content();
embeddingStore.addAll(vectors, batch);
}
Schritt 11: Die nächstgelegenen Blöcke abrufen
Embedding queryVector = embeddingModel.embed(question).content();
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryVector)
.maxResults(MAX_RESULTS)
.minScore(MIN_SCORE)
.build();
List<EmbeddingMatch<TextSegment>> matches = embeddingStore.search(request).matches();
Schritt 12: Den RAG-Aufruf zusammenstellen
StringBuilder input = new StringBuilder();
input.append("Answer the question using only the context below.\n");
input.append("Cite the source file and page for every fact you use.\n\n");
input.append("QUESTION:\n").append(question).append("\n\nCONTEXT:\n");
int n = 1;
for (EmbeddingMatch<TextSegment> hit : matches) {
Metadata m = hit.embedded().metadata();
String citation = m.containsKey("page") ? m.getString("source") + " p." + m.getInteger("page")
: m.getString("source");
input.append("[").append(n++).append("] ").append(citation).append("\n");
input.append(hit.embedded().text().strip()).append("\n\n");
}
Schritt 13: An Gemini übergeben
ChatModel llm = GoogleGenAiChatModel.builder()
.apiKey(System.getenv("GEMINI_API_KEY"))
.modelName("gemini-3.5-flash")
.build();
String answer = llm.chat(prompt);
System.out.println(answer.strip());
Der vollständige Code
package com.gupta.compele_rag_appl;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel;
import dev.langchain4j.model.google.genai.GoogleGenAiChatModel;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.qdrant.QdrantEmbeddingStore;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.VectorParams;
import net.sourceforge.tess4j.Tesseract;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
public class RagOverPdf {
static final String qdrantHost = env("QDRANT_CLUSTER_ENDPOINT", "localhost");
static final String qdrantApiKey = System.getenv("QDRANT_API_KEY"); // null for a local instance
static final int qdrantPort = 6334;
static final boolean qdrantUseTls = true; // true for Qdrant Cloud
static final String collectionName = "worldcup_2026";
static final Path inputFile = Path.of("C:\\code\\LangChain4jDemo\\src\\main\\resources\\pdf\\fifa-world-cup-2026-knockout-log.pdf");
static final String tessdataDir = "/usr/share/tesseract-ocr/5/tessdata";
// Indexing behavior. These are constants, not config, because they decide
// what gets embedded: change any of them and you must re-index.
static final int MAX_SEGMENT_CHARS = 900;
static final int SEGMENT_OVERLAP_CHARS = 150;
static final int EMBED_BATCH_SIZE = 64;
// Retrieval behavior.
static final int MAX_RESULTS = 2;
static final double MIN_SCORE = 0.30;
// A page that yields fewer usable characters than this is treated as scanned
// and sent to OCR. Raise it for dense layouts, lower it for sparse forms.
static final int MIN_TEXT_CHARS_PER_PAGE = 80;
static final int OCR_DPI = 300;
static final String OCR_LANGUAGE = "eng";
// Reuse one embedding model instance. It is thread safe and loading it is expensive.
static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
public static void main(String[] args) throws Exception {
if (!Files.isRegularFile(inputFile)) {
System.err.println("Input file not found: " + inputFile.toAbsolutePath()
+ "\nPut fifa-world-cup-2026-knockout-log.pdf next to this program, or edit inputFile.");
return;
}
int vectorDimension = embeddingModel.dimension();
// The LLM. This is the step the previous post left out.
ChatModel llm = GoogleGenAiChatModel.builder()
.apiKey(System.getenv("GEMINI_API_KEY"))
.modelName("gemini-3.5-flash")
.build();
try (QdrantClient qdrant = openQdrant()) {
ensureCollection(qdrant, vectorDimension);
EmbeddingStore<TextSegment> embeddingStore = buildStore();
// ---- Extract: one Document per PDF page (Tika for other formats) ----
List<Document> documents;
if (isPdf(inputFile)) {
documents = extractPdfPages(inputFile);
} else {
documents = List.of(extractWithTika(inputFile));
}
long ocrPages = documents.stream()
.filter(d -> "ocr".equals(d.metadata().getString("extraction")))
.count();
// ---- Split: chunk each Document. Metadata is copied onto every segment. ----
DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
List<TextSegment> segments = new ArrayList<>();
for (Document document : documents) {
segments.addAll(splitter.split(document));
}
System.out.printf("Split %d page(s) into %d chunks (max %d chars, overlap %d).%n",
documents.size(), segments.size(), MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
// ---- Embed and upsert in batches ----
int stored = 0;
for (int start = 0; start < segments.size(); start += EMBED_BATCH_SIZE) {
List<TextSegment> batch = segments.subList(start, Math.min(start + EMBED_BATCH_SIZE, segments.size()));
List<Embedding> vectors = embeddingModel.embedAll(batch).content();
embeddingStore.addAll(vectors, batch);
stored += batch.size();
}
System.out.printf("Ingested %s: %d page(s) (%d via OCR), %d chunks stored in '%s'.%n",
inputFile.getFileName(), documents.size(), ocrPages, stored, collectionName);
// ---- Ask a question, get an answer, repeat until 'exit' ----
System.out.println("\nAsk a question about the 2026 World Cup. Type 'exit' to quit.");
try (Scanner scanner = new Scanner(System.in)) {
while (true) {
System.out.print("\n> ");
if (!scanner.hasNextLine()) break;
String question = scanner.nextLine().strip();
if (question.isEmpty()) continue;
if (question.equalsIgnoreCase("exit") || question.equalsIgnoreCase("quit")) break;
runQuery(embeddingStore, llm, question);
}
}
System.out.println("Goodbye.");
}
}
/**
* 1. Embeds the question and retrieves the closest chunks from Qdrant,
* 2. Builds the RAG payload (question + retrieved chunks with citations),
* 3. Sends it to Gemini and prints the written answer,
* 4. Prints the sources so the answer stays checkable.
*/
static void runQuery(EmbeddingStore<TextSegment> embeddingStore, ChatModel llm, String question) {
Embedding queryVector = embeddingModel.embed(question).content();
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryVector)
.maxResults(MAX_RESULTS)
.minScore(MIN_SCORE)
.build();
List<EmbeddingMatch<TextSegment>> matches = embeddingStore.search(request).matches();
if (matches.isEmpty()) {
System.out.println("\nNothing in the document cleared the similarity threshold.");
return;
}
String prompt = buildLlmInput(question, matches);
String answer = llm.chat(prompt);
System.out.println("\n===================== ANSWER =====================");
System.out.println(answer.strip());
System.out.println("==================================================");
System.out.println("Sources:");
for (EmbeddingMatch<TextSegment> hit : matches) {
Metadata metadata = hit.embedded().metadata();
String citation = metadata.containsKey("page")
? metadata.getString("source") + " p." + metadata.getInteger("page")
: metadata.getString("source");
System.out.printf(" %.0f%% match %s%n", hit.score() * 100, citation);
}
}
/**
* Assemble the message a RAG app hands to an LLM: a short instruction, the
* user's question, and the retrieved passages, each tagged with its citation
* so the model can attribute the facts it uses.
*/
static String buildLlmInput(String question, List<EmbeddingMatch<TextSegment>> matches) {
StringBuilder input = new StringBuilder();
input.append("Answer the question using only the context below.\n");
input.append("Cite the source file and page for every fact you use.\n\n");
input.append("QUESTION:\n").append(question).append("\n\n");
input.append("CONTEXT:\n");
int n = 1;
for (EmbeddingMatch<TextSegment> hit : matches) {
Metadata metadata = hit.embedded().metadata();
String citation = metadata.containsKey("page")
? metadata.getString("source") + " p." + metadata.getInteger("page")
: metadata.getString("source");
input.append("[").append(n++).append("] ").append(citation).append("\n");
input.append(hit.embedded().text().strip()).append("\n\n");
}
return input.toString().strip() + "\n";
}
/**
* PDF path: extract page by page so page numbers survive, remove repeated
* headers and footers, and OCR any page that has no text layer.
*/
static List<Document> extractPdfPages(Path file) throws Exception {
try (PDDocument pdf = Loader.loadPDF(file.toFile())) {
int totalPages = pdf.getNumberOfPages();
String[] rawText = new String[totalPages];
String[] method = new String[totalPages];
PDFTextStripper stripper = new PDFTextStripper();
PDFRenderer renderer = null; // created lazily, only if a page needs OCR
// Pass 1: pull the raw text of each page, with OCR fallback for scans.
for (int pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
stripper.setStartPage(pageNumber);
stripper.setEndPage(pageNumber);
String text = stripper.getText(pdf).strip();
String extraction = "text";
if (text.length() < MIN_TEXT_CHARS_PER_PAGE) {
if (renderer == null) renderer = new PDFRenderer(pdf);
text = ocrPage(renderer, pageNumber - 1).strip();
extraction = "ocr";
}
rawText[pageNumber - 1] = text;
method[pageNumber - 1] = extraction;
}
// Detect lines that repeat across most pages. Those are the running
// header and footer, and they are noise, not content.
Set<String> boilerplate = detectRepeatedLines(rawText);
System.out.println("Extracting " + file.getFileName() + " (" + totalPages + " pages):");
// Pass 2: build one clean Document per page, carrying its provenance.
List<Document> pages = new ArrayList<>();
for (int pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
String raw = rawText[pageNumber - 1];
String cleaned = removeBoilerplate(raw, boilerplate);
System.out.printf(" page %d: %d chars via %s, %d chars after header/footer removal%n",
pageNumber, raw.length(), method[pageNumber - 1], cleaned.length());
if (cleaned.isBlank()) continue;
Metadata metadata = new Metadata();
metadata.put("source", file.getFileName().toString());
metadata.put("path", file.toAbsolutePath().toString());
metadata.put("page", pageNumber);
metadata.put("total_pages", totalPages);
metadata.put("extraction", method[pageNumber - 1]);
pages.add(Document.from(cleaned, metadata));
}
return pages;
}
}
/** Lines that appear on 60 percent or more of the pages are treated as running header/footer. */
static Set<String> detectRepeatedLines(String[] pages) {
Map<String, Integer> frequency = new HashMap<>();
for (String page : pages) {
Set<String> seenOnThisPage = new HashSet<>();
for (String line : page.split("\\R")) {
String trimmed = line.strip();
if (!trimmed.isEmpty() && seenOnThisPage.add(trimmed)) {
frequency.merge(trimmed, 1, Integer::sum);
}
}
}
int threshold = Math.max(2, (int) Math.ceil(pages.length * 0.6));
Set<String> boilerplate = new HashSet<>();
for (Map.Entry<String, Integer> entry : frequency.entrySet()) {
if (entry.getValue() >= threshold) boilerplate.add(entry.getKey());
}
return boilerplate;
}
/** Drop the detected boilerplate lines plus per-page markers like "Page 3 of 5". */
static String removeBoilerplate(String text, Set<String> boilerplate) {
StringBuilder kept = new StringBuilder();
for (String line : text.split("\\R")) {
String trimmed = line.strip();
if (trimmed.isEmpty()) continue;
if (boilerplate.contains(trimmed)) continue;
if (trimmed.matches("(?i)page \\d+ of \\d+")) continue;
kept.append(trimmed).append("\n");
}
return kept.toString().strip();
}
/** Render one PDF page to an image and read it with Tesseract. */
static String ocrPage(PDFRenderer renderer, int pageIndex) throws Exception {
BufferedImage image = renderer.renderImageWithDPI(pageIndex, OCR_DPI, ImageType.RGB);
Tesseract tesseract = new Tesseract();
tesseract.setDatapath(tessdataDir);
tesseract.setLanguage(OCR_LANGUAGE);
return tesseract.doOCR(image);
}
/**
* Office and text formats: Apache Tika auto detects and parses almost anything.
* Tika does not expose reliable page numbers, so these are cited by source file.
* Note: ApacheTikaDocumentParser is stateful, so build a fresh one per file.
*/
static Document extractWithTika(Path file) {
Document document = FileSystemDocumentLoader.loadDocument(file, new ApacheTikaDocumentParser());
Metadata metadata = document.metadata();
metadata.put("source", file.getFileName().toString());
metadata.put("path", file.toAbsolutePath().toString());
metadata.put("extraction", "text");
return document;
}
// ---- Qdrant plumbing ----
static QdrantClient openQdrant() {
QdrantClient client = new QdrantClient(QdrantGrpcClient.newBuilder(qdrantHost,
6334,
true)
.withApiKey(qdrantApiKey)
.build());
return client;
}
/** Create the collection sized to the embedding model. Optionally wipe it first. */
static void ensureCollection(QdrantClient qdrant, int vectorDimension) throws Exception {
if (qdrant.collectionExistsAsync(collectionName).get()) {
qdrant.deleteCollectionAsync(collectionName).get();
}
qdrant.createCollectionAsync(
collectionName,
VectorParams.newBuilder()
.setDistance(Distance.Cosine)
.setSize(vectorDimension)
.build()
).get();
}
static EmbeddingStore<TextSegment> buildStore() {
var builder = QdrantEmbeddingStore.builder()
.host(qdrantHost)
.port(qdrantPort)
.useTls(qdrantUseTls)
.collectionName(collectionName);
if (qdrantApiKey != null && !qdrantApiKey.isBlank()) {
builder.apiKey(qdrantApiKey);
}
return builder.build();
}
// ---- helpers ----
static boolean isPdf(Path file) {
return file.getFileName().toString().toLowerCase().endsWith(".pdf");
}
static String env(String key, String fallback) {
String value = System.getenv(key);
return (value == null || value.isBlank()) ? fallback : value;
}
}
Ausführen
> Why did Egypt not win the 2026 World Cup?
===================== ANSWER =====================
Egypt were knocked out in the Round of 16: they lost 3-2 to Argentina in
Atlanta on 7 July 2026, after beating Australia on penalties in the Round
of 32. So they were out before the quarterfinals.
[fifa-world-cup-2026-knockout-log.pdf p.5, p.3]
==================================================
Sources:
71% match fifa-world-cup-2026-knockout-log.pdf p.5
69% match fifa-world-cup-2026-knockout-log.pdf p.3