首页 / 文章

无需故事情节,只需最简代码即可构建可运行的RAG系统。

一个可运行的RAG应用的最简代码,无需多余解释:包括密钥、依赖项、嵌入模型,以及用于存储向量的Qdrant服务和用于生成答案的Gemini模型。

发布者:REACTAPP.TOP阅读约 13 分钟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:依赖关系

在第一步的依赖关系阶段,应在修改代码之前明确输入内容、该步骤的负责人以及结束标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。 需同时记录正常流程和异常恢复流程。重试机制、人工审核环节以及错误处理都是产品本身的组成部分,而非后续需要补充的功能。 必须引用实际作为答案依据的段落。如果没有引用,操作人员就无法区分是虚假信息还是索引缺失导致的错误。

<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的嵌入阶段,修改代码之前需明确输入参数、该步骤的负责人以及终止标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。 除了功能结果外,还需记录执行时间以及令牌或查询成本。提前了解成本情况,可避免在从演示环境过渡到共享环境时出现意外费用。

static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();

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

步骤3:连接到托管的Qdrant

在执行“连接到”这一步骤时,首先记下相关契约:所需的输入参数、成功信号以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 将配置信息置于应用程序代码之外。环境文件、密钥存储以及功能标志应集中存放,这样操作人员无需查看整个系统结构即可进行审计。 在调整提示词之前,先使用固定的问题集来测试召回率。仅仅更换提示词往往无法解决检索效果不佳的问题。

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步“构建阶段”时,首先需明确相关约定:所需输入、成功信号以及部分失败时的处理方式。这份清单能确保后续的代码修改保持一致性。 在功能结果旁记录执行时间以及token或查询成本。提前了解成本情况,可避免从演示环境过渡到共享环境时出现意外费用。

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

将第6步“阅读阶段”视为可度量的对象来处理效果最佳。在扩大范围之前,先记录一个成功的案例、一个失败案例以及回滚说明。 将配置与应用程序代码分开。环境文件、密钥存储和功能标志应集中存放于一处,这样操作人员无需查看整个架构即可进行审计。 将分块策略与检索策略分开。当质量指标发生变化时,修改其中一项不应强制要求重新编写另一项。

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步:去除重复的页眉和页脚

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

总结

操作检查清单