Startseite / Artikel

Das Chunking ist das gesamte Retrieval-System: Spring AI 800 gegen 128-Falle

Wenn der Splitter 800-Token-Blöcke erzeugt, aber der Embedder nur 128 liest, kann das Retrieval den abgeschnittenen Teil nicht sehen. Blockgröße, Fenster und Überschneidung sind Bestandteile des Retrieval-Systems.

Verlag: REACTAPP.TOP16 Min. Lesezeit3727 Wörter

Quellen: Chunking Is the Whole Retrieval System

Der Standard-Splitter von Spring AI teilt den Text in Blöcke mit etwa 800 Tokenn auf, während das mitgelieferte lokale Embedding-Modell nur die ersten 128 Token jedes Blocks liest. Weder topK, noch ein Ähnlichkeitsschwellenwert oder ein Reranker können Texte sichtbar machen, die niemals in den Vektor aufgenommen wurden.

Der Chunker bestimmt, was jeder Vektor aussagen darf. Jede nach dem Vektorstore folgende Stufe ordnet lediglich die vom Chunker suchbar gemachten Kandidaten um.

Das Problem: Achtzig Token – hundertachtundzwanzig eingebettet

Drei Komponenten von Spring AI 2.0.1 bilden den Index hinter vielen JVM-RAG-Demos, und zwei versteckte Zahlen bestimmen, was dieser Index finden kann:

List<Document> pages = new TikaDocumentReader(handbook).read();
List<Document> chunks = new TokenTextSplitter().apply(pages);
vectorStore.add(chunks);

Die erste Zahl befindet sich im Splitter. new TokenTextSplitter() teilt den Text bei der OpenAI-Kodierung cl100k_base in etwa 800 Token auf und geht anschließend bis zum letzten ., ?, ! oder Zeilenumbruch zurück, sofern noch mindestens etwa 350 Zeichen übrig sind – das entspricht in etwa sechshundert englischen Wörtern pro Teil.

Die zweite Zahl befindet sich im JSON-Datei des Tokenisierers. Wenn der Store das lokale TransformersEmbeddingModel von Spring AI verwendet, lädt er standardmäßig all-MiniLM-L6-v2. Die mitgelieferte tokenizer.json-Datei konfiguriert die Trunkierung wie folgt:

"truncation": { "direction": "Right", "max_length": 128, "strategy": "LongestFirst", "stride": 0 }

DJL 0.36.0, die Tokenisierungsschicht unter Spring AI 2.0.1, behält die Trunkierung bei und berücksichtigt den Wert von max_length. Jeder Chunk-Vektor wird daher aus den ersten 128 WordPiece-Tokenen berechnet, von denen zwei [CLS] und [SEP] sind. Verschiedene Tokenisierer ergeben ein annäherndes, aber nicht präzises Verhältnis – und es bleibt weiterhin unausgeglichen. Der Vektor umfasst in etwa die ersten hundert Wörter eines sechshundert Wörter umfassenden Chunks. Der Rest wird gespeichert, abgerufen und an das Chat-Modell übergeben, nimmt jedoch nie am Suchvorgang teil.

Chunking ist nicht nur eine vorbereitende Maßnahme, die man einmal vor der eigentlichen Anpassung des Retrievers durchführt. Es bestimmt vielmehr, was überhaupt abgerufen werden kann. Wenn man topK von dem Standardwert 4 erhöht, werden mehr Blöcke zurückgegeben, deren Inhalte genauso unsichtbar bleiben können, und es können Tausende von Token in den Prompt eingefügt werden. Ein Cross-Encoder-Reranker ordnet lediglich die bereits von der Vektorsuche zurückgegebenen Kandidaten um; unsichtbarer Text kann nicht priorisiert werden. Bei einem Standardwert von 0.0 für SearchRequest, der bereits alles akzeptiert, bleibt kaum noch Spielraum für Ähnlichkeitsschwellenwerte.

Durch den Wechsel auf OpenAI text-embedding-3-small entfällt das Trunkieren, da die Eingabegrenze dieses Modells um ein Vielfaches höher ist als die der normalen Chunking-Methoden, doch es bleiben weiterhin Probleme – diese werden unten erläutert. Alles nach dem Vektorlagersystem kann nur das umordnen, was der Chunker bereits durchgelassen hat.

Kernarchitekturkonzepte

Vier Fakten darüber, wie aus einem Chunk ein Hit wird, zeigen sich direkt im Verhalten von Spring AI.

Ein Chunk ist ein Vektor, und ein Vektor ist ein Durchschnitt

TransformersEmbeddingModel führt das ONNX-Modell aus, liest last_hidden_state (384 Dimensionen pro Token) und führt eine Mittelwert-Pooling-Operation mit der Attention-Maske durch, wobei Padded-Daten übersprungen werden. Unabhängig von der Länge des Chunks erkennt die Suche einen einzigen Vektor:

