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