Accueil / Articles

Pas d’histoires. Seulement le code minimal nécessaire pour créer un RAG fonctionnel.

Le code minimal pour une application RAG fonctionnelle, sans fioritures : des clés, des dépendances, un modèle d’embedding, Qdrant hébergé pour les vecteurs et Gemini pour générer les réponses.

Éditeur : REACTAPP.TOP13 min de lecture2995 mots

Sources: No stories. Just the minimal code to build a working RAG application

Utilisez ceci comme une version révisée destinée aux opérateurs des idées présentées dans « Aucune histoire. Seulement le code minimal nécessaire pour créer une application RAG fonctionnelle » : étapes claires, emplacements de code ordonnés et notes de récupération qui survivent au transfert de responsabilités. L’étape « Aperçu » fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un enregistrement exemplaire, un cas d’échec et la note de réversion avant d’élargir le périmètre. Enregistrez les temps d’exécution ainsi que le coût en tokens ou requêtes à côté des résultats fonctionnels. Une visibilité précoce des coûts évite les factures inattendues lorsque le projet passe de la démonstration aux environnements partagés.

Étape 0 : Clés

Pour l’étape 0 des clés, définissez les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Conservez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans avoir à lire l’ensemble du système. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

export QDRANT_CLUSTER_ENDPOINT="YOUR-CLUSTER.cloud.qdrant.io"
export QDRANT_API_KEY="..."
export GEMINI_API_KEY="..."

Étape 1 : Dépendances

Pour l’étape des dépendances du Pas 1, définitz les entrées, le responsable de l’étape et les critères de fin avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Documentez ensemble le parcours idéal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie du produit, et non d’une mise en forme ultérieure. Citez les passages qui ont réellement servi de base à la réponse. Sans citations, les opérateurs ne peuvent pas distinguer une hallucination d’un manque d’indexation.

<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>

Pas 2 : Le modèle d’incorporation

Pour l’étape 2, la phase d’incorporation, définissez les entrées, le responsable de l’étape et les critères d’arrêt avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Préférez des unités petites et testables plutôt que des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité et non un processus embrouillé. Préférez des sorties structurées avec validation de schéma plutôt que du texte libre lorsque l’étape suivante consiste en du code ou une appel à outil. Pour l’étape 2, la phase d’incorporation, définissez les entrées, le responsable de l’étape et les critères d’arrêt avant de modifier le code. Les opérateurs doivent pouvoir relancer l’étape à partir d’un point de contrôle connu sans deviner l’état caché. Enregistrez les temps d’exécution ainsi que le coût en tokens ou requêtes à côté des résultats fonctionnels. Une visibilité précoce des coûts évite des factures inattendues lorsque le processus passe d’un environnement de démonstration à des environnements partagés.

static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();

int vectorDimension = embeddingModel.dimension();   // 384

Étape 3 : Se connecter à Qdrant hébergé

Lors de l’exécution de l’étape 3 « Se connecter », notez d’abord les exigences : entrées requises, signal de succès et conséquences en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Conservez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les administrateurs peuvent auditer sans devoir lire l’ensemble du système. Mesurez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Un changement fréquent des prompts ne résout que rarement un système de récupération insuffisant.

static QdrantClient openQdrant() {
    return new QdrantClient(
        QdrantGrpcClient.newBuilder(qdrantHost, 6334, true)   // true = TLS, for Qdrant Cloud
                        .withApiKey(qdrantApiKey)
                        .build());
}

Étape 4 : Créer la collection

Lors de l’exécution de l’Étape 4 « Créer la phase », notez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Documentez ensemble le parcours normal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non traités font partie intégrante du produit, et non d’améliorations apportées ultérieurement. Mesurez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Un changement fréquent des prompts ne résout que rarement un système de récupération insuffisant.

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();
}

Étape 5 : Construire le stockage d’embeddings

Lors de l’exécution de l’Étape 5 « Construire la phase », notez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Préférez des unités petites et testables plutôt que des scripts complexes. Lorsqu’une étape échoue, l’échec doit indiquer une seule responsabilité et non un processus embrouillé. Mesurez le taux de rappel sur un ensemble fixe de questions avant d’ajuster les prompts. Un changement fréquent de prompts ne résout que rarement un système de récupération insuffisant. Lors de l’exécution de l’Étape 5 « Construire la phase », notez d’abord le contrat : les entrées requises, le signal de succès et ce qui se passe en cas d’échec partiel. Cette liste de contrôle permet de rester honnête lors des modifications ultérieures du code. Enregistrez les temps d’exécution ainsi que le coût en tokens ou requêtes à côté des résultats fonctionnels. Une visibilité précoce des coûts évite les factures inattendues lorsque le système passe de l’environnement de démonstration à des environnements partagés.

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();
}

Étape 6 : Lire la page PDF page par page

La étape 6 « Lire l’état » fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript parfait, un cas d’échec et la note de réversion avant d’élargir le périmètre. Conservez la configuration en dehors du code de l’application. Les fichiers d’environnement, les bases de données secrètes et les indicateurs fonctionnels doivent être regroupés en un seul endroit que les opérateurs peuvent auditer sans devoir lire l’ensemble du système. Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

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;
}

Étape 7 : Supprimer les en-têtes et pieds de page répétés

La méthode en étapes répétées de l’Étape 7 fonctionne le mieux lorsqu’elle est considérée comme une surface mesurable. Capturez un transcript parfait, un cas d’échec et la note de réversion avant d’élargir le périmètre. Documentez ensemble le parcours optimal et le parcours de récupération. Les tentatives répétées, les contrôles humains et la gestion des messages non livrés font partie intégrante du produit, et non d’une mise en forme ultérieure. Séparez la politique de segmentation de la politique de récupération : modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent.

Set<String> boilerplate = detectRepeatedLines(rawText);   // lines on >= 60% of pages
String cleaned = removeBoilerplate(raw, boilerplate);     // also drops "Page 3 of 5"

Étape 8 : Marquez chaque page indiquant d’où elle provient

The Step 8 Tag every stage fonctionne le mieux lorsqu’il est considéré comme une surface mesurable. Capturez un exemple idéal, un cas d’échec et la note de réversion avant d’élargir le périmètre. Préférez des unités petites et testables aux scripts complexes. Lorsqu’une étape échoue, l’erreur doit indiquer une seule responsabilité plutôt qu’un processus embrouillé. Séparez la politique de segmentation de la politique de récupération. Modifier l’une ne doit pas obliger à réécrire l’autre lorsque les métriques de qualité évoluent. The Step 8 Tag every stage fonctionne le mieux lorsqu’il est considéré comme une surface mesurable. Capturez un exemple idéal, un cas d’échec et la note de réversion avant d’élargir le périmètre. Enregistrez les temps d’exécution ainsi que le coût en tokens ou requêtes à côté des résultats fonctionnels. Une visibilité précoce des coûts évite les factures inattendues lorsque le processus passe de la démonstration aux environnements partagés.

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));

Étape 9 : Segmenter les pages

DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
List<TextSegment> segments = new ArrayList<>();
for (Document document : documents) {
    segments.addAll(splitter.split(document));
}

Étape 10 : Incorporer et stocker par lots

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);
}

Étape 11 : Récupérer les blocs les plus proches

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();

Étape 12 : Assembler le prompt 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");
}

Étape 13 : Le transmettre à 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());

Le code complet

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;
    }
}

Le lancer

> 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

Résumé

Liste de contrôle opérationnelle