v = Σ mᵢ·hᵢ / Σ mᵢ           hᵢ = vector for token i,  mᵢ = 1 for a real token, 0 for padding
score(question, chunk) = cos(q, v)

Durch das Mittelwert-Pooling werden besonders auffällige Phrasen, die hinter dem Trunkierungsfenster liegen, noch weiter abgeschwächt – sie gelangen niemals in die Summe. Die Retrieval-Scores vergleichen den Abfragenvektor mit diesem Durchschnitt, wodurch die wichtigsten Tokens sowohl bei der Embedding-Generierung als auch bei der Ähnlichkeitsberechnung dominieren.

Drei Tokenisierer – und nichts vergleicht sie miteinander

Die Grenzen der Blöcke, die eingegebenen Daten sowie oft auch die Chat-Anfrage verwenden jeweils unterschiedliche Token-Sprachen. Eine praktische Faustregel für lokale MiniLM-Einrichtungen ist es, die Embedding-Token innerhalb des Modellfensters zu halten und dabei Platz für Sonderzeichen einzuplanen:

chunk tokens (embedding tokenizer)  ≤  window − 2        // 126 for the bundled MiniLM tokenizer
prompt tokens  ≈  topK × chunk tokens + system + question + history

Falls der Splitter cl100k_base zählt, während der Embedder WordPiece verwendet, bedeutet „800 Tokens“ nicht automatisch „800 Embedding-Tokens“. Teams, die diese beiden Werte nie vergleichen, erstellen Indizes, die in den Speichermetriken gut aussehen, aber bei Fragen, die nur am Ende der Blöcke beantwortet werden können, versagen.

Ihre Metadaten werden entweder eingebettet oder ignoriert – je nach einer einzigen Enumeration

Die Blöcke enthalten Metadaten wie zum Beispiel:

source: refund-policy.pdf
parent_document_id: 5b1f0c2e-8d7a-4c1e-9f3b-2a6d4e8c1b90
chunk_index: 3
total_chunks: 9

Annual plans are refunded pro rata from the day the request is received...

Ob diese Felder in den eingebetteten String zusammengefügt oder ausgeschlossen werden, hängt von der Konfiguration des Formatters ab:

ContentFormatter formatter = DefaultContentFormatter.builder()
    .withExcludedEmbedMetadataKeys("parent_document_id", "chunk_index", "total_chunks") // bookkeeping, not meaning
    .build();
chunks.forEach(chunk -> chunk.setContentFormatter(formatter));

Das Einbetten von Quelltext-Pfaden oder UUIDs kann den semantischen Raum verschmutzen; das Ausklammern von Überschriften kann das einzige Signal zur Entmischung entfernen. Wählen Sie bewusst zwischen Einbetten und Abrufen von Metadaten – ein einziger Wechsel in der Enumeration beeinflusst sowohl die Erinnerungskraft als auch die Qualität der Zitationen.

Der Standardberater ignoriert Ihre Zitate

Eine typische Frage-Antwort-Sitzung erscheint einfach:

ChatResponse response = chatClient.prompt()
    .user(question)
    .advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
    .call()
    .chatResponse();

List<Document> used = response.getMetadata().get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);
List<String> citations = used.stream()
    .map(d -> d.getMetadata().get("source") + " § " + d.getMetadata().getOrDefault("title", ""))
    .distinct()
    .toList();

Standardberater können den Kontexttext an das Modell weiterleiten, ohne die Quelldaten pro Abschnitt in der strukturierten Antwort beizubehalten. Sie erhalten zwar den richtigen Absatz, können aber die PDF-Seite oder Überschrift nicht anzeigen, es sei denn, Sie passen den Berater an oder verarbeiten die Dokumente vor dem Aufruf nachträglich. Zitationen sind ein Problem des Datenflusses, keine optische Verzierung der Benutzeroberfläche.

Praktischer Implementierungsleitfaden

Messen Sie das Abrufen, bevor Sie den Trenner anfassen

Erstellen Sie eine kleine Bewertungsmenge an Fragen zusammen mit Antwortabschnitten, die im abgerufenen Text vorhanden sein müssen:

public record RetrievalCase(String question, String answerSpan) {}
public final class RetrievalEval {
    private final VectorStore vectorStore;public RetrievalEval(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }
    public double recallAt(int k, List<RetrievalCase> cases) {
        long hits = cases.stream().filter(c -> retrieved(c, k)).count();
        return (double) hits / cases.size();
    }
    private boolean retrieved(RetrievalCase c, int k) {
        List<Document> top = vectorStore.similaritySearch(
            SearchRequest.builder().query(c.question()).topK(k).build());
        String span = normalize(c.answerSpan());
        return top.stream().anyMatch(d -> normalize(d.getText()).contains(span)); // a span split across chunks fails
    }
    private static String normalize(String s) {
        return s == null ? "" : s.replaceAll("\\s+", " ").strip().toLowerCase(Locale.ROOT);
    }
    public static List<RetrievalCase> load(Path tsv) throws IOException {
        return Files.readAllLines(tsv).stream()
            .filter(line -> !line.isBlank())
            .map(line -> line.split("\t", 2))
            .map(p -> new RetrievalCase(p[0], p[1]))
            .toList();
    }
}

