← Back to list

What Football Can Teach You About Chunking Data

Building vector search in Java with Qdrant and why your chunks make or break it. Part 4 of the “Vector Databases for Java Developers”…

Mala Gupta · 2026-07-10 01:39 · 52 claps · 22.5 min read
#qdrant #ai-agent #ai #rags #data-chunking
Open on Medium ↗
Wiki topics: RAG · RAG & Retrieval AGT · AI Agents AI · AI · General ⚽ · Football / Soccer

What Football Can Teach You About Chunking Data

Building vector search in Java with Qdrant and why your chunks make or break it. Part 4 of the “Vector Databases for Java Developers” series.

Before we talk about any concepts or look at a single line of code, see for yourself. The GIF below shows an application you could use to search information about the current FIFA championship. Notice the initial search string, the Qdrant chunks, and the matching vector search results at the bottom. Drag the chunk-size slider and keep your eye on two things:

  1. Changes in the chunks in Qdrant
  2. Top Vector search results;

These can sometimes show the wrong answer entirely (I’ll explain why later). You can also change the search query.

There will be a short pause while every chunk re-embeds and re-indexes into Qdrant (I’ll explain that too later in this post). All the code for this single file is included at the end of this blog post, which you can run on your system.

Introduction

My first three posts covered multiple topics: starting with explaining what a vector database is, we learned to look at a 384-dimensional embedding, and we worked through how cosine similarity decides which vectors are “close.” This post answers what most developers usually ignore, that is, how you cut your data or documents into chunks, storing them in a vector database and how it affects the search results for a query string.

The demo code in this post uses the same 384-dimension AllMiniLmL6V2EmbeddingModel from Post 2, stores the vectors in a live Qdrant collection, and runs vector search against it.

A standalone Java UI application that you can run on your system makes this demo interesting (don’t forget to run it on your system). The application re-indexes every time you change the chunking. Drag the chunk-size slider and it re-chunks, re-embeds, drops and recreates the Qdrant collection, re-upserts every point, and re-runs the query. You will see it pause (since all these steps take time to complete).

I’ve used used 2026 FIFA World Cup text as the sample (the tournament is in the quarterfinals as I write this), because the offside rule has the same “main rule unless an exception” shape that makes chunking mistakes so vivid: split the exception away from the rule and no amount of good embeddings can retrieve a complete answer, because no single chunk contains one.

Dependencies for this project

All the demo code in this project is defined in a single Java source file. However, you still need the following dependencies to use classes from Qdrant and LangChain4j:

<dependency>
    <groupId>io.qdrant</groupId>
    <artifactId>client</artifactId>
    <version>1.18.3</version>
</dependency>

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
</dependency>

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
</dependency>

How chunking, storing vectors, and searching work

Let’s talk how all of this works. Every re-index runs the same ingestion steps a production job runs. It splits, embeds, and stores data in the Qdrant hosted vector database, done live whenever you change the chunk window slider:

  1. Split the source text into TextSegments with the selected LangChain4j splitter (recursive / by paragraph / by sentence / by character).
  2. Embed all the segments in one batched embedAll call, producing a 384-float vector each.
  3. Recreate the Qdrant collection (delete + create, size=384, distance=Cosine) so each run is clean.
  4. Upsert one point per segment: the vector plus a payload holding the chunk text and its order.
  5. Embed the query and search Qdrant for the nearest points.

I’ve used a Swing application so you can play around with the chunking concept and how it affects your search results. The heavy steps run on a background thread (via SwingWorker) so the window stays responsive, and a short debounce coalesces slider drags into one re-index. The status bar reports how long each stage took, so when a huge chunk count makes embedding+upsert slow, you feel it.

The part that actually matters: chunking + re-indexing

The full application code below is a few hundred lines, but most of it is Swing wiring for the UI. If you read only one part of this post, read this one. It is the chunking logic and the handful of Qdrant calls that turn chunks into a searchable collection. Everything else just draws these on screen.

1. The four chunking strategies. Using LangChain4j’s splitters

LangChain4j ships a DocumentSplitter interface with production implementations, and the four options in the app map straight onto four of them. Each takes a maximum segment size and an overlap, wraps your text in a Document, and returns a List<TextSegment> , this is how ingestion is used in a real RAG pipeline.

private List<TextSegment> split(String text, int method, int size, int overlap) {
    if (text == null || text.isBlank()) return List.of();

    DocumentSplitter splitter = switch (method) {
        // The recommended general-purpose splitter: fits whole paragraphs, then
        // recursively falls back to lines -> sentences -> words -> characters.
        case 0 -> DocumentSplitters.recursive(size, overlap);
        // Split on paragraph boundaries (blank lines).
        case 1 -> new DocumentByParagraphSplitter(size, overlap);
        // Split on sentence boundaries (Apache OpenNLP under the hood).
        case 2 -> new DocumentBySentenceSplitter(size, overlap);
        // The naive baseline: blind fixed-size character windows.
        default -> new DocumentByCharacterSplitter(size, overlap);
    };

    // The splitter returns TextSegments directly - we carry those through to
    // embedding, exactly as a production ingestion pipeline does.
    return splitter.split(Document.from(text));
}

Why Langchain4j’s splitters:

  • **DocumentSplitters.recursive(...)** is LangChain4j's recommended default. It packs as many paragraphs as fit into a segment, and when a paragraph is too big it recursively drops to lines, then sentences, then words, then characters. So, you get large, coherent chunks without ever blowing past the size limit.
  • **DocumentBySentenceSplitter** uses the Apache OpenNLP sentence model, not a naive split on periods, so abbreviations like "No." or "vs." don't fool it.
  • **DocumentByCharacterSplitter** is the naive baseline. It blind windows that cut mid-word and mid-clause. It's here precisely so you can watch it break.
  • The size/overlap here are characters (the two-argument constructors). In production you'd often make the limit token-based to respect your model's context window and see the note in the chunk-size section below.

2. Turning chunks into a Qdrant collection

It recreates a clean collection, then upsert one point per segment (its vector plus a payload holding the text and its order). The query is embedded with a single embed; the chunks are embedded in one batched embedAll call in the loop below.

private void recreateCollection() throws Exception {
    try { qdrant.deleteCollectionAsync(COLLECTION).get(); } catch (Exception ignore) { /* first run */ }
    qdrant.createCollectionAsync(COLLECTION,
            VectorParams.newBuilder().setDistance(Distance.Cosine).setSize(DIM).build()).get();
}

