Галоўная / Артыкулы

Без гісторый: мінімальны код для працоўнага RAG

Мінімальны код для працоўнай RAG-праграмы без лішніх слоў: ключы, залежнасці, мадэль эмбедынгаў, Qdrant у воблаку для вектараў і Gemini для адказаў.

Выдавец: REACTAPP.TOP13 хв чытання2995 слоў

Крыніцы: No stories. Just the minimal code to build a working RAG application

Існавайце гэта як перапрацоўку ідэй з документа “Няма апавяданняў. Толькі мінімальны код для стварэння працюючага RAG-прыемліка”: чыстыя этапы, аранжаваныя блакі для коду і прыметкі з восстанавлення, якія застаюцца пасля перадачы. Этап “Апглед” найэфектывней працюе, калі яго спрыяваць як вімерную плошчу. Запісайце адна ідэальная транскрыпцыю, адзін прыклад неудачы і прыметку з вярненням да пачатковага стану прычаму расшырэння масштаба. Запісвайце часы выканання і кост токенав або запытаў праза функцыйнае рэзультат. Відкрытыя даныя пра косцы з’являюцца ранішэ, чым утвараюцца неспадзянаныя рахункі, калі працэс пераходзіць з дамавайна да спяльных сэрвісоў.

Шаг 0: Ключы

Для этапы Ключоў кроку 0 неабходна пазначыць вхідныя даны, адпаведальнага за крок і крэтыяры завершэння пры перадзеіснавленні коду. Аператары должны магчымае запускаваць крок з вядомай точкі контролю, не прабуючы спадарацца пра схованы стан. Конфігурацыю трэба залічыць паза кодам прыкладнення. Файлы сераўнавання, хранільнікі секрэтных даных і флагі функцыйяў должны знаходзіцца ў аднам месцы, якое аператары можаць пераглядаць, не чытаяўшы весь структураны код. Паказваць трэба тыя часткі тэксту, якія фактычна ляглі в основу адпаведнай адказы. Без цых цітатаў аператары не можаць розразліць галюцынацію ад прычыны, зв’язанай з недастаткам індэксацыі.

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

Крок 1: Залежнасці

Для этапы Залежнасцяў крока 1 неабходна прадзефінавацыя вхідных дадзеных, адпаведнага адпаведальнага за крок і крэатарыяў завершэння працы перад зменым коду. Аперацыйныя працавнікі должны магчымае запускаць крок з вядомай точкі контролю, не падозрываючы прыхованы стан. Неабходна задокументаваць як шлях успеху, так і шлях вярнення да нормальнага стану. Перапрыбуткі, людзкія перакрыцця і апрацоўка некорэктных паведамленняў ёсць часткай продукту, а не чымсь, што дадаецца пазней. Паказваць трэба тыя часткі тэксту, якія фактычна лежаць у падставе адпаведнай адказы. Без ціх цытатаў аперацыйныя працавнікі не зможуць адразніць галюцынацыю ад працявання з непаштоўнымі дадзенымі.

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

Крок 2: Мадэль імбеддынгу

Для этапы вбудоввання (Крок 2) неабяжна ўзначыць вхідныя даны, адпаведальнага за крок і крэтыяры завершэння пры перадзеі коду. Аперацыйныя працавнікі павінны магчымае перазапускаць крок з вядомай точкі контролю, не падозрываючы прыхованы стан. Лепш выбіраць маленькія, тэставаныя елементы замест большых скрыптов. Калі крок не выйшае, прычына неудачы павінна вказываць на адзін конкрэтны аспект, а не на заплутаны ланцюг задач. Калі наступны крок — гэта код або вызов інструмента, лепш выкарыстоўваць структураваныя выходныя даны з пераканальваннем схэмы, замест вольнага фармата тэксту. Для этапы вбудоввання (Крок 2) неабяжна ўзначыць вхідныя даны, адпаведальнага за крок і крэтыяры завершэння пры перадзеі коду. Аперацыйныя працавнікі павінны магчымае перазапускаць крок з вядомай точкі контролю, не падозрываючы прыхованы стан. Запісваць час выканання і кост токеноў або запытаў разам з функцыйнальнымі рэзультатамі. Відразувыя даны пра косцы запобегаюць неспакоўным рахункам, калі працэс пераходзіць з дэмавай версіі у спяльныя сераўы.

static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();

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

Шаг 3: З’яўленне з хоставаным Qdrant

Калі працюеце над шагам 3 «З’яўленне», спачатку запішыце умовы взаўмадзейнасці: неабходныя даны, сігнал успеху і тое, што відбываецца у разы частковага невялікога браку. Такі спіс дапамагае залішыцца адкрытым пад час пазнейшых змян у кодзе. Хавайце настройкі парадульна ад коду прыемліка. Файлы сераўіса, хранільнікі секрэтных дадзеных і флагі функцыйяў должны знаходзіцца ў аднам месцы, куды аператары можаць пераглядаць іх без неабходнасці чытання всей структуры. Перад налаштаваннем запітоў пераканайцеся ў рывні відтворэння дадзеных на фіксаваным наборэ запітаў. Частая зміна запітоў рэдка калі вялікім чынам павышае якасць пошуку.

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