Führen Sie den aktuellen Index aus, dokumentieren Sie Treffer oder Fehlschläge bei einer festgelegten Anzahl von topK, und ändern Sie erst danach die Einstellungen des Trenners. Ohne einen Vergleichswert ist jede Anpassung der Chunk-Größe reine Spekulation.

Passen Sie jeden Chunk in das Embedding-Fenster ein

Schützen Sie die Dokumente, damit die Länge der Embedder nicht heimlich gekürzt wird:

public final class EmbeddingWindowGuard implements DocumentTransformer {
private final HuggingFaceTokenizer tokenizer;
    private final int window;
    public EmbeddingWindowGuard(Path tokenizerJson, int window) throws IOException {
        // truncation off, so we measure the real length rather than the clipped one
        this.tokenizer = HuggingFaceTokenizer.newInstance(tokenizerJson, Map.of("truncation", "false"));
        this.window = window;
    }
    public int count(String text) {
        return tokenizer.encode(text).getIds().length; // includes [CLS] and [SEP]
    }
    @Override
    public List<Document> apply(List<Document> chunks) {
        for (Document chunk : chunks) {
            int n = count(chunk.getText());
            if (n > window) {
                throw new IllegalStateException("%s chunk %s is %d tokens; the model reads %d".formatted(
                    chunk.getMetadata().get("source"), chunk.getMetadata().get("chunk_index"), n, window));
            }
        }
        return chunks;
    }
}

Die Konfiguration kann bei Bedarf entsprechende Tokenisierungsoptionen aktivieren:

spring.ai.embedding.transformer.tokenizer.options.maxLength=256

Falls das Modell nur 128 Token sieht, sollten Sie entweder die Chunks verkleinern oder das Modell wechseln. Tun Sie nicht so, als würde ein Reranker fehlende Signale erfinden.

Trennen Sie zunächst nach Struktur, danach nach Größe

Geben Sie Überschriften, Absätze und Listegrenzen Vorrang vor reinen Tokenzählungen:

public final class RecursiveTextSplitter extends TextSplitter {
private static final List<String> SEPARATORS = List.of("\n\n", "\n", ". ", " ");
    private final ToIntFunction<String> tokens;
    private final int maxTokens;
    public RecursiveTextSplitter(ToIntFunction<String> tokens, int maxTokens) {
        this.tokens = tokens;           // the embedding model's tokenizer, not cl100k
        this.maxTokens = maxTokens;
    }
    @Override
    protected List<String> splitText(String text) {
        return split(text, 0).stream().filter(s -> !s.isBlank()).toList();
    }
    private List<String> split(String text, int level) {
        if (tokens.applyAsInt(text) <= maxTokens || level == SEPARATORS.size()) {
            return List.of(text.strip());
        }
        String sep = SEPARATORS.get(level);
        List<String> chunks = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        for (String piece : text.split(Pattern.quote(sep))) {
            String candidate = current.isEmpty() ? piece : current + sep + piece;
            if (tokens.applyAsInt(candidate) <= maxTokens) {
                current.setLength(0);
                current.append(candidate);
                continue;
            }
            if (!current.isEmpty()) {
                chunks.add(current.toString().strip());
                current.setLength(0);
            }
            if (tokens.applyAsInt(piece) <= maxTokens) {
                current.append(piece);
            } else {
                chunks.addAll(split(piece, level + 1)); // only this piece drops to a finer boundary
            }
        }
        if (!current.isEmpty()) {
            chunks.add(current.toString().strip());
        }
        return chunks;
    }
}

Anschließend setzen Sie das Embedding-Fenster durch:

EmbeddingWindowGuard guard = new EmbeddingWindowGuard(Path.of("tokenizer.json"), 128);
TextSplitter splitter = new RecursiveTextSplitter(guard::count, 120); // headroom under the 126 usable
vectorStore.add(guard.apply(splitter.apply(sections)));

Strukturbezogene Aufteilungen bewahren Definitionen zusammen mit ihren Begriffen sowie Schritte zusammen mit ihren Verfahren – genau die Einheiten, nach denen die Benutzer fragen.

Überschneidungen bringen einen Vorteil, doch man zahlt dafür überall den Preis

Überschneidungen erhöhen die Wahrscheinlichkeit, dass ein Grenzbegriff vollständig innerhalb eines bestimmten Abschnitts erscheint, auf Kosten mehrerer Abschnitte:

