Sin historias. Solo el código mínimo necesario para crear un RAG funcional.
El código mínimo para una aplicación RAG funcional, sin rodeos: claves, dependencias, un modelo de embedding, Qdrant alojado para vectores y Gemini para generar las respuestas.
Fuentes: No stories. Just the minimal code to build a working RAG application
Úselo como una versión reestructurada dirigida a los operadores de las ideas presentadas en “Sin historias. Solo el código mínimo para crear una aplicación RAG funcional”: etapas claras, espacios ordenados para el código y notas de recuperación que perduran tras la transferencia de responsabilidades. La etapa de Resumen funciona mejor cuando se trata como una superficie medible. Capture una transcripción clave, un caso de fallo y la nota de reversión antes de ampliar el alcance. Registre los tiempos y el costo en tokens o consultas junto a los resultados funcionales. La visibilidad temprana del costo evita facturas inesperadas cuando el proceso pasa de la demostración a entornos compartidos.
Paso 0: Claves
En la fase de Claves Paso 0, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Mantenga la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de datos secretos y las banderas de funcionalidad deben estar en un lugar donde los operadores puedan auditarlos sin necesidad de leer todo el sistema. Cite los pasajes que realmente sustentan la respuesta. Sin citaciones, los operadores no pueden distinguir entre alucinaciones y lagunas en el indexado.
export QDRANT_CLUSTER_ENDPOINT="YOUR-CLUSTER.cloud.qdrant.io"
export QDRANT_API_KEY="..."
export GEMINI_API_KEY="..."
Paso 1: Dependencias
En la fase de Dependencias del Paso 1, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Documente tanto la ruta óptima como la ruta de recuperación. Las reintentos, los controles humanos y el manejo de mensajes no entregados forman parte del producto, no son mejoras posteriores. Cite los pasajes que realmente sustentan la respuesta. Sin citas, los operadores no pueden distinguir entre alucinaciones y brechas en el indexado.
<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>
Paso 2: El modelo de embedding
En la etapa de incrustación del Paso 2, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando un paso falla, el error debe indicar una única responsabilidad y no un proceso complicado. Prefiera salidas estructuradas con validación de esquema en lugar de texto libre cuando el paso siguiente sea código o una llamada a una herramienta. En la etapa de incrustación del Paso 2, defina las entradas, el responsable del paso y los criterios de finalización antes de modificar el código. Los operadores deben poder volver a ejecutar el paso a partir de un punto de control conocido sin tener que adivinar el estado oculto. Registre los tiempos de ejecución y el costo en tokens o consultas junto con los resultados funcionales. La visibilidad temprana del costo evita facturas inesperadas cuando el proceso pasa de entornos de demostración a entornos compartidos.
static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
int vectorDimension = embeddingModel.dimension(); // 384
Paso 3: Conectarse a Qdrant alojado
Al trabajar en la fase de Conexión del Paso 3, anote primero el contrato: las entradas requeridas, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación ayuda a mantener honestas las futuras modificaciones del código. Guarde la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de secretos y las banderas de funcionalidad deben estar en un lugar donde los operadores puedan auditarlos sin tener que leer todo el sistema. Mida la tasa de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.
static QdrantClient openQdrant() {
return new QdrantClient(
QdrantGrpcClient.newBuilder(qdrantHost, 6334, true) // true = TLS, for Qdrant Cloud
.withApiKey(qdrantApiKey)
.build());
}
Paso 4: Crear la colección
Al trabajar en el Paso 4, Crear la etapa, anote primero el contrato: los datos de entrada requeridos, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación ayuda a mantener honestas las futuras modificaciones del código. Documente junto con ello el camino óptimo y el camino de recuperación. Las reintentos, los controles humanos y el manejo de mensajes no entregados forman parte del producto, no son mejoras posteriores. Mida la capacidad de recuperación con un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente.
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();
}
Paso 5: Construir el almacén de embeddings
Al trabajar en la Etapa 5, “Construir la plataforma”, anote primero el contrato: las entradas requeridas, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación garantiza que los cambios posteriores en el código sean transparentes. Prefiera unidades pequeñas y verificables a scripts extensos. Cuando un paso falla, el fallo debe apuntar a una única responsabilidad y no a un proceso complicado. Mida el rendimiento en un conjunto fijo de preguntas antes de ajustar los prompts. El cambio constante de prompts rara vez soluciona un sistema de recuperación deficiente. Al trabajar en la Etapa 5, “Construir la plataforma”, anote primero el contrato: las entradas requeridas, la señal de éxito y qué ocurre en caso de fallo parcial. Esa lista de verificación garantiza que los cambios posteriores en el código sean transparentes. Registre los tiempos de ejecución y el costo en tokens o consultas junto con los resultados funcionales. Tener visibilidad del costo desde el principio evita facturas inesperadas cuando se pasa de entornos de demostración a entornos compartidos.
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();
}
Etapa 6: Leer la página del PDF página por página
La etapa 6, “Leer la fase”, funciona mejor cuando se trata como una superficie medible. Capture un registro exitoso, un caso de fallo y la nota de reversión antes de ampliar el alcance. Mantenga la configuración fuera del código de la aplicación. Los archivos de entorno, los almacenes de datos secretos y las banderas de funcionalidad deben estar en un lugar donde los operadores puedan auditarlos sin tener que leer todo el sistema. Separe la política de fragmentación de la política de recuperación; cambiar una no debe obligar a reescribir la otra cuando cambian las métricas de calidad.
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;
}
Etapa 7: Eliminar encabezados y pies de página repetidos
La etapa repetida de Step 7 Strip funciona mejor cuando se trata como una superficie medible. Capture un registro exitoso, un caso de fallo y la nota de reversión antes de ampliar el alcance. Documente tanto el camino óptimo como el camino de recuperación juntos. Las reintentos, los controles humanos y el manejo de mensajes no entregados forman parte del producto, no son ajustes realizados posteriormente. Separe la política de fragmentación de la política de recuperación; cambiar una no debe obligar a reescribir la otra cuando cambian las métricas de calidad.
Set<String> boilerplate = detectRepeatedLines(rawText); // lines on >= 60% of pages
String cleaned = removeBoilerplate(raw, boilerplate); // also drops "Page 3 of 5"
Paso 8: Etiquete cada página indicando de dónde proviene
The Step 8 Tag every stage funciona mejor cuando se trata como una superficie medible. Capture un registro ideal, un caso de fallo y la nota de reversión antes de ampliar el alcance. Prefiera unidades pequeñas y verificables en lugar de scripts extensos. Cuando un paso falla, el fallo debe apuntar a una sola responsabilidad y no a un proceso complicado. Separe la política de fragmentación de la política de recuperación. Cambiar una no debe obligar a reescribir la otra cuando cambian las métricas de calidad. The Step 8 Tag every stage funciona mejor cuando se trata como una superficie medible. Capture un registro ideal, un caso de fallo y la nota de reversión antes de ampliar el alcance. Registre los tiempos y el costo en tokens o consultas junto con los resultados funcionales. Tener visibilidad del costo desde el principio evita facturas inesperadas cuando el proceso pasa de la versión de demostración a entornos compartidos.
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));
Paso 9: Fragmentar las páginas
DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
List<TextSegment> segments = new ArrayList<>();
for (Document document : documents) {
segments.addAll(splitter.split(document));
}
Paso 10: Incrustar y almacenar por lotes
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);
}
Paso 11: Obtener los fragmentos más cercanos
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();
Paso 12: Componer el 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");
}
Paso 13: Entregarlo a 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());
El código completo
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;
}
}
Ejecutarlo
> 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