Bez historii. Tylko minimalny kod potrzebny do stworzenia działającego RAG.
Minimalny kod dla działającego aplikacji RAG, bez zbędnych szczegółów: klucze, zależności, model embeddingowy, Qdrant do przechowywania wektorów oraz Gemini do generowania odpowiedzi.
Źródła: No stories. Just the minimal code to build a working RAG application
Niech to służy jako wersja przeznaczona dla operatorów, zawierająca idee z tekstu „Brak historii. Tylko minimalny kod potrzebny do stworzenia działającego aplikacji RAG”: wyraźne etapy, uporządkowane sekcje kodu oraz notatki dotyczące napraw, które przetrwają przeniesienie projektu. Etap Przeglądu działa najlepiej, gdy traktowany jest jako mierzalna powierzchnia do analizy. Zapisz jeden idealny zapis działania, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian, zanim rozszerzysz zakres pracy. Zapisuj czasy wykonywania zadań oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega nieoczekiwanym rachunkom, gdy przechodzi się od wersji demonstracyjnej do środowisk współdzielonych.
Krok 0: Klucze
W fazie Kluczy Kroku 0 należy zdefiniować dane wejściowe, osobę odpowiedzialną za krok oraz kryteria zakończenia przed modyfikacją kodu. Operatorzy powinni móc ponownie uruchomić krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Konfigurację należy przechowywać poza kodem aplikacji. Pliki środowiskowe, magazyny tajnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności przeglądania całej struktury. Należy podawać fragmenty tekstu, które faktycznie stanowią podstawę odpowiedzi. Bez tych odniesień operatorzy nie będą w stanie odróżnić halucynacji od luki w indeksowaniu.
export QDRANT_CLUSTER_ENDPOINT="YOUR-CLUSTER.cloud.qdrant.io"
export QDRANT_API_KEY="..."
export GEMINI_API_KEY="..."
Krok 1: Zależności
W fazie Zależności kroku 1 należy zdefiniować dane wejściowe, osobę odpowiedzialną za dany krok oraz kryteria zakończenia przed zmianą kodu. Operatorzy powinni móc ponownie uruchomić dany krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Należy udokumentować zarówno prawidłowy przebieg działania, jak i ścieżkę naprawczą. Próby ponownych działań, kontrolne punkty ludzkie oraz obsługa wiadomości błędnych stanowią część produktu, a nie elementy dodawane później. Należy podać fragmenty tekstu, które faktycznie stanowią podstawę odpowiedzi. Bez tych odniesień operatorzy nie będą w stanie odróżnić halucynacji od luki w indeksowaniu.
<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>
Krok 2: Model embeddingowy
W etapie wplecania z drugiego kroku należy przed zmianą kodu określić dane wejściowe, osobę odpowiedzialną za ten krok oraz kryteria zakończenia. Operatorzy powinni móc ponownie uruchomić ten krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Należy preferować małe, testowalne jednostki zamiast rozbudowanych skryptów. Gdy krok się nie powiedzie, przyczyna powinna odnosić się do konkretnej odpowiedzialności, a nie do skomplikowanego łańcucha operacji. W przypadku, gdy następnym krokiem jest kod lub wywołanie narzędzia, lepsze są ustrukturyzowane wyniki z walidacją schematu niż tekst w formie swobodnej. W etapie wplecania z drugiego kroku należy przed zmianą kodu określić dane wejściowe, osobę odpowiedzialną za ten krok oraz kryteria zakończenia. Operatorzy powinni móc ponownie uruchomić ten krok na podstawie znanego punktu kontrolnego, bez konieczności zgadywania ukrytego stanu. Obok wyników funkcjonalnych należy rejestrować czas wykonywania oraz koszt tokenów lub zapytań. Wczesna widoczność kosztów zapobiega niespodziewanym rachunkom, gdy przechodzi się z środowiska demonstracyjnego do współdzielonych środowisk.
static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
int vectorDimension = embeddingModel.dimension(); // 384
Krok 3: Połączenie z hostowanym Qdrant
Podczas pracy nad etapem 3 „Połączenie”, najpierw zapisz specyfikację: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna pomaga zachować uczciwość przy późniejszych zmianach w kodzie. Konfigurację należy przechowywać oddzielnie od kodu aplikacji. Pliki środowiskowe, magazyny haseł oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności czytania całej struktury. Zmierz stopień odzyskiwania informacji na ustalonej grupie pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko poprawiają słabą efektywność wyszukiwania.
static QdrantClient openQdrant() {
return new QdrantClient(
QdrantGrpcClient.newBuilder(qdrantHost, 6334, true) // true = TLS, for Qdrant Cloud
.withApiKey(qdrantApiKey)
.build());
}
Krok 4: Stworzenie kolekcji
Gdy przechodzisz do kroku 4 „Stworzenie etapu”, najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Zdokumentuj zarówno ścieżkę prawidłowego działania, jak i ścieżkę naprawczą. Próby ponownych działań, kontrolne punkty ludzkie oraz obsługa wiadomości błędnych stanowią część produktu, a nie elementy dodawane później w celu udoskonalenia. Zmierz stopień odzyskiwania informacji na ustalonej grupie pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko naprawiają słabe możliwości wyszukiwania.
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();
}
Krok 5: Budowa magazynu embeddingów
Gdy przechodzisz przez etap 5 „Budowanie”, najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Wolno preferować małe, testowalne jednostki zamiast rozbudowanych skryptów. Gdy jakiś krok się nie powiedzie, błąd powinien wskazywać na konkretną odpowiedzialność, a nie na skomplikowany proces. Zmierz stopień przywoływania informacji na ustalonej serii pytań przed dostosowywaniem promptów. Częste zmiany promptów rzadko naprawiają słabe możliwości wyszukiwania. Gdy przechodzisz przez etap 5 „Budowanie”, najpierw zapisz umowę: wymagane dane wejściowe, sygnał sukcesu oraz to, co dzieje się w przypadku częściowego niepowodzenia. Taka lista kontrolna zapewnia uczciwość późniejszych zmian w kodzie. Zapisz czasy wykonywania oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega niespodziewanym rachunkom, gdy przechodzi się z środowiska demonstracyjnego do wspólnych środowisk.
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();
}
Krok 6: Czytaj stronę PDF strona po stronie
Krok 6 – Czytanie etapu działa najlepiej, gdy traktuje się go jako mierzalną powierzchnię. Zapisz jeden idealny przepis, jeden przypadek awarii oraz notatkę o cofnięciu zmian, zanim rozszerzysz zakres. Przechowuj konfigurację poza kodem aplikacji. Pliki środowiskowe, składysek tajnych danych oraz flagi funkcjonalne powinny znajdować się w jednym miejscu, które operatorzy mogą sprawdzić bez konieczności przeglądania całej struktury. Oddziel zasadę dzielenia na fragmenty od zasady pobierania danych. Zmiana jednej nie powinna zmuszać do przepisywania drugiej, gdy zmieniają się metryki jakości.
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;
}
Krok 7: Usuń powtarzające się nagłówki i stopki
Procedura powtarzania etapu nr 7 działa najlepiej, gdy traktuje się ją jako mierzalną powierzchnię. Zapisz jeden przykład udanego działania, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia działań, zanim rozszerzysz zakres. Zdokumentuj zarówno ścieżkę udanego działania, jak i ścieżkę przywracania do stanu poprzedniego. Próby ponownych działań, kontrolne punkty ludzkie oraz obsługa wiadomości błędowych stanowią część produktu, a nie elementy dodawane później. Oddziel zasadę dzielenia na fragmenty od zasady wyszukiwania. Zmiana jednej z nich nie powinna zmuszać do przepisywania drugiej w przypadku zmian wskaźników jakości.
Set<String> boilerplate = detectRepeatedLines(rawText); // lines on >= 60% of pages
String cleaned = removeBoilerplate(raw, boilerplate); // also drops "Page 3 of 5"
Etap 8: Oznacz każdą stronę informacją o jej pochodzeniu
The Step 8 Tag every stage funkcjonuje najlepiej, gdy jest traktowany jako mierzalna powierzchnia. Zapisz jeden idealny przepis działania, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian, zanim rozszerzysz zakres. Wolno preferować małe, testowalne jednostki zamiast rozbudowanych skryptów. Gdy jakaś etapa zawiedzie, awaria powinna wskazywać na konkretną odpowiedzialność, a nie na skomplikowany proces. Oddziel zasadę dzielenia na fragmenty od zasady pobierania danych. Zmiana jednej z nich nie powinna zmuszać do przepisywania drugiej, gdy zmieniają się metryki jakości. The Step 8 Tag every stage funkcjonuje najlepiej, gdy jest traktowany jako mierzalna powierzchnia. Zapisz jeden idealny przepis działania, jeden przypadek awarii oraz notatkę dotyczącą cofnięcia zmian, zanim rozszerzysz zakres. Zapisuj czasy wykonywania oraz koszt tokenów lub zapytań obok wyników funkcjonalnych. Wczesna widoczność kosztów zapobiega niespodziewanym rachunkom, gdy proces przechodzi z wersji demonstracyjnej do środowisk współdzielonych.
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));
Krok 9: Dziel strony na fragmenty
DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
List<TextSegment> segments = new ArrayList<>();
for (Document document : documents) {
segments.addAll(splitter.split(document));
}
Krok 10: Wbudowanie i przechowywanie w partiach
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);
}
Krok 11: Pobranie najbliższych fragmentów
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();
Krok 12: Skomponowanie promptu RAG
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");
}
Krok 13: Przekazanie go Gemini
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());
Pełny kod
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;
}
}
Zapuszczenie go
> 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