chunks ≈ ⌈ (N − o) / (s − o) ⌉

s = 800, o = 0    →  about N / 800
s = 800, o = 400  →  about N / 400      // twice the vectors, almost every sentence stored twice

Eine Verdopplung der Überschneidung führt in etwa zu einer Verdopplung der gespeicherten Vektoren sowie der damit verbundenen Rechenkosten. Verwenden Sie eine mäßige Überschneidung, wenn Grenzen Sätze durchtrennen; betrachten Sie eine große Überschneidung nicht als Ersatz für die Anpassung des Embedding-Fensters.

Tabellen und Codeblöcke durchbrechen jede Strategie

Markdown-Tabellen sowie eingefasster Code erfordern eine spezielle Behandlung – z. B. spaltenweise Aufteilungen oder eigene Extraktoren – damit ein Vektor nicht den gesamten Tabellenbereich zu einem unstrukturierten Ganzen zusammenfasst:

static List<String> tableRows(String markdown) {
    Parser parser = Parser.builder().extensions(List.of(TablesExtension.create())).build();
    List<String> rows = new ArrayList<>();
    parser.parse(markdown).accept(new AbstractVisitor() {
        @Override
        public void visit(CustomBlock block) {
            if (!(block instanceof TableBlock table) || !(table.getLastChild() instanceof TableBody body)) {
                visitChildren(block);
                return;
            }
            List<String> headers = cells((TableRow) table.getFirstChild().getFirstChild());
            for (Node row = body.getFirstChild(); row != null; row = row.getNext()) {
                List<String> values = cells((TableRow) row);
                StringJoiner line = new StringJoiner(" · ");
                for (int i = 0; i < Math.min(headers.size(), values.size()); i++) {
                    line.add(headers.get(i) + ": " + values.get(i)); // every value keeps its column name
                }
                rws.add(line.toString());
            }
        }
    });
    return rows;
}

static List<String> cells(TableRow row) {
    List<String> out = new ArrayList<>();
    for (Node cell = row.getFirstChild(); cell != null; cell = cell.getNext()) {
        StringBuilder text = new StringBuilder();
        cell.accept(new AbstractVisitor() {
            @Override public void visit(Text t) { text.append(t.getLiteral()); }
            @Override public void visit(Code c) { text.append(c.getLiteral()); }
        });
        out.add(text.toString().strip());
    }
    return out;
}

Führen Sie Überschriften weiter, damit ein Abschnitt, der mitten in einem Abschnitt beginnt, seinen Kontext behält:

public final class CarryHeadingForward implements DocumentTransformer {
@Override
    public List<Document> apply(List<Document> documents) {
        String heading = null;
        for (Document doc : documents) {
            Object title = doc.getMetadata().get("title");
            if (title != null) {
                heading = title.toString();
            } else if (heading != null) {
                doc.getMetadata().put("title", heading); // code blocks and the prose after them
            }
        }
        return documents;
    }
}

Fehlt ein Überschriftsrahmen, liefern die Abfragen Warteschlangenzeilen zurück, die das Chat-Modell nicht zuordnen kann.

Kleine Einbettungen, große Rückgaben

Ein nützliches Muster besteht darin, das Einbettete vom Zurückgegebenen zu trennen. Indizieren Sie kompakte Abschnitte für die Suche und erweitern Sie sie anschließend zu übergeordneten Abschnitten für die Anfrage:

public record Section(String id, String source, String heading, String body) {}

public List<Section> retrieve(String question) {
    List<Document> children = vectorStore.similaritySearch(
        SearchRequest.builder().query(question).topK(20).build()); // search wide on small chunks
    Map<String, Double> bestByParent = new HashMap<>();
    for (Document child : children) {
        String parentId = (String) child.getMetadata().get("parent_document_id");
        double score = child.getScore() == null ? 0.0 : child.getScore();
        bestByParent.merge(parentId, score, Math::max);
    }
    List<String> top = bestByParent.entrySet().stream()
        .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
        .limit(3)                                                 // answer from a few whole sections
        .map(Map.Entry::getKey)
        .toList();
    return jdbc.sql("SELECT id, source, heading, body FROM section WHERE id IN (:ids)")
        .param("ids", top)
        .query(Section.class)
        .list()
        .stream()
        .sorted(Comparator.comparingInt(s -> top.indexOf(s.id())))  // SQL does not keep our ranking
        .toList();
}

Die Eingabe kann Markdown in Abschnitte zerlegen und sowohl detaillierte als auch übergeordnete Inhalte speichern:

public void ingest(Resource markdown, String source) {
    MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
        .withAdditionalMetadata("source", source)
        .build();
    List<Document> sections = new CarryHeadingForward().apply(new MarkdownDocumentReader(markdown, config).get());
for (Document s : sections) {                          // parents: stored whole, never embedded
        jdbc.sql("INSERT INTO section (id, source, heading, body) VALUES (?, ?, ?, ?)")
            .params(s.getId(), source, s.getMetadata().get("title"), s.getText())
            .update();
    }
    List<Document> children = splitter.apply(sections);  // each child: parent_document_id = its section
    children.forEach(child -> child.setContentFormatter(formatter));
    vectorStore.add(guard.apply(children));              // fail the job rather than truncate silently
}

Die Suche bleibt bei kleinen Einbettungen präzise; die Generierung erhält einen breiteren Kontext, ohne dass der Vektor mit Token gefüllt wird, die vom Einbettungsmodul ignoriert würden.

Mittelwert-Pooling und Aufmerksamkeitsmasken in der Praxis

Nach dem ONNX-Forward-Pass hat jedes überlebende Token einen versteckten Vektor. Durch Mean-Pooling werden diese Vektoren addiert und durch die Anzahl der unmaskierten Positionen geteilt. Padding-Tokens tragen nichts bei, wenn die Maske korrekt ist; abgeschnittene Tokens tragen ebenfalls nichts bei, da sie im Forward-Pass nie existierten. Man geht manchmal davon aus, „das Modell habe das Document-Objekt gelesen“. Tatsächlich hat es die Ausgabe des Tokenisierers gelesen. Wenn Ihre Protokollierung den vollständigen Text eines Chunk neben einem Vektor anzeigt, betrachten Sie gleichzeitig die Speicherrealität und die Suchrealität – und diese stimmen nur dann überein, wenn die Länge unter dem Fensterlimit liegt.

Die Ähnlichkeitssuche ordnet anschließend nach Entfernung oder Kosinusähnlichkeit gegenüber diesem gepoolten Vektor. Eine Abfrage nach einer Rückerstattungsausnahme, die sich in der zweiten Hälfte eines 800-Token umfassenden Policy-Chunks befindet, hat schlechte Chancen gegenüber Chunks, die Rückerstattungen bereits zu Beginn erwähnen. Die Autoren haben keine schlechten Policies verfasst; der Index weigerte sich lediglich, die Ausnahmeklausel zu erkennen.

Advisor-Pipelines und Zitierhygiene

Chat-Clienten, die Suchadvisor verwenden, fügen oft den Inhalten von Dokumenten zu einem einzigen Kontextstring zusammen. Sofern Schlüssel aus der Metadatenkarte nicht im Antwortobjekt oder in absichtlich eingefügten Zitiermarkern im Prompt-Template übernommen werden, kann das Modell zwar korrekt antworten, die Anwendung kann jedoch den entsprechenden Abschnitt im Handbuch nicht hervorheben. Behandeln Sie Zitelfelder in Tests als Teil des Suchvertrags: Prüfen Sie nach jeder Antwort, ob jede verwendete Chunk-ID auf source, chunk_index sowie ggf. den Überschriftstext verweist.

Kundenspezifische Advisor können Fußnoten oder JSON-Blöcke einfügen, die von der Benutzeroberfläche interpretiert werden können. Standard-Demos überspringen diese Funktionalität, weshalb viele interne RAG-Tools mit „Antworten ohne Links“ ausgeliefert werden.

Die Ökonomie der Überlappung jenseits der Formel

Die Formel für die Anzahl der Blöcke bei Überschneidungen ist ein Planungstool, keine Qualitätsgarantie. Überschneidungen helfen, wenn ein Satz eine Grenze überschreitet; sie können jedoch keine Tokens wiederherstellen, die vom Embedder weggelassen wurden. Wenn man für Überschneidungen bezahlt, während max_length auf 128 festgehalten wird, verschwendet man Geld für unsichtbare Teile. Beheben Sie zunächst das Problem des Fensterpassens und fügen Sie anschließend nur geringe Überschneidungen hinzu, falls bei den Bewertungen weiterhin Grenzüberschreitungen auftreten.

Details zur strukturorientierten Aufteilung

Rekursive Trenner, die zuerst Überschriften, dann Absätze, anschließend Sätze und schließlich rohe Zeichen versuchen, funktionieren weiterhin einwandfrei. Trenner, die Überschriften berücksichtigen, behalten „## Refunds“ samt seinen Aufzählungspunkten bei. Absatzgetrennte Vorgehensweisen bewahren die Kohärenz der Argumente. Zeichentrennungen sind ein letztes Mittel und sollten bei dominanter Verwendung in einem Korpus die Eingabetests scheitern lassen – dies signalisiert, dass die Dokumente keine Struktur aufweisen, die es lohnt, sie als Prosa zu indizieren. Für Markdown im API-Referenzkontext sollten Sie in Erwägung ziehen, an ##-Endpunkten zu trennen, sodass jede Methode ein eigenes Vektor ist.