private void upsert(List<TextSegment> segments, List<Embedding> embeddings) throws Exception {
    if (segments.isEmpty()) return;
    List<PointStruct> points = new ArrayList<>();
    for (int i = 0; i < segments.size(); i++) {
        points.add(PointStruct.newBuilder()
                .setId(id(i))
                .setVectors(vectors(embeddings.get(i).vector()))
                .putAllPayload(Map.of(
                        "text", value(segments.get(i).text()),
                        "order", value((long) i)))
                .build());
    }
    qdrant.upsertAsync(COLLECTION, points).get();
}

Search is the mirror image: embed the query, ask Qdrant for the nearest points, and read the original text back out of each point’s payload.

private List<Hit> search(float[] queryVec, int k) throws Exception {
    List<ScoredPoint> points = qdrant.queryAsync(QueryPoints.newBuilder()
            .setCollectionName(COLLECTION)
            .setQuery(nearest(queryVec))
            .setLimit(k)
            .setWithPayload(WithPayloadSelector.newBuilder().setEnable(true).build())
            .build()).get();

    List<Hit> hits = new ArrayList<>();
    for (ScoredPoint p : points) {
        Map<String, Value> payload = p.getPayloadMap();
        String txt = payload.containsKey("text") ? payload.get("text").getStringValue() : "";
        int order = payload.containsKey("order") ? (int) payload.get("order").getIntegerValue() : -1;
        hits.add(new Hit(order, p.getScore(), txt));
    }
    return hits;
}

3. The loop that ties it all together

Every time you change the chunking, this sequence runs on a background thread. It’s the canonical LangChain4j ingestion-and-search flow — the same three lines you’ll find in any RAG tutorial (splitembedAll → store), plus the query:

// Ingest: split into segments, batch-embed them all at once, store in Qdrant.
List<TextSegment> segments = split(text, method, size, overlap);
List<Embedding> embeddings = MODEL.embedAll(segments).content();
recreateCollection();
upsert(segments, embeddings);

// Retrieve: embed the query and find the nearest points.
List<Hit> hits = search(embed(query), segments.size());

