No stories. Just the minimal code to build a working RAG
The minimal code for a working RAG app, no stories: keys, dependencies, an embedding model, hosted Qdrant for vectors and Gemini to generate the answers.
This article is published in English.
Sources: No stories. Just the minimal code to build a working RAG application
Use this as an operator-facing rebuild of the ideas in “No stories. Just the minimal code to build a working RAG application”: clear stages, ordered code slots, and recovery notes that survive a handoff. The Overview stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments.
Step 0: Keys
For the Step 0 Keys stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
export QDRANT_CLUSTER_ENDPOINT="YOUR-CLUSTER.cloud.qdrant.io"
export QDRANT_API_KEY="..."
export GEMINI_API_KEY="..."
Step 1: Dependencies
For the Step 1 Dependencies stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.
<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>
Step 2: The embedding model
For the Step 2 The embedding stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Prefer structured outputs with schema validation over free-form prose when the next step is code or a tool call. For the Step 2 The embedding stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments.
static final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
int vectorDimension = embeddingModel.dimension(); // 384
Step 3: Connect to hosted Qdrant
When working through the Step 3 Connect to stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
static QdrantClient openQdrant() {
return new QdrantClient(
QdrantGrpcClient.newBuilder(qdrantHost, 6334, true) // true = TLS, for Qdrant Cloud
.withApiKey(qdrantApiKey)
.build());
}
Step 4: Create the collection
When working through the Step 4 Create the stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.
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();
}
Step 5: Build the embedding store
When working through the Step 5 Build the stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface. When working through the Step 5 Build the stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments.
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();
}
Step 6: Read the PDF page by page
The Step 6 Read the stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.
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;
}
Step 7: Strip repeated headers and footers
The Step 7 Strip repeated stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.
Set<String> boilerplate = detectRepeatedLines(rawText); // lines on >= 60% of pages
String cleaned = removeBoilerplate(raw, boilerplate); // also drops "Page 3 of 5"
Step 8: Tag every page with where it came from
The Step 8 Tag every stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move. The Step 8 Tag every stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments.
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));
Step 9: Chunk the pages
DocumentSplitter splitter = DocumentSplitters.recursive(MAX_SEGMENT_CHARS, SEGMENT_OVERLAP_CHARS);
List<TextSegment> segments = new ArrayList<>();
for (Document document : documents) {
segments.addAll(splitter.split(document));
}
Step 10: Embed and store, in batches
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);
}
Step 11: Retrieve the nearest chunks
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();
Step 12: Assemble the RAG prompt
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");
}
Step 13: Hand it to 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());
The complete code
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;
}
}
Run it
> 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