Embed-small/return-big-Ausgabeweg

Zur Abfragerzeit liefert die Ähnlichkeitssuche nur kleine Abschnitts-ID‑Werte. Eine Verknüpfungstabelle oder eine Eltern‑ID in den Metadaten lädt dann den umfassenderen Abschnittstext für die Anfrage. Dieses Muster ermöglicht es MiniLM, sich auf enge semantische Einheiten zu konzentrieren, während das große Sprachmodell die umliegenden Erklärungen berücksichtigen kann. Die Implementierungskosten bestehen aus einem zusätzlichen Speicher oder einem Eltern‑Payload‑Feld – was günstiger ist als zu behaupten, ein 128-Token‑Fenster verstehe eine ganze Seite.

Bewertungskultur

Goldene Datensätze sollten Fragen enthalten, deren Antworten sich am Anfang, in der Mitte und am Ende langer Abschnitte befinden. Selbst wenn nur Antworten an den Anfangsstellen akzeptiert werden, bleibt das Problem der Trunkierung bestehen. Fügen Sie mindestens eine fragenorientierte Frage zu Tabellen und eine fragenorientierte Frage zu Code hinzu, sofern diese Formate im Korpus vorkommen. Markieren Sie langsame Tests, die ONNX-Gewichte herunterladen, damit der CI sie bei Standard‑PR-Ausführungen überspringen kann, während die nächtlichen Builds weiterhin Prüfungen auf Vektorempfindlichkeit durchführen.

Konfiguration und Best Practices

Stellen Sie die Regeln für den Trenn-Token-Algorithmus in Einklang mit dem Tokenisierer des Embedders, behalten Sie die Inhaltsteile der Blöcke innerhalb des Embedding-Fensters, ausschließen Sie störende Metadaten aus den Embeddings, behalten sie aber für Zitate bei, bevorzugen Sie strukturelle Trennungen, verwenden Sie Überlappungen sparsam und behandeln Sie Tabellen sowie Code als Sonderfall. Führen Sie nach jeder Änderung einen festgelegten Evaluations-Testkatalog erneut durch.

Teststrategie

Automatisierte Tests sollten die Eigenschaften sichern, die in Demos üblicherweise übersehen werden.

Beweisen Sie, dass das Ende eines Blocks seinen Vektor verändert