Change the chunking and every line above re-runs. This is why you feel the lag in the application, and why the results move. That is the entire lesson in one code block. The full class below wraps it in a SwingWorker (so the UI stays responsive), a debounce (so slider drags don't queue up dozens of re-indexes), and the on-screen rendering.

Where this differs from a real deployment (on purpose)

This application rebuilds the whole collection on every edit so you can see the cost of re-chunking. A production system ingests once (or incrementally upserts only changed points with stable IDs) rather than dropping the collection on every change; it requests a small top-k instead of every point; and it often uses token-based splitting to respect the model’s context window. LangChain4j also offers a higher-level path that wraps all of this — EmbeddingStoreIngestor (splitter + model + store) together with QdrantEmbeddingStore , but here we call the Qdrant client directly so the collection and points stay visible.

The full application

Here’s the complete, runnable class — the excerpts above are pulled straight from it.

package com.gupta.morevectors.blooooooooooog4;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.splitter.DocumentByCharacterSplitter;
import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter;
import dev.langchain4j.data.document.splitter.DocumentBySentenceSplitter;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel;
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 io.qdrant.client.grpc.JsonWithInt.Value;
import io.qdrant.client.grpc.Points.PointStruct;
import io.qdrant.client.grpc.Points.QueryPoints;
import io.qdrant.client.grpc.Points.ScoredPoint;
import io.qdrant.client.grpc.Points.WithPayloadSelector;

import javax.swing.*;
import javax.swing.Timer;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.border.LineBorder;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.*;
import java.awt.geom.Line2D;
import java.awt.geom.Path2D;
import java.util.*;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.ValueFactory.value;
import static io.qdrant.client.VectorsFactory.vectors;

/**
 * ChunkingPlayground (Qdrant edition)
 *
 * What this class does:
 *
 * 1. Chunks text,
 * 2. Embeds each chunk with AllMiniLmL6V2EmbeddingModel,
 * 3. Stores the vectors in hosted (free) Qdrant collection,
 * 4. Runs vector search.
 * 5. Changing the chunking re-embeds and re-indexes everything on the fly
 */
public class ChunkingPlayground extends JFrame {

    static final String QDRANT_CLUSTER_ENDPOINT = System.getenv().getOrDefault("QDRANT_CLUSTER_ENDPOINT", "localhost");
    static final String QDRANT_API_KEY = System.getenv().getOrDefault("QDRANT_API_KEY", "");

    private static final String COLLECTION = "chunking_demo";
    private static final int DIM = 384;

    // Model and DB client are created once and reused (both are thread-safe).
    private static final EmbeddingModel MODEL = new AllMiniLmL6V2EmbeddingModel();
    private QdrantClient qdrant = new QdrantClient(QdrantGrpcClient.newBuilder(QDRANT_CLUSTER_ENDPOINT,
                                                                               6334,
                                                                               true)
                                                                   .withApiKey(QDRANT_API_KEY)
                                                                   .build());

    // Palette (stadium / pitch theme)
    private static final Color PITCH_A        = new Color(0x1E, 0x5B, 0x37); // mowed grass, light stripe
    private static final Color PITCH_B        = new Color(0x18, 0x4E, 0x2F); // mowed grass, dark stripe
    private static final Color SCOREBOARD     = new Color(0x0C, 0x1A, 0x12); // near-black header/ticker
    private static final Color SCOREBOARD_2   = new Color(0x12, 0x27, 0x1B);
    private static final Color PANEL          = new Color(0xEC, 0xF2, 0xEE); // list background behind white rows
    private static final Color SURFACE        = Color.WHITE;
    private static final Color SURFACE_GOLD   = new Color(0xFF, 0xFB, 0xEE); // top-match card
    private static final Color NOTICE_BG      = new Color(0xFE, 0xF2, 0xF2);
    private static final Color BORDER         = new Color(0xD4, 0xDE, 0xD8);
    private static final Color ROW_BORDER     = new Color(0xE2, 0xE9, 0xE4);
    private static final Color TEXT           = new Color(0x14, 0x21, 0x1A);
    private static final Color MUTED_DARK     = new Color(0x4C, 0x5E, 0x54);
    private static final Color MUTED          = new Color(0x83, 0x93, 0x8A);
    private static final Color DANGER         = new Color(0xB4, 0x23, 0x18);
    private static final Color ACCENT         = new Color(0x14, 0x9E, 0x54); // pitch green
    private static final Color ACCENT_DARK    = new Color(0x0A, 0x6E, 0x3A);
    private static final Color GOLD           = new Color(0xE0, 0xA4, 0x24); // trophy gold (winner)
    private static final Color GOLD_DARK      = new Color(0xA9, 0x78, 0x0F);
    private static final Color ON_DARK        = new Color(0xE9, 0xF3, 0xEC); // text on scoreboard

    private static final String UI_FAMILY = resolveFamily();

    // Controls
    private final JTextArea sourceArea = new JTextArea();
    private final JTextField queryField = new JTextField();
    private final JComboBox<String> methodBox = new JComboBox<>(new String[]{
            "Recursive (recommended)",
            "By paragraph",
            "By sentence",
            "By character (fixed window)"
    });
    private final JSlider sizeSlider = new JSlider(40, 1500, 700);
    private final JSlider overlapSlider = new JSlider(0, 50, 15);
    private final JLabel sizeValue = new JLabel();
    private final JLabel overlapValue = new JLabel();

    // Output
    private final ScrollableColumn chunksColumn = new ScrollableColumn();
    private final ScrollableColumn resultsColumn = new ScrollableColumn();
    private JScrollPane chunksScroll;
    private JScrollPane resultsScroll;
    private final JLabel statusLabel = new JLabel();

    // Pipeline state
    private final AtomicInteger seq = new AtomicInteger();
    private final Timer debounce = new Timer(400, e -> runPipeline());
    private volatile boolean pendingReindex = false;
    private List<String> lastChunks = List.of();

    private record Hit(int order, double score, String text) {}

    private static class Result {
        List<String> chunks;
        List<Hit> hits;
        long embedMs, indexMs, searchMs;
        String error;
    }

    public ChunkingPlayground() {
        super("Chunking Playground - Qdrant edition");
        debounce.setRepeats(false);
        buildUi();
        sourceArea.setText(SAMPLE_TEXT);
        queryField.setText("Can a player be offside from a corner kick?");
        wireEvents();
        start();
    }

    //  Startup: warm the model, check the DB, then do the first index
    private void start() {
        setStatus("Loading embedding model and connecting to Qdrant…", false);
        new SwingWorker<String, Void>() {
            protected String doInBackground() {
                try {
                    embed("warm up");                 // triggers one-time model load
                    qdrant.listCollectionsAsync().get(); // connectivity check
                    return null;
                } catch (Exception ex) {
                    return friendlyError(ex);
                }
            }
            protected void done() {
                String err;
                try { err = get(); } catch (Exception e) { err = friendlyError(e); }
                if (err != null) {
                    render(errorResult(err), false);
                } else {
                    pendingReindex = true;
                    runPipeline();
                }
            }
        }.execute();
    }

    //  The re-index / search pipeline (runs off the EDT)
    private void schedule(boolean reindex) {
        if (reindex) pendingReindex = true;
        debounce.restart();
    }

    private void runPipeline() {
        final boolean reindex = pendingReindex;
        pendingReindex = false;
        final int reqId = seq.incrementAndGet();

        final String text = sourceArea.getText();
        final String query = queryField.getText();
        final int method = methodBox.getSelectedIndex();
        final int size = sizeSlider.getValue();
        final int overlap = size * overlapSlider.getValue() / 100;
        final List<String> existing = lastChunks;

        setStatus(reindex ? "⏳ Re-indexing…" : "⏳ Searching…", false);

        new SwingWorker<Result, Void>() {
            protected Result doInBackground() {
                Result r = new Result();
                try {
                    List<String> chunks;

                    if (reindex) {
                        // Split into TextSegments (LangChain4j's ingestion primitive).
                        List<TextSegment> segments = split(text, method, size, overlap);

                        // Embed ALL segments in a single batched call - not one at a
                        // time. This is the standard production ingestion pattern.
                        long t0 = System.nanoTime();
                        List<Embedding> embeddings = segments.isEmpty()
                                ? List.of()
                                : MODEL.embedAll(segments).content();
                        r.embedMs = ms(t0);

                        long t1 = System.nanoTime();
                        recreateCollection();
                        upsert(segments, embeddings);
                        r.indexMs = ms(t1);

                        chunks = segments.stream().map(TextSegment::text).toList();
                    } else {
                        chunks = existing;
                    }
                    r.chunks = chunks;

                    long t2 = System.nanoTime();
                    r.hits = chunks.isEmpty() ? List.of() : search(embed(query), chunks.size());
                    r.searchMs = ms(t2);
                } catch (Exception ex) {
                    r.error = friendlyError(ex);
                }
                return r;
            }
            protected void done() {
                if (reqId != seq.get()) return; // a newer request superseded this one
                Result r;
                try { r = get(); } catch (Exception e) { r = errorResult(friendlyError(e)); }
                if (reindex && r.error == null) lastChunks = r.chunks;
                render(r, reindex);
            }
        }.execute();
    }

    private static long ms(long startNanos) {
        return (System.nanoTime() - startNanos) / 1_000_000;
    }

    // Embed a single string (used for the query). Chunks are embedded in a
    // batch with MODEL.embedAll(...) during re-indexing, not through here.
    private static float[] embed(String text) {
        return MODEL.embed(text == null ? "" : text).content().vector();
    }

    private void recreateCollection() throws Exception {
        try { qdrant.deleteCollectionAsync(COLLECTION).get(); } catch (Exception ignore) { /* first run */ }
        qdrant.createCollectionAsync(COLLECTION,
                                     VectorParams.newBuilder().setDistance(Distance.Cosine).setSize(DIM).build()).get();
    }

    private void upsert(List<TextSegment> segments, List<Embedding> embeddings) throws Exception {
        if (segments.isEmpty()) return;
        List<PointStruct> points = new ArrayList<>();
        for (int i = 0; i < segments.size(); i++) {
            points.add(PointStruct.newBuilder()
                                  .setId(id(i))
                                  .setVectors(vectors(embeddings.get(i).vector()))
                                  .putAllPayload(Map.of(
                                          "text", value(segments.get(i).text()),
                                          "order", value((long) i)))
                                  .build());
        }
        qdrant.upsertAsync(COLLECTION, points).get();
    }

    private List<Hit> search(float[] queryVec, int k) throws Exception {
        // Demo: k = number of chunks so we can show the FULL ranking. In production
        // you'd request a small top-k (e.g. 5) - the query call is otherwise identical.
        List<ScoredPoint> points = qdrant.queryAsync(QueryPoints.newBuilder()
                                                                .setCollectionName(COLLECTION)
                                                                .setQuery(nearest(queryVec))
                                                                .setLimit(k)
                                                                .setWithPayload(WithPayloadSelector.newBuilder().setEnable(true).build())
                                                                .build()).get();

        List<Hit> hits = new ArrayList<>();
        for (ScoredPoint p : points) {
            Map<String, Value> payload = p.getPayloadMap();
            String txt = payload.containsKey("text") ? payload.get("text").getStringValue() : "";
            int order = payload.containsKey("order") ? (int) payload.get("order").getIntegerValue() : -1;
            hits.add(new Hit(order, p.getScore(), txt));
        }
        return hits;
    }

    private static Result errorResult(String msg) {
        Result r = new Result();
        r.error = msg;
        r.chunks = List.of();
        return r;
    }

    private String friendlyError(Throwable ex) {
        String m = (ex.getMessage() == null) ? ex.toString() : ex.getMessage();
        String lower = m.toLowerCase();
        if (lower.contains("unavailable") || lower.contains("connection") || lower.contains("refused")) {
            return "Can't reach Qdrant on localhost:6334. Start it with:  "
                   + "docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant";
        }
        return m;
    }

    //  Chunking: real LangChain4j DocumentSplitter implementations.
    //  Each takes a max segment size and an overlap (both in characters here),
    //  wraps the text in a Document, and returns a List<TextSegment>.
    private List<TextSegment> split(String text, int method, int size, int overlap) {
        if (text == null || text.isBlank()) return List.of();

        DocumentSplitter splitter = switch (method) {
            // Recommended general-purpose splitter: fits whole paragraphs, then
            // recursively falls back to lines -> sentences -> words -> characters.
            case 0 -> DocumentSplitters.recursive(size, overlap);
            // Split on paragraph boundaries (blank lines).
            case 1 -> new DocumentByParagraphSplitter(size, overlap);
            // Split on sentence boundaries (Apache OpenNLP under the hood).
            case 2 -> new DocumentBySentenceSplitter(size, overlap);
            // The naive baseline: blind fixed-size character windows.
            default -> new DocumentByCharacterSplitter(size, overlap);
        };

        return splitter.split(Document.from(text));
    }

    //  UI
    private void buildUi() {
        PitchPanel root = new PitchPanel();
        root.setLayout(new BorderLayout(0, 14));
        root.setBorder(new EmptyBorder(16, 18, 14, 18));

        JPanel north = new JPanel();
        north.setOpaque(false);
        north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS));
        north.add(buildHeader());
        north.add(Box.createVerticalStrut(12));
        north.add(buildControls());
        root.add(north, BorderLayout.NORTH);

        chunksScroll = transparentScroll(chunksColumn);
        resultsScroll = transparentScroll(resultsColumn);

        JScrollPane sourceScroll = transparentScroll(sourceArea);
        sourceArea.setOpaque(false);
        sourceArea.setFont(ui(Font.PLAIN, 14));
        sourceArea.setForeground(TEXT);
        sourceArea.setCaretColor(ACCENT_DARK);
        sourceArea.setLineWrap(true);
        sourceArea.setWrapStyleWord(true);
        sourceArea.setBorder(new EmptyBorder(10, 12, 10, 12));

        JPanel columns = new JPanel(new GridLayout(1, 2, 14, 0));
        columns.setOpaque(false);
        columns.add(titledCard("Chunks in Qdrant", "one point per chunk", chunksScroll, PANEL));
        columns.add(titledCard("Vector search results",
                               "real MiniLM 384-d · cosine", resultsScroll, PANEL));

        JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT,
                                          titledCard("Text to be chunked (editable)", null, sourceScroll, SURFACE),
                                          columns);
        split.setResizeWeight(0.36);
        split.setBorder(null);
        split.setOpaque(false);
        split.setDividerSize(16);
        root.add(split, BorderLayout.CENTER);

        root.add(buildTicker(), BorderLayout.SOUTH);

        setContentPane(root);
    }

    // Scoreboard-style bottom ticker that reports the pipeline timings.
    private JComponent buildTicker() {
        RoundedPanel bar = new RoundedPanel(SCOREBOARD, 14);
        bar.setBorderColor(SCOREBOARD_2);
        bar.setLayout(new BorderLayout(10, 0));
        bar.setBorder(new EmptyBorder(9, 14, 9, 14));
        statusLabel.setFont(ui(Font.BOLD, 12));
        statusLabel.setForeground(ON_DARK);
        bar.add(new BallIcon(15), BorderLayout.WEST);
        bar.add(statusLabel, BorderLayout.CENTER);
        return bar;
    }

    private JComponent buildHeader() {
        RoundedPanel bar = new RoundedPanel(SCOREBOARD, 20);
        bar.setBorderColor(SCOREBOARD_2);
        bar.setLayout(new BorderLayout(14, 0));
        bar.setBorder(new EmptyBorder(14, 18, 14, 18));

        JPanel left = new JPanel();
        left.setOpaque(false);
        left.setLayout(new BoxLayout(left, BoxLayout.X_AXIS));
        left.add(new BallIcon(40));
        left.add(Box.createHorizontalStrut(14));

        JPanel titles = new JPanel();
        titles.setOpaque(false);
        titles.setLayout(new BoxLayout(titles, BoxLayout.Y_AXIS));
        JLabel title = new JLabel("CHUNKING PLAYGROUND");
        title.setFont(ui(Font.BOLD, 22));
        title.setForeground(ON_DARK);
        title.setAlignmentX(LEFT_ALIGNMENT);
        JLabel sub = new JLabel("Re-chunk → re-embed → re-index into Qdrant, and watch the match change");
        sub.setFont(ui(Font.PLAIN, 12));
        sub.setForeground(new Color(0x9E, 0xB8, 0xA8));
        sub.setAlignmentX(LEFT_ALIGNMENT);
        titles.add(title);
        titles.add(Box.createVerticalStrut(3));
        titles.add(sub);
        left.add(titles);

        JPanel chips = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
        chips.setOpaque(false);
        chips.add(pill("WORLD CUP 2026", GOLD, SCOREBOARD));
        chips.add(pill("QDRANT · 384-d", ACCENT, Color.WHITE));

        bar.add(left, BorderLayout.WEST);
        bar.add(chips, BorderLayout.EAST);
        return bar;
    }

    private JComponent buildControls() {
        RoundedPanel card = new RoundedPanel(SURFACE, 18);
        card.setBorderColor(BORDER);
        card.setBorder(new EmptyBorder(16, 18, 16, 18));
        card.setLayout(new GridBagLayout());

        queryField.setFont(ui(Font.PLAIN, 14));
        queryField.setForeground(TEXT);
        queryField.setBorder(new CompoundBorder(
                new LineBorder(BORDER, 1, true), new EmptyBorder(8, 10, 8, 10)));
        methodBox.setFont(ui(Font.PLAIN, 13));

        styleSlider(sizeSlider);
        styleSlider(overlapSlider);
        for (JLabel v : new JLabel[]{sizeValue, overlapValue}) {
            v.setFont(ui(Font.BOLD, 12));
            v.setForeground(ACCENT_DARK);
            v.setHorizontalAlignment(SwingConstants.RIGHT);
            v.setPreferredSize(new Dimension(96, 20));
        }

        GridBagConstraints c = new GridBagConstraints();
        c.insets = new Insets(7, 6, 7, 6);
        c.anchor = GridBagConstraints.WEST;
        c.fill = GridBagConstraints.HORIZONTAL;

        addRow(card, c, 0, "Search query", queryField);
        addRow(card, c, 1, "Chunking method", methodBox);
        addRow(card, c, 2, "Chunk window", sliderRow(sizeSlider, sizeValue));
        addRow(card, c, 3, "Overlap", sliderRow(overlapSlider, overlapValue));
        return card;
    }

    private JComponent sliderRow(JSlider slider, JLabel value) {
        JPanel p = new JPanel(new BorderLayout(12, 0));
        p.setOpaque(false);
        p.add(slider, BorderLayout.CENTER);
        p.add(value, BorderLayout.EAST);
        return p;
    }

    private void addRow(JPanel p, GridBagConstraints c, int y, String label, JComponent field) {
        c.gridy = y;
        c.gridx = 0; c.weightx = 0;
        JLabel l = new JLabel(label);
        l.setFont(ui(Font.BOLD, 12));
        l.setForeground(MUTED_DARK);
        l.setPreferredSize(new Dimension(140, 26));
        p.add(l, c);
        c.gridx = 1; c.weightx = 1;
        p.add(field, c);
    }

    private JComponent titledCard(String title, String note, JComponent content, Color cardBg) {
        JPanel wrap = new JPanel(new BorderLayout(0, 8));
        wrap.setOpaque(false);

        JPanel head = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
        head.setOpaque(false);
        RoundedPanel dot = new RoundedPanel(GOLD, 999);
        dot.setPreferredSize(new Dimension(9, 9));
        head.add(dot);
        JLabel t = new JLabel(note == null ? title
                                      : "<html>" + title + " &nbsp;<font color='#C8D8CF'>&middot; " + note + "</font></html>");
        t.setFont(ui(Font.BOLD, 13));
        t.setForeground(ON_DARK);   // sits over the green pitch, so it must be light
        head.add(t);
        wrap.add(head, BorderLayout.NORTH);

        RoundedPanel card = new RoundedPanel(cardBg, 18);
        card.setBorderColor(BORDER);
        card.setLayout(new BorderLayout());
        card.setBorder(new EmptyBorder(6, 6, 6, 6));
        card.add(content, BorderLayout.CENTER);
        wrap.add(card, BorderLayout.CENTER);
        return wrap;
    }

    private JScrollPane transparentScroll(JComponent c) {
        JScrollPane sp = new JScrollPane(c);
        sp.setOpaque(false);
        sp.getViewport().setOpaque(false);
        sp.setBorder(null);
        sp.getVerticalScrollBar().setUnitIncrement(16);
        return sp;
    }

    private JComponent pill(String text, Color bg, Color fg) {
        RoundedPanel p = new RoundedPanel(bg, 999);
        p.setBorder(new EmptyBorder(6, 13, 6, 13));
        p.setLayout(new BorderLayout());
        JLabel l = new JLabel(text);
        l.setFont(ui(Font.BOLD, 11));
        l.setForeground(fg);
        p.add(l);
        return p;
    }

    private void styleSlider(JSlider s) {
        s.setOpaque(false);
        s.setForeground(ACCENT);
    }

    private void wireEvents() {
        // Chunking controls -> full re-index.
        DocumentListener reindexDoc = simpleDoc(() -> schedule(true));
        sourceArea.getDocument().addDocumentListener(reindexDoc);
        methodBox.addActionListener(e -> schedule(true));
        sizeSlider.addChangeListener(e -> { sizeValue.setText(sizeSlider.getValue() + " chars"); schedule(true); });
        overlapSlider.addChangeListener(e -> { updateOverlapLabel(); schedule(true); });

        // Query -> search only (no re-index needed).
        queryField.getDocument().addDocumentListener(simpleDoc(() -> schedule(false)));

        sizeValue.setText(sizeSlider.getValue() + " chars");
        updateOverlapLabel();
    }

    private void updateOverlapLabel() {
        // Every LangChain4j splitter accepts an overlap, so it always applies.
        overlapValue.setText(overlapSlider.getValue() + "%");
        overlapValue.setForeground(ACCENT_DARK);
    }

    private DocumentListener simpleDoc(Runnable r) {
        return new DocumentListener() {
            public void insertUpdate(DocumentEvent e) { r.run(); }
            public void removeUpdate(DocumentEvent e) { r.run(); }
            public void changedUpdate(DocumentEvent e) { r.run(); }
        };
    }

    //  Rendering
    private void render(Result r, boolean reindex) {
        chunksColumn.removeAll();
        if (r.chunks != null) {
            for (int i = 0; i < r.chunks.size(); i++) {
                chunksColumn.add(chunkRow(i, r.chunks.get(i)));
                chunksColumn.add(Box.createVerticalStrut(8));
            }
        }
        chunksColumn.add(Box.createVerticalGlue());
        chunksColumn.revalidate();
        chunksColumn.repaint();

        resultsColumn.removeAll();
        if (r.error != null) {
            resultsColumn.add(noticeRow(r.error));
        } else if (r.hits != null) {
            double max = r.hits.isEmpty() ? 0 : r.hits.get(0).score();
            int rank = 1;
            for (Hit h : r.hits) {
                resultsColumn.add(resultRow(h, max, rank == 1 && h.score() > 0, rank));
                resultsColumn.add(Box.createVerticalStrut(8));
                rank++;
            }
        }
        resultsColumn.add(Box.createVerticalGlue());
        resultsColumn.revalidate();
        resultsColumn.repaint();

        if (r.error != null) {
            setStatus(r.error, true);
        } else if (reindex) {
            setStatus(String.format(
                    "Indexed %d chunks into Qdrant  ·  embed %d ms  ·  upsert %d ms  ·  search %d ms",
                    r.chunks.size(), r.embedMs, r.indexMs, r.searchMs), false);
        } else {
            setStatus(String.format("Search only  ·  %d ms", r.searchMs), false);
        }
    }

    private void setStatus(String text, boolean error) {
        statusLabel.setText(text);
        statusLabel.setForeground(error ? new Color(0xFF, 0x9B, 0x8F) : ON_DARK);
    }

    private JComponent chunkRow(int idx, String text) {
        Row row = new Row(SURFACE, 14);
        row.setBorderColor(ROW_BORDER);
        row.setBorder(new EmptyBorder(11, 14, 12, 14));
        row.setLayout(new BorderLayout(0, 5));
        row.setAlignmentX(LEFT_ALIGNMENT);

        JLabel head = new JLabel("POINT " + idx + "  ·  " + text.length() + " chars");
        head.setFont(ui(Font.BOLD, 11));
        head.setForeground(ACCENT_DARK);

        row.add(head, BorderLayout.NORTH);
        row.add(bodyText(oneLine(text), TEXT), BorderLayout.CENTER);
        return row;
    }

    private JComponent resultRow(Hit h, double max, boolean top, int rank) {
        Row row = new Row(top ? SURFACE_GOLD : SURFACE, 14);
        row.setBorderColor(top ? GOLD : ROW_BORDER);
        row.setBorder(new EmptyBorder(12, 14, 12, 14));
        row.setLayout(new BorderLayout(0, 8));
        row.setAlignmentX(LEFT_ALIGNMENT);

        JPanel badgeHolder = new JPanel(new GridBagLayout());
        badgeHolder.setOpaque(false);
        badgeHolder.add(badge(rank, top));

        double frac = max > 0 ? h.score() / max : 0;
        ScoreBar bar = new ScoreBar();
        bar.set(frac, top ? GOLD : scoreColor(frac));

        JLabel scoreL = new JLabel(String.format("%.3f", h.score()));
        scoreL.setFont(ui(Font.BOLD, 13));
        scoreL.setForeground(top ? GOLD_DARK : TEXT);
        scoreL.setHorizontalAlignment(SwingConstants.RIGHT);
        scoreL.setPreferredSize(new Dimension(54, 18));

        JPanel barWrap = new JPanel(new BorderLayout(10, 0));
        barWrap.setOpaque(false);
        barWrap.add(bar, BorderLayout.CENTER);
        barWrap.add(scoreL, BorderLayout.EAST);

        JPanel strip = new JPanel(new BorderLayout(10, 0));
        strip.setOpaque(false);
        strip.add(badgeHolder, BorderLayout.WEST);
        strip.add(barWrap, BorderLayout.CENTER);

        JLabel meta = new JLabel(top ? "★ TOP MATCH  ·  point " + h.order() : "point " + h.order());
        meta.setFont(ui(top ? Font.BOLD : Font.PLAIN, 11));
        meta.setForeground(top ? GOLD_DARK : MUTED);

        JPanel body = new JPanel(new BorderLayout(0, 3));
        body.setOpaque(false);
        body.add(meta, BorderLayout.NORTH);
        body.add(bodyText(oneLine(h.text()), top ? TEXT : MUTED_DARK), BorderLayout.CENTER);

        row.add(strip, BorderLayout.NORTH);
        row.add(body, BorderLayout.CENTER);
        return row;
    }

    // A round jersey-number badge: gold for the winner, pitch-green otherwise.
    private JComponent badge(int n, boolean top) {
        RoundedPanel b = new RoundedPanel(top ? GOLD : ACCENT, 999);
        Dimension d = new Dimension(28, 28);
        b.setPreferredSize(d);
        b.setMinimumSize(d);
        b.setMaximumSize(d);
        b.setLayout(new GridBagLayout());
        JLabel l = new JLabel(String.valueOf(n));
        l.setFont(ui(Font.BOLD, 13));
        l.setForeground(top ? SCOREBOARD : Color.WHITE);
        b.add(l);
        return b;
    }

    private JComponent noticeRow(String message) {
        Row row = new Row(NOTICE_BG, 14);
        row.setBorderColor(new Color(0xF3, 0xC7, 0xC2));
        row.setBorder(new EmptyBorder(14, 16, 14, 16));
        row.setLayout(new BorderLayout());
        row.setAlignmentX(LEFT_ALIGNMENT);
        row.add(bodyText(message, DANGER), BorderLayout.CENTER);
        return row;
    }

    private JTextArea bodyText(String text, Color fg) {
        JTextArea ta = new JTextArea(text);
        ta.setEditable(false);
        ta.setLineWrap(true);
        ta.setWrapStyleWord(true);
        ta.setOpaque(false);
        ta.setBorder(null);
        ta.setFont(ui(Font.PLAIN, 12));
        ta.setForeground(fg);
        return ta;
    }

    private Color scoreColor(double f) {
        if (f <= 0) return new Color(0xD1, 0xD5, 0xDB);
        Color low = new Color(0xF4, 0x7A, 0x6B);
        Color mid = new Color(0xF5, 0xB2, 0x4B);
        Color high = new Color(0x10, 0xB9, 0x81);
        return f < 0.5 ? lerp(low, mid, f / 0.5) : lerp(mid, high, (f - 0.5) / 0.5);
    }

    private Color lerp(Color a, Color b, double t) {
        t = Math.max(0, Math.min(1, t));
        int r = (int) Math.round(a.getRed() + (b.getRed() - a.getRed()) * t);
        int g = (int) Math.round(a.getGreen() + (b.getGreen() - a.getGreen()) * t);
        int bl = (int) Math.round(a.getBlue() + (b.getBlue() - a.getBlue()) * t);
        return new Color(r, g, bl);
    }

    private String oneLine(String s) {
        return s.replaceAll("\\s+", " ").trim();
    }

    //  Custom components

    /** A simple hand-drawn soccer ball (white with a central pentagon and spokes). */
    static class BallIcon extends JComponent {
        private final int d;
        BallIcon(int d) { this.d = d; setOpaque(false);
            setPreferredSize(new Dimension(d, d)); setMaximumSize(new Dimension(d, d)); }
        @Override protected void paintComponent(Graphics g) {
            Graphics2D g2 = (Graphics2D) g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int size = Math.min(getWidth(), getHeight());
            int x = (getWidth() - size) / 2, y = (getHeight() - size) / 2;
            double cx = x + size / 2.0, cy = y + size / 2.0, r = size / 2.0;
            Color ink = new Color(0x11, 0x1C, 0x16);

            g2.setColor(Color.WHITE);
            g2.fillOval(x, y, size - 1, size - 1);

            double pr = r * 0.42;
            g2.setColor(ink);
            g2.fill(pentagon(cx, cy, pr, -90));

            g2.setStroke(new BasicStroke(Math.max(1f, (float) (size * 0.05))));
            for (double a : new double[]{-90, -18, 54, 126, 198}) {
                double ar = Math.toRadians(a);
                g2.draw(new Line2D.Double(
                        cx + Math.cos(ar) * pr, cy + Math.sin(ar) * pr,
                        cx + Math.cos(ar) * r * 0.97, cy + Math.sin(ar) * r * 0.97));
            }
            g2.setStroke(new BasicStroke(Math.max(1f, (float) (size * 0.045))));
            g2.drawOval(x, y, size - 1, size - 1);
            g2.dispose();
        }
        private static Path2D pentagon(double cx, double cy, double r, double startDeg) {
            Path2D p = new Path2D.Double();
            for (int i = 0; i < 5; i++) {
                double a = Math.toRadians(startDeg + i * 72);
                double px = cx + Math.cos(a) * r, py = cy + Math.sin(a) * r;
                if (i == 0) p.moveTo(px, py); else p.lineTo(px, py);
            }
            p.closePath();
            return p;
        }
    }

    /** The app background: a mowed-grass pitch with faint white markings. */
    static class PitchPanel extends JPanel {
        PitchPanel() { setOpaque(true); }
        @Override protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2 = (Graphics2D) g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int w = getWidth(), h = getHeight();

            int stripes = 9;
            int sw = Math.max(1, w / stripes);
            for (int i = 0; i <= stripes; i++) {
                g2.setColor((i % 2 == 0) ? PITCH_A : PITCH_B);
                g2.fillRect(i * sw, 0, sw + 1, h);
            }
            // faint pitch markings
            g2.setColor(new Color(255, 255, 255, 24));
            g2.setStroke(new BasicStroke(2f));
            int m = 8;
            g2.drawRoundRect(m, m, w - 2 * m, h - 2 * m, 20, 20);
            g2.drawLine(w / 2, m, w / 2, h - m);
            int cr = Math.min(w, h) / 6;
            g2.drawOval(w / 2 - cr, h / 2 - cr, 2 * cr, 2 * cr);
            g2.fillOval(w / 2 - 3, h / 2 - 3, 6, 6);
            g2.dispose();
        }
    }

    static class RoundedPanel extends JPanel {
        private final int radius;
        private final Color fill;
        private Color border;
        RoundedPanel(Color fill, int radius) { this.fill = fill; this.radius = radius; setOpaque(false); }
        void setBorderColor(Color c) { this.border = c; }
        @Override protected void paintComponent(Graphics g) {
            Graphics2D g2 = (Graphics2D) g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int w = getWidth(), h = getHeight();
            int arc = Math.min(radius, Math.min(w, h));
            g2.setColor(fill);
            g2.fillRoundRect(0, 0, w - 1, h - 1, arc, arc);
            if (border != null) { g2.setColor(border); g2.drawRoundRect(0, 0, w - 1, h - 1, arc, arc); }
            g2.dispose();
            super.paintComponent(g);
        }
    }

    static class Row extends RoundedPanel {
        Row(Color fill, int radius) { super(fill, radius); }
        @Override public Dimension getMaximumSize() {
            return new Dimension(Integer.MAX_VALUE, getPreferredSize().height);
        }
    }

    static class ScoreBar extends JComponent {
        private double frac;
        private Color color = new Color(0xD1, 0xD5, 0xDB);
        ScoreBar() { setPreferredSize(new Dimension(150, 10)); }
        void set(double frac, Color color) {
            this.frac = Math.max(0, Math.min(1, frac));
            this.color = color;
            repaint();
        }
        @Override protected void paintComponent(Graphics g) {
            Graphics2D g2 = (Graphics2D) g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int w = getWidth(), h = getHeight(), arc = h;
            g2.setColor(new Color(0xE5, 0xE7, 0xEB));
            g2.fillRoundRect(0, 0, w, h, arc, arc);
            int fw = (int) Math.round(w * frac);
            if (fw > 0) { g2.setColor(color); g2.fillRoundRect(0, 0, Math.max(fw, h), h, arc, arc); }
            g2.dispose();
        }
    }

    static class ScrollableColumn extends JPanel implements Scrollable {
        ScrollableColumn() {
            setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
            setOpaque(false);
            setBorder(new EmptyBorder(6, 6, 6, 6));
        }
        public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); }
        public int getScrollableUnitIncrement(Rectangle r, int o, int d) { return 16; }
        public int getScrollableBlockIncrement(Rectangle r, int o, int d) { return 80; }
        public boolean getScrollableTracksViewportWidth() { return true; }
        public boolean getScrollableTracksViewportHeight() { return false; }
    }

    private static Font ui(int style, int size) { return new Font(UI_FAMILY, style, size); }

    private static String resolveFamily() {
        String[] prefs = {"Segoe UI", "SF Pro Text", "Helvetica Neue", "Inter",
                "Roboto", "DejaVu Sans", "SansSerif"};
        Set<String> avail = new HashSet<>(Arrays.asList(
                GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()));
        for (String p : prefs) if (avail.contains(p)) return p;
        return "SansSerif";
    }

    //  Sample text - 2026 FIFA World Cup
    private static final String SAMPLE_TEXT = """
            # FIFA World Cup 2026

            The 2026 FIFA World Cup is the first edition to feature 48 teams, expanded from the 32-team format used since 1998. It is co-hosted by the United States, Canada, and Mexico across 16 host cities, and runs from June 11 to July 19, 2026. The 48 teams are drawn into 12 groups of four. The top two teams from each group, plus the eight best third-placed teams, advance to a new Round of 32. The final will be played at MetLife Stadium in New York. Argentina enter the tournament as the defending champions.

            # The Offside Rule

            A player is in an offside position if any part of their head, body, or feet is in the opponents' half and nearer to the opponents' goal line than both the ball and the second-last opponent. Being in an offside position is not an offence on its own. However, it is not an offside offence if a player receives the ball directly from a goal kick, a corner kick, or a throw-in. A player is only penalised for offside if, at the moment the ball is played by a teammate, they are actively involved in the play.

            # Penalty Shootouts

            If a knockout match is still level after 90 minutes and 30 minutes of extra time, the winner is decided by a penalty shootout. Each team takes five kicks, with the teams alternating turns. The team that scores more of its five kicks wins. If the teams are still level after five kicks each, the shootout goes to sudden death, and the teams keep taking one kick each until one scores and the other misses in the same round.

            # The Golden Boot

            The Golden Boot is awarded to the tournament's leading goalscorer. If two or more players finish level on the same number of goals, the tie is broken first by the number of assists. If they are still level after that, the award goes to the player who spent the fewest minutes on the pitch.
            """;

    public static void main(String[] args) {
        try {
            for (UIManager.LookAndFeelInfo info : UIManager.getInstalledLookAndFeels()) {
                if ("Nimbus".equals(info.getName())) {
                    UIManager.setLookAndFeel(info.getClassName());
                    break;
                }
            }
        } catch (Exception ignored) { /* default LAF */ }

        SwingUtilities.invokeLater(() -> {
            ChunkingPlayground app = new ChunkingPlayground();
            app.setDefaultCloseOperation(EXIT_ON_CLOSE);
            app.setSize(1100, 840);
            app.setMinimumSize(new Dimension(900, 660));
            app.setLocationRelativeTo(null);
            app.setVisible(true);
        });
    }
}