Шаг 4: Створэнне калекцыі

Калі працюеце над 4-м крокам «Стварыць стэйдж», спачатку запісайце умовы контракту: неабяжлівыя даны, сігнал успеху і тое, што выканаецца у разе частковага нявыполнення. Такі спіс пераканаець у тым, што пазнейшыя змены коду будуць адгукавацца да правіл.

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

5-й крок: Стварыць хранілню эмбеджаў

Калі працуеце над крокам 5 «Стварэнне этапы», спачатку запісайце умовы вярбунка: неабяжлівыя даныя, сігнал успеху і тое, што выканаецца у разе частковага нявыполнення. Такі спіс контроля дапамагае заліцварыць пазнейшыя змены ў кодзе. Валіце маленькія, тэставаныя елементы замест большых скрыптов. Калі якісь крок не выйшоў, прычына нявыполнення павінна вказываць на адну конкрэтную адпаведальнасць, а не на заплутаны ланцюг задач. Перад налаштаваннем запітоў пераканайцеся, наскількі эфектыва сістэма адпаведзядае на фіксаваны набор запітаў. Частыя змены запітоў рэдка калі вядуць да павышэння якосці адпаведзенняў. Калі працуеце над крокам 5 «Стварэнне этапы», спачатку запісайце умовы вярбунка: неабяжлівыя даныя, сігнал успеху і тое, што выканаецца у разе частковага нявыполнення. Такі спіс контроля дапамагае заліцварыць пазнейшыя змены ў кодзе. Запісвайце час выканання і вартасьць токеноў або запітаў разам з функцыональнымі рэзултатамі. Відразувыя даны пра вартасьці запобегаюць неспакойным рахункам, калі праця пераходзіць з дэмавайнага рэжыма ў спяльныя асяроддзя.

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

Крок 6: Чытайце PDF сторунку за сторункам

Шэсціцы параграф «Чытанне стадзіі» работае наяўней, калі яго спрыяваць як мерыемую паверхню. Зафіксавайце адна «золатая» транскрыпцыю, адын прыклад неудачы і запіс пра вярнэнне да пачатковага стану перш чым расшырваць масштаб. Зберагаюце настройкі пазырочна ад коду прыемліка. Файлы сяродавішча, хранільнікі секрэтных дадзеных і пазнакі функцый должны знаходзіцца ў адном месцы, куда аператары можуць адбавляць аўдыт без неабяжнага чытання всіх дадзеных. Раздзеляйце правілы часткавання і правілы выявлення. Змена адных не должна прыводзіць да перапісву іншых, калі змянююцыся паказчыкі якосці.

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

Шэпт сям: Адзьёмка павтараючыхся заголовкіў і наголоўкаў

Працэс, які паветрацуецца на 7-му кроцы, працюе найэфектыўней, калі яго расследжваць як мерыемую паверхню. Запісаце адна «золатая» транскрыпцыя, адзін прыклад неудачы і прыметку па адвярненню перад расшырэнням масштаба. Дакументавайце як успішны, так і варыянт вяселення. Перапрыбуткі, людзкі контроль і обработка некорэктных паведамленняў ёсць часткай продукту, а не пасляднім дапрацоўкам. Раздзеліце правілы частковай обработкі і правілы выкарыстоўвання дакументаў. Змена аднаго з іх не должна вымагаць перапісвання другога, калі зменяюцца паказнікі якосці.

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

8-й крок: Прызначыце атрыбут кожнай сторонцы з пазначэнням ўзроку яе стварэння

Метод «Step 8 Tag every stage» працюе наякша, калі кожны этап спрыглядаецца як вімерна плошча. Перад расшырэнням масштаба зафіксавайце адны ідэальны прымер працы, адну справу з бягамі і прыметку па адварцыю. Валіце маленькія, тэставаныя елементы замест большых скрыптов. Калі якісь крок не выйшаў, прычына бягу должна вказываць на адну конкрэтную адпаведальнасць, а не на заплутаны ланцужок задач. Раздзеляйце правілы часткавання інфармацыі ад правіл яе выкарыстоўвання. Змена адных не должна прымусваць перапісвання іншых, калі змянююцца паказнікі якосці. Метод «Step 8 Tag every stage» працюе наякша, калі кожны этап спрыглядаецца як вімерна плошча. Перад расшырэнням масштаба зафіксавайце адны ідэальны прымер працы, адну справу з бягамі і прыметку па адварцыю. Запісвайце час выканання і вартасьць токеноў чы апыткаў па боку функцыйнальных рэзультатаў. Відразлівасьць вартасцей з самага пачатку запобегае неспакойным рахункам, калі працэс пераходзіць з дэмаверсіі ў спяльныя сераўысы.

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

Шаг 9: Разбіць сторункі на часткі

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

Шаг 10: Умякчэнне і зберыцеўка пакетамі

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

Шаг 11: Адзысканне найбліжэйшых фрагментаў

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

Шаг 12: Складанне запросу 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");
}

Шаг 13: Перадача запросу 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());

Полны код

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

Запуск

> 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

Апূর্ণанне

Чэрніця кантролю эксплуатацыі