@Test
@Tag("slow") // downloads the ONNX model on the first run
void theEndOfAChunkReachesTheVector() throws Exception {
    TransformersEmbeddingModel model = new TransformersEmbeddingModel();
    model.afterPropertiesSet();

String chunk = Files.readString(Path.of("src/test/resources/chunks/longest-chunk.txt"));
    float[] original = model.embed(chunk);
    float[] extended = model.embed(chunk + " The refund window is thirty days.");
    assertThat(cosine(original, extended))
        .as("identical vectors mean the appended sentence was truncated away")
        .isLessThan(0.9999);
}
static double cosine(float[] a, float[] b) {
    double dot = 0, na = 0, nb = 0;
    for (int i = 0; i < a.length; i++) {
        dot += a[i] * b[i];
        na += a[i] * a[i];
        nb += b[i] * b[i];
    }
    return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

Falls das Hinzufügen von charakteristischem Text außerhalb des Fensters den Vektor nicht verschiebt, ist eine Trunkierung bestätigt.

Beweisen Sie, dass die Trefferquote nicht sinkt, wenn sich der Blocker ändert

@Test
void recallAtFiveHoldsTheFloor() throws IOException {
    List<RetrievalCase> cases = RetrievalEval.load(Path.of("src/test/resources/retrieval-cases.tsv"));
    double recall = new RetrievalEval(vectorStore).recallAt(5, cases);

assertThat(recall).isGreaterThanOrEqualTo(RECALL_AT_5_FLOOR); // recorded from the current chunker; only ever raised
}

Halten Sie die Trefferquote@K bei den Experimenten mit dem Trenn-Algorithmus auf einem Mindestwert, damit „Verbesserungen“, die bekannte Antworten zerstören, nicht unbemerkt zusammengeführt werden können.

Überprüfen Sie, dass jeder indizierte Block zitiert werden kann

@Test
void everyIndexedChunkCarriesItsSourceAndHeading() {
    List<Document> sample = vectorStore.similaritySearch(
        SearchRequest.builder().query("refund").topK(50).build());

assertThat(sample).allSatisfy(d ->
        assertThat(d.getMetadata()).containsKeys("source", "title", "parent_document_id"));
}

Beispiele für Ähnlichkeitsergebnisse sowie Überprüfung darauf, dass die source-Daten und Überschriftsmetadaten erhalten bleiben. Ein unauffälliger Verlust von Zitaten gilt als Produktfehler, selbst wenn der Antworttext korrekt erscheint.

Ressourcen

Dokumentation zu Spring AI für Splitter, Embedding-Modelle und Berater; die ONNX MiniLM-Modellkarte samt tokenizer.json; das Verhalten des DJL-Tokenizers; sowie eigene Evaluierungswerkzeuge für die Informationsabrufung. Die wichtige Lektion ist architektonischer Natur: Die Qualität der Informationsabrufung beginnt bereits bei der Aufteilung in Blöcke. Die Anpassung von topK kann keinen Text wiederherstellen, den das Embedding-Modell nie gesehen hat.

Warum Demos die Abweichungen verbergen

Tutorials loben die dreizeilige Indizierung, weil sie auf einem Laptop laufen kann und zumindest eine einige Antwort liefert. Sie geben selten Konfigurationen der Tokenisierer an oder vergleichen die Länge von WordPiece mit der Länge des Splitters. Dann erscheinen Produktionsfehler als „RAG ist unzuverlässig“, obwohl der Index einfach nie den Antwortabschnitt eingebettet hat. Der Aufwand von einem Tag für Evaluationswerkzeuge vor der Erstellung von Prompten verkürzt in der Regel das gesamte Projekt.

Operative Checkliste

  1. Geben Sie die max_length-Einstellung des Embedders sowie den Overhead für Sonderzeichen an.
  2. Messen Sie die mediane und p95-Wert-Längen der Token im Tokenisierer des Embedders.
  3. Verweigern Sie die Aufnahme, wenn ein Chunk die zulässige Grenze überschreitet.
  4. Bewahren Sie eine „goldene“ Fragebasis mit Antwortabschnitten und Erwartungen bezüglich der Zitierweise auf.
  5. Führen Sie nach jeder Änderung am Splitter oder an den Metadaten Tests zur Erinnerungsleistung und zu den Zitieranforderungen durch.
  • Wählen Sie embed-small/return-big, wenn der übergeordnete Kontext für die Generierung wichtig ist.
  • Teams, die diese Schritte als optional betrachten, entdecken sie erneut während einer Vorführung für Führungskräfte, wenn die Antwort im Handbuch auf Seite zwei eines 800-Token-Blöcks zu finden ist.

    Fazit

    Das Chunking stellt das Informationsengpass des Abrufsystems dar. Vektoren bilden ein Durchschnitt dessen, was ihnen gezeigt wird; sie können nicht unbekannte Elemente erfinden. Stellen Sie die Token-Sprachen in Einklang, passen Sie das Fenster an, teilen Sie den Inhalt nach Struktur auf, handhaben Sie Tabellen, bewahren Sie Zitate bei und belegen Sie das Verhalten mit Tests. Erst dann können topK, Schwellenwerte und Neurankierer auf einer Kandidatenmenge arbeiten, die dies verdient.

    Tokenisierungsunterschiede als eigenständige Fehlerklasse

    Kulturen im Engineering, die „800 Tokens“ als universelle Einheit betrachten, werden die Chunk-Größen für immer falsch festlegen. Gehen Sie sowohl die Splitter-Kodierung als auch die Embedder-Kodierung in den Startprotokollen auf. Konvertieren Sie eine Stichprobe der Produktions-Chunks mithilfe beider Tokenisierer sowie anhand der Histogrammlängen. Wenn der p95-Wert des Embedders den Wert max_length - specials überschreitet, trinken Sie in der Produktion Daten ab, selbst wenn die durchschnittliche Länge in Ordnung erscheint.

    Teams, die von OpenAI-Embeddings zu lokalen MiniLM-Modellen migrieren (oder umgekehrt), müssen nicht nur neu einbetten, sondern auch die Chunks neu strukturieren. Ein Korpus, der für ein bestimmtes Fenster konzipiert wurde, eignet sich nicht für das andere.

    Warum topK und Reranker keine Ausgleichsmöglichkeit bieten

    Die Erhöhung von topK steigert die Wahrscheinlichkeit, dass ein einigermaßen relevanter Teilabschnitt abgerufen wird, doch jeder Abschnitt verbirgt weiterhin seinen eigenen abgeschnittenen Teil. Die Prompt-Budgets explodieren: Zehn 800-Token-Abschnitte entsprechen Tausenden von Token an meist nie durchsuchtem Text. Cross-Encoder, die nur den vollständigen Abschnittstext bewerten, sehen nur die Kandidaten, die bereits in der Vektorphase vorgeschlagen wurden; sie können keinen Abschnitt bevorzugen, dessen suchbarer Präfix niemals übereinstimmte. Schwellwerte nahe Null akzeptieren bereits fast alles, was die Vektorsuche liefert; ihre Verschärfung führt zum Aussortieren von Kandidaten, ohne die unsichtbaren Abschnitte wiederherzustellen.

    Die Lösung liegt bei der Eingabe: Anpassen des Fensters, Ändern des Modells oder Aufteilen, bis eine passende Anpassung gewährleistet ist.

    Metadatenrichtlinienmatrix

    Entscheiden Sie pro Feld: Einbetten, nur Abrufen oder Ignorieren. Pfade und UUIDs verschmutzen in der Regel die Einbettungen. Überschriften helfen oft bei der Entschärfung von Mehrdeutigkeiten und sollten manchmal eingebettet sowie stets abgerufen werden. Boolesche Werte wie deprecated gehören eher in Filter als in den Text. Dokumentieren Sie die Matrix neben dem Formatter-Builder, damit zukünftige Änderungen nicht versehentlich Enums ändern.

    Zitierverträge in Spring AI Advisors

    Falls der Standard-Q&A-Advisor Dokumente in Strings umwandelt, umhüllen oder ersetzen Sie ihn, sodass jeder verwendete Teil source, chunk_index sowie die Überschrift beibehält. UI-Fußnoten sollten auf diese Felder verweisen. Fügen Sie einen Test hinzu, der fehlschlägt, wenn Zitate leer sind, obwohl der Kontext nicht leer ist. Das Vertrauen in das Produkt hängt mehr davon ab als von BLEU-Scores.

    Rezepte für strukturelle Aufteilung

    Für Handbuch-Markdown: Trennen Sie an ##, dann folgen die Absätze. Für javadoc-ähnliche Kommentare: Trennen Sie an den Grenzen der Deklarationen. Für Protokolle: Embedden Sie nicht ganze Stack-Informationen als ein Stück; trennen Sie an Zeitstempeln oder Fehleridentifikatoren. Für Tabellen: Ein Zeile oder ein logisches Datensatz pro Stück, wobei die Spaltenüberschriften als Präfixtext mitübernommen werden. Für Code: Wählen Sie eine Funktionsgranularität statt willkürlicher Token-Fenster, damit Abfragen am Aufrufort ganze Funktionen abrufen können.

    Überschneidung als chirurgisches Werkzeug

    Verwenden Sie Überschneidungen, wenn die Bewertung zeigt, dass Grenzen bei Sätzen übersprungen werden, die sich über die Schnittstellen erstrecken. Fünf bis fünfzehn Prozent der Chunk-Größe bilden einen gängigen Ausgangspunkt; messen Sie zunächst die Speicher- und Embed-Kosten, bevor Sie auf fünfzig Prozent wechseln. Überschneidungen beheben niemals das Trunkierungsproblem; sie duplizieren lediglich sichtbare Präfixe, wenn das Fenster bereits voll ist.

    Embed-small/return-big in der Praxis

    Speichern Sie section_id, parent_id sowie body_small / body_full (oder fügen Sie sie einer Sections-Tabelle hinzu). Suchen Sie nach Embeddings von body_small; verwenden Sie body_full zur Anfrage. Überwachen Sie Fälle, in denen kurze Texte nicht genügend Signale enthalten – passen Sie dann den Splitter an, nicht nur topK.

    Testsuite zur Festlegung des Verhaltens

    • Sensitivität gegenüber Trunkierung: Verändern Sie den Chunk-Suffix außerhalb des Fensters; überprüfen Sie die Richtlinie zur Vektoränderung (sie sollte unverändert bleiben, wenn getrunkiert wird).
    • Mindest-Rückgabewahrscheinlichkeit: Prüfen Sie „goldene“ Fragen mit Abschnitten im Anfang/Mittel/Ende; verwerfen Sie Builds, bei denen die Rückgabewahrscheinlichkeit im Mittel/Ende nach Änderungen des Chunkers sinkt.
    • Überleben von Zitaten: Prüfen Sie die Stichprobenabrufung; stellen Sie sicher, dass die Metadaten Schlüssel vorhanden sind.
    • Längenschutz: Die Eingabe fehlschlägt, wenn die Anzahl der Token des Embedders den sicheren Maximalwert überschreitet.

    Schleppende Tests, die ONNX-Gewichte herunterladen, gehören in die nightly-Pipelines; behalten Sie einen vereinfachten Unit-Path für PR CI bei.

    Operative Checkliste (erweitert)

    Stimmen Sie die Token-Sprachen ab; gewährleisten Sie eine passende Fenstergröße; führen Sie Trennungen mit vorrangiger Strukturierung durch; nutzen Sie spärliche Überschneidungen; beauftragen Sie Spezialisten für Tabellen und Code; erstellen Sie eine Metadatenmatrix; nutzen Sie Berater, die Zitierungen bewahren; führen Sie eine Gold-Evaluation vor und nach Änderungen durch; verwenden Sie „embed-small/return-big“, wenn die Generierung Kontext benötigt, den der Embedder nicht sehen kann. Das Chunking bleibt das Engpassproblem des Retrieval-Systems – nehmen Sie dies explizit in Angriff.