Experiments to try

Retrieval here is semantic, so try for yourself the following and have fun:

  • Query “can a player be offside from a corner kick?”. On Recursive the top hit has the rule + exception; on By character (window ~70) the exception is stranded.
  • Query “how many teams are in the 2026 World Cup?” . By character, window >1000: watch the top score drop as the fact gets averaged away.
  • Drag the window large → tiny: watch embed/upsert timings climb with the chunk count.

How to choose the right chunk size

There’s no universal number, but there is a reliable way to reason about it. Some pointers, roughly in priority order:

  • Start from your model’s token limit, not a character count. AllMiniLmL6V2EmbeddingModel caps at ~256 tokens and silently truncates beyond that. Never let a chunk exceed the limit, and leave headroom: target ~180–220 tokens so overlap and metadata still fit.
  • Make the splitter’s limit token-based to enforce that ceiling exactly. Our splitters use the two-argument (character) constructors so the slider is intuitive, but every LangChain4j splitter has an overload that counts tokens — you pass a token counter and the size means tokens. For example, DocumentSplitters.recursive(250, 30, tokenCountEstimator) gives real 250-token segments with 30-token overlap. (Recent LangChain4j renamed the old Tokenizer parameter to TokenCountEstimator; pick one whose tokenizer matches your embedding model.) This is how you guarantee no chunk is ever truncated.
  • Aim for one idea per chunk. A chunk should answer one kind of question. If a single chunk mixes the offside rule and penalty shootouts, its vector is an average of two topics and matches both weakly. Align chunk boundaries to semantic units — sections, then paragraphs, then sentences.
  • Keep rules and their exceptions together. Any “unless,” proviso, or illustration must stay with the clause it modifies. This is the single highest-leverage rule for avoiding confidently-wrong retrieval.
  • Use overlap of about 10–20%. It’s cheap insurance so an idea sitting near a boundary survives intact in at least one chunk.
  • Match chunk size to your queries. Short, factual lookups (“how many teams?”) reward smaller, focused chunks. Broad, thematic questions (“summarize the knockout format”) reward somewhat larger chunks that hold more context. If you don’t know, start around 200–300 tokens for prose and 100–150 for dense reference/FAQ material.
  • Mind the cost curve you can see in the app. Smaller chunks mean more points to embed, store, and search. There’s a sweet spot where retrieval quality stops improving but indexing cost keeps rising — find it, don’t overshoot it.
  • Attach metadata to every point. The app stores each chunk’s text and its order in the Qdrant payload; in a real system you’d also add the source document, section title, and character offsets (LangChain4j already puts an index on each TextSegment, and you can enrich further with a TextSegmentTransformer). Metadata is what lets you cite sources — which is how users catch a wrong answer.
  • Measure, don’t guess. Write down 10–20 real questions, run them, and check whether the correct chunk lands in the top results. Chunk size is an empirical knob: tune it against retrieval quality, then stop. This app is a miniature version of exactly that loop.

Summary

Chunking decides whether your embeddings, your Qdrant collection, and your cosine scores have any chance of being right. Qdrant can only return what you put in it, and you only put in what your chunker produced. Split the World Cup rules carelessly and no search will piece the exception back together; split them with respect for structure and token limits, and the right answer floats to the top.


메타데이터
post_id
16bca9cf4000
slug
what-football-can-teach-you-about-chunking-data-16bca9cf4000
url
https://medium.com/@mala.gupta/what-football-can-teach-you-about-chunking-data-16bca9cf4000
canonical_url
https://medium.com/@mala.gupta/what-football-can-teach-you-about-chunking-data-16bca9cf4000
author_url
https://medium.com/@mala.gupta
status
ok
fetched_at
2026-07-20 12:41:23