Le chunking constitue l’ensemble du système de récupération : le piège Spring AI 800 contre 128
Lorsque le splitter génère des blocs de 800 tokens tandis que l’embedder n’en lit que 128, le système de récupération ne peut pas prendre en compte la partie tronquée. La taille des blocs, la fenêtre et l’overlap font partie intégrante du système de récupération.
Le séparateur par défaut de Spring AI divise le texte en blocs d’environ 800 tokens, tandis que le modèle d’incorporation local fourni ne lit que les 128 premiers tokens de chacun d’eux. Aucun paramètre
topK, seuil de similarité ou outil de réordonnancement ne peut faire apparaître du texte qui n’a jamais été intégré au vecteur.
C’est le séparateur qui décide de ce que chaque vecteur est autorisé à représenter. Chaque étape suivante, après le stockage des vecteurs, se contente de réorganiser les éléments que le séparateur a rendus recherchables.
Le problème : huit cents tokens, cent vingt-huit seulement incorporés
Trois lignes de code dans Spring AI 2.0.1 constituent l’index utilisé dans de nombreuses démonstrations RAG pour JVM, et deux valeurs cachées déterminent ce que cet index peut trouver :
List<Document> pages = new TikaDocumentReader(handbook).read();
List<Document> chunks = new TokenTextSplitter().apply(pages);
vectorStore.add(chunks);
Le premier chiffre se trouve dans le splitter. La fonction new TokenTextSplitter() divise le texte en environ 800 tokens selon l’encodage cl100k_base d’OpenAI, puis remonte jusqu’au dernier ., ?, ! ou saut de ligne si au moins 350 caractères restent — soit environ six cents mots en anglais par bloc.
Le second chiffre se trouve dans le fichier JSON du tokenizer. Lorsque le système utilise le modèle local TransformersEmbeddingModel de Spring AI, il charge par défaut all-MiniLM-L6-v2. Le fichier tokenizer.json fourni configure la troncation de la manière suivante :
"truncation": { "direction": "Right", "max_length": 128, "strategy": "LongestFirst", "stride": 0 }
DJL 0.36.0, la couche de tokenisation intégrée à Spring AI 2.0.1, maintient la troncature activée et respecte la valeur de max_length. Chaque vecteur de chunk est donc calculé à partir des 128 premiers tokens WordPiece, deux d’entre eux étant [CLS] et [SEP]. Les différents tokeniseurs donnent un ratio approximatif, non précis, et encore déséquilibré. Le vecteur ne prend en compte que les premiers cent mots d’un chunk de six cents mots. Le reste est stocké, récupéré et transmis au modèle de chat, mais il n’a jamais participé à la recherche.
Le découpage n’est pas une simple étape de prétraitement effectuée une fois avant l’ajustement réel du moteur de recherche. Il détermine ce qui peut être récupéré. Augmenter la valeur de topK par rapport à sa valeur par défaut de 4 génère davantage de fragments dont les parties finales restent tout aussi invisibles, ce qui peut ajouter des milliers de tokens dans le prompt. Un réordonneur à base d’encodeur croisé ne fait que reclasser les candidats déjà retournés par la recherche vectorielle ; le texte invisible ne peut pas être mis en avant. Les seuils de similarité disposent alors de peu d’espace, étant donné que SearchRequest accepte déjà par défaut tout ce qui atteint une similarité de 0.0.
Le passage à OpenAI text-embedding-3-small élimine le problème de troncature, car sa limite d’entrée est bien supérieure à celle des fragments, mais des modes de défaillance plus subtils persistent — abordés ci-dessous. Tout ce qui se trouve après le stockage vectoriel ne peut que réordonner ce que le découpeur a permis de passer.
Concepts architecturaux fondamentaux
Quatre faits sur la manière dont un chunk devient un résultat pertinent se reflètent directement dans le comportement de Spring AI.
Un chunk correspond à un vecteur, et un vecteur est une moyenne
TransformersEmbeddingModel exécute le modèle ONNX, lit last_hidden_state (384 dimensions par token) et effectue une agrégation par moyenne avec le masque d’attention en ignorant les éléments de remplissage. Peu importe la longueur du chunk, la recherche considère un seul vecteur :
v = Σ mᵢ·hᵢ / Σ mᵢ hᵢ = vector for token i, mᵢ = 1 for a real token, 0 for padding
score(question, chunk) = cos(q, v)
L’agrégation par moyenne dilue encore davantage les phrases distinctives situées après la fenêtre de troncature : elles ne font jamais partie de la somme. Les scores de récupération comparent le vecteur de la requête à cette moyenne, de sorte que les tokens principaux dominent à la fois ce qui est intégré et ce que la similarité peut détecter.
Trois tokeniseurs, et rien ne les compare
Les limites des blocs, les entrées d’incorporation et souvent le prompt de chat utilisent chacun des langages de tokens différents. Une règle pratique pour les configurations locales de MiniLM est de conserver les tokens d’incorporation à l’intérieur de la fenêtre du modèle, en laissant de la place pour les caractères spéciaux :
chunk tokens (embedding tokenizer) ≤ window − 2 // 126 for the bundled MiniLM tokenizer
prompt tokens ≈ topK × chunk tokens + system + question + history
Si le séparateur compte des cl100k_base tandis que l’incorporeur utilise WordPiece, « 800 tokens » ne signifie pas « 800 tokens d’incorporation ». Les équipes qui ne comparent jamais les deux génèrent des index qui semblent en bon état selon les métriques de stockage, mais échouent sur des questions répondues uniquement dans les parties finales des blocs.
Vos métadonnées sont incorporées ou ignorées, en fonction d’une valeur enum
Les blocs contiennent des métadonnées telles que :
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...
Le fait que ces champs soient concaténés dans la chaîne incorporée ou exclus dépend de la configuration du formatteur :
ContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("parent_document_id", "chunk_index", "total_chunks") // bookkeeping, not meaning
.build();
chunks.forEach(chunk -> chunk.setContentFormatter(formatter));
L’inclusion de chemins source ou d’UUID peut polluer l’espace sémantique ; l’exclusion des titres peut supprimer le seul signal permettant d’éviter les ambiguïtés. Choisissez consciemment entre l’incorporation et la récupération des métadonnées — un simple changement dans l’énumération affecte à la fois la qualité de la récupération et celle des citations.
L’assistant par défaut supprime vos citations
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();
Guide pratique pour la mise en œuvre
Mesurez la récupération avant d’intervenir sur le séparateur
Créez un petit ensemble d’évaluation composé de questions associées à des extraits de réponses qui doivent figurer dans le texte récupéré :
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();
}
}
Faites fonctionner l’index actuel, enregistrez les résultats positifs ou négatifs à un niveau topK fixe, et ne modifiez les paramètres de séparation qu’ensuite. Sans référence de base, toute modification de la taille des blocs n’est que spéulation.
Intégrez chaque bloc dans la fenêtre d’incorporation
Protégez les documents afin que la longueur générée par l’incorporateur ne puisse pas les tronquer discrètement :
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;
}
}
La configuration peut activer des options de tokenisation pertinentes le cas échéant :
spring.ai.embedding.transformer.tokenizer.options.maxLength=256
Si le modèle ne voit que 128 tokens, réduisez la taille des blocs pour qu’ils tiennent ou changez de modèle. Ne prétendez pas qu’un réordonneur pourra inventer des signaux manquants.
Séparez d’abord selon la structure, puis selon la taille
Préférez les titres, les paragraphes et les limites des listes aux simples comptages de tokens bruts :
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;
}
}
Puis appliquez les contraintes de la fenêtre d’incorporation :
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)));
Les divisions tenant compte de la structure conservent les définitions avec leurs termes et les étapes avec leurs procédures — exactement les unités dont les utilisateurs demandent des informations.
L’overlap apporte un avantage, mais il a un coût partout
L’overlap augmente les chances qu’une expression de limite apparaisse entièrement à l’intérieur d’un bloc, au détriment du nombre de blocs générés :
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
Doubler l’overlap double approximativement le nombre de vecteurs stockés ainsi que les coûts liés à leur génération. Utilisez un overlap modéré lorsque les limites coupent des phrases ; ne remplacez pas une fenêtre d’embedding adaptée par un niveau d’overlap élevé.
Les tableaux et les blocs de code contrecarrent toutes les stratégies
Les tableaux Markdown et les blocs de code nécessitent un traitement spécialisé — des divisions par ligne ou des outils d’extraction dédiés — afin qu’un vecteur ne mélange pas tout un tableau en une masse homogène :
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;
}
Passez les en-têtes à l’avant afin qu’un bloc commençant au milieu d’une section conserve son contexte :
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;
}
}
Sans en-tête de transport, la récupération renvoie des lignes orphelines que le modèle de chat ne peut pas localiser.
Intégrer de petits éléments, renvoyer des éléments importants
Un schéma utile consiste à séparer ce que l’on intègre de ce que l’on renvoie. Indexer les sections compactes pour la recherche, puis étendre aux sections parentes pour le prompt :
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();
}
L’ingestion peut parser du Markdown en sections et stocker à la fois des éléments détaillés et les contenus parentaux :
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
}
La recherche reste précise avec de petits embeddings ; la génération reçoit un contexte plus large sans que le vecteur ne soit surchargé de tokens que l’embedder ignorerait.
Pooling des moyennes et masques d’attention en pratique
Après le passage en avant de l’ONNX, chaque token survivant possède un vecteur caché. Le pooling par moyenne additionne ces vecteurs puis les divise par le nombre de positions non masquées. Les tokens de remplissage ne contribuent rien lorsque la masque est correcte ; les tokens tronqués ne contribuent rien non plus car ils n’ont jamais existé lors du passage en avant. On suppose parfois que « le modèle a lu l’objet Document ». En réalité, il a lu la sortie du tokenizer. Si votre journalisation affiche le texte complet d’un chunk à côté d’un vecteur, vous observez en même temps la réalité du stockage et celle de la recherche — et elles ne coïncident jamais lorsque la longueur dépasse la fenêtre autorisée.
La recherche de similarité classe ensuite les résultats en fonction de la distance ou de la similarité cosinus par rapport à ce vecteur agrégé. Une requête concernant une exception de remboursement cachée dans la seconde moitié d’un chunk de politique composé de 800 tokens a peu de chances de rivaliser avec des chunks mentionnant les remboursements dès le début. Les auteurs n’ont pas rédigé de politiques mauvaises ; c’est l’index qui refuse de prendre en compte la clause d’exception.
Pipelines de conseillers et hygiène des citations
Les clients de chat qui utilisent des conseillers de récupération concatènent souvent le contenu des documents en une seule chaîne de contexte. À moins que les clés du tableau de métadonnées ne soient conservées dans l’objet de réponse ou dans des marqueurs de citation délibérés dans le modèle de prompt, le modèle peut donner une réponse correcte tandis que l’application ne parvient pas à mettre en évidence la section correspondante du manuel. Dans les tests, considérez les champs de citation comme faisant partie des conditions du contrat de récupération : après chaque réponse, vérifiez que chaque identifiant de fragment utilisé renvoie bien à source, chunk_index et, le cas échéant, au titre correspondant.
Les conseillers personnalisés peuvent insérer des notes de bas de page ou des blocs JSON que l’interface utilisateur peut parser. Les démos par défaut omettent cette fonctionnalité, d’où le fait que de nombreux outils RAG internes soient fournis avec des « réponses sans liens ».
Économie de l’overlap au-delà de la formule
La formule de plafond pour le nombre d’éléments en cas de chevauchement est un outil de planification, et non une garantie de qualité. Le chevauchement est utile lorsque une phrase dépasse une limite ; il ne peut pas restaurer les tokens que l’outil d’incorporation a supprimés. Payer pour un chevauchement tout en laissant max_length à 128 revient à dépenser de l’argent pour dupliquer des parties invisibles. Corrigez d’abord le problème de taille de la fenêtre, puis ajoutez un petit chevauchement si des fuites à la limite persistent lors des évaluations.
Détails de la segmentation axée sur la structure
Les séparateurs récursifs qui tentent d’abord les titres, puis les paragraphes, ensuite les phrases, et enfin les caractères bruts fonctionnent de manière fiable. Les séparations tenant compte des titres conservent « ## Remboursements » avec ses points à puces. Les séparations par paragraphes maintiennent la cohésion des arguments. Les séparations par caractères constituent un dernier recours qui devrait échouer aux tests d’ingestion s’ils dominent un corpus, indiquant que les documents manquent de structure suffisante pour être indexés en tant que prose. Pour la référence API Markdown, envisagez de séparer les contenus aux points de terminaison ## afin que chaque méthode constitue un vecteur distinct.
Chemin de récupération Embed-small/return-big
Lors de la requête, la recherche par similarité renvoie des IDs de petites sections. Une table de jointure ou un ID parent dans les métadonnées charge le corps complet de la section correspondante. Ce schéma permet à MiniLM de se concentrer sur des unités sémantiques restreintes, tandis que le LLM prend en compte les explications environnantes. Le coût d’implémentation consiste en un second stockage ou un champ de charge parentale — ce qui est moins cher que de prétendre qu’une fenêtre de 128 tokens comprend une page entière.
Culture d’évaluation
Les ensembles de référence devraient inclure des questions dont les réponses se trouvent au début, au milieu et à la fin de longues sections. Même si seules les réponses situées en tête sont acceptées, le troncement reste problématique. Incluez au moins une question axée sur les tableaux et une question axée sur le code si ces formats apparaissent dans le corpus. Marquez les tests lents qui téléchargent des poids ONNX afin que CI puisse les ignorer lors des exécutions par défaut de PR, tandis que les builds nocturnes continuent d’appliquer des vérifications de sensibilité vectorielle.
Configuration et bonnes pratiques
Alignez les règles de segmentation des tokens avec celles du tokeniseur de l’embedder, conservez le contenu des blocs à l’intérieur de la fenêtre d’embedding, excluez les métadonnées bruyantes des embeddings tout en les conservant pour les citations, privilégiez les séparations structurées, utilisez l’overlap avec modération, et traitez spécifiquement les tableaux et le code. Réexécutez un ensemble d’évaluation fixe après chaque modification.
Stratégie de test
Les tests automatisés doivent vérifier les propriétés que les démonstrations ont généralement tendance à ignorer.
Prouvez que la fin d’un bloc modifie son vecteur
@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));
}
Si l’ajout de texte distinctif en dehors de la fenêtre ne modifie pas le vecteur, la troncation est confirmée.
Prouvez que le taux de rappel ne diminue pas lors du changement du chunker
@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
}
Maintenez une limite minimale pour le taux de rappel@K lors des expériences de segmentation afin que les « améliorations » qui détruisent des réponses connues ne puissent pas passer inaperçues.
Vérifiez que chaque bloc indexé peut être cité
@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"));
}
Exemples de résultats de similarité montrant que les métadonnées source et des titres sont préservées. Une absence de citation silencieuse constitue un défaut du produit, même si le texte de réponse semble correct.
Ressources
La documentation Spring AI concernant les outils de segmentation, les modèles d’incorporation et les conseillers ; la fiche du modèle ONNX MiniLM ainsi que son fichier tokenizer.json ; le comportement du tokenizer DJL ; ainsi que vos propres outils d’évaluation de la récupération. La leçon essentielle est architecturale : la qualité de la récupération débute par la segmentation en blocs. Ajuster topK ne permet pas de récupérer du texte que l’outil d’incorporation n’a jamais vu.
Pourquoi les démos cachent les incohérences
Les tutoriels mettent en avant l’indexation à trois lignes car elle fonctionne sur un ordinateur portable et fournit une réponse partielle. Ils impriment rarement les configurations des outils de tokenisation ou ne comparent pas la longueur des éléments WordPiece avec celle des séparateurs. Les échecs en environnement de production semblent alors provenir d’un RAG peu fiable, alors que l’index n’a tout simplement jamais intégré la portion de réponse concernée. Consacrer une journée à l’élaboration d’outils d’évaluation avant de se lancer dans la création de prompts raccourcit généralement le délai global du projet.
Liste de contrôle opérationnelle
- Imprimer la valeur
max_lengthde l’outil d’incorporation ainsi que la consommation supplémentaire liée aux tokens spéciaux. - Mesurer la médiane et la valeur p95 des longueurs des tokens dans le tokenizer de l’outil d’incorporation.
- Interrompre l’ingestion lorsque une portion dépasse la limite autorisée.
- Garder un ensemble de questions de référence accompagné des portions de réponse correspondantes et des attentes en matière de citation.
- Après chaque modification apportée aux séparateurs ou aux métadonnées, effectuer des tests de rappel et de citation sur cet ensemble de référence.
Les équipes qui considèrent ces étapes comme facultatives les redécouvrent lors d’une démonstration destinée aux dirigeants, lorsque la réponse du manuel se trouve à la deuxième page d’un bloc de 800 tokens.
Conclusion
Le découpage en blocs représente le goulot d’étranglement de l’information dans le système de récupération. Les vecteurs représentent en moyenne ce qui leur est montré ; ils ne peuvent pas inventer des éléments inconnus. Alignez les langages des tokens, adaptez la fenêtre de traitement, divisez selon la structure, gérez les tableaux, conservez les citations, et prouvez le comportement par des tests. Ainsi, topK, les seuils et les outils de réclassification fonctionnent enfin sur un ensemble de candidats qui le mérite.
L’incompatibilité des tokeniseurs en tant que catégorie de bug de premier ordre
Les cultures d’ingénierie qui considèrent « 800 tokens » comme une unité universelle vont toujours calculer de mauvaises tailles pour les chunks. Affichez à la fois l’encodage du séparateur et celui de l’incorporeur dans les journaux de démarrage. Convertissez un échantillon de chunks en production à l’aide des deux tokeniseurs ainsi que selon les longueurs d’histogramme. Si la valeur p95 de l’incorporeur dépasse max_length - specials, vous effectuez des troncations en production, même si la longueur moyenne semble correcte.
Les équipes qui passent des embeddings d’OpenAI aux MiniLM locaux (ou inversement) doivent répartir les données en chunks à nouveau, et non seulement les incorporer à nouveau. Un corpus conçu pour une fenêtre donnée n’est pas adapté à l’autre.
Pourquoi topK et les outils de reranking ne peuvent pas compenser
Augmenter la valeur de topK augmente les chances de récupérer quelques fragments partiellement pertinents, mais chaque fragment conserve néanmoins sa propre fin tronquée. Les budgets alloués aux prompts explosent : dix fragments de 800 tokens représentent en réalité des milliers de tokens de texte généralement jamais recherché. Les encodeurs croisés qui évaluent uniquement le texte complet des fragments ne voient que les candidats déjà proposés par l’étape vectorielle ; ils ne peuvent pas promouvoir un fragment dont le préfixe recherchable n’a jamais correspondu. Des seuils proches de zéro acceptent déjà presque tout ce que la recherche vectorielle renvoie ; les resserrer élimine des candidats sans restituer les parties invisibles.
La solution réside lors de l’ingestion : ajuster la fenêtre, modifier le modèle ou diviser les données jusqu’à ce que l’adaptation soit garantie.
Matrice de politique des métadonnées
Décidez par champ : intégrer, récupérer uniquement ou ignorer. Les chemins et les UUID polluent généralement les embeddings. Les titres aident souvent à dissiper les ambiguïtés et devraient parfois être intégrés ainsi que toujours récupérés. Des booléens tels que deprecated devraient figurer dans les filtres plutôt que dans le texte. Documentez la matrice à côté du constructeur de formatateur afin que les modifications futures n’inversent pas accidentellement les valeurs des enums.
Contrats de citation dans les conseillers Spring AI
Si le conseiller Q&A par défaut transforme les documents en chaînes de caractères, enveloppez-le ou remplacez-le afin que chaque morceau utilisé conserve source, chunk_index et le titre. Les notes de bas de page de l’interface utilisateur doivent faire référence à ces champs. Ajoutez un test qui échoue lorsque les citations sont vides malgré un contexte non vide. La confiance dans le produit dépend davantage de cela que des scores BLEU.
Méthodes de segmentation structurée
Pour le Markdown de manuel : séparez par ## puis par paragraphes. Pour les commentaires de type Javadoc : séparez aux limites des déclarations. Pour les journaux d’erreurs : ne mettez pas l’ensemble des informations d’erreur en un seul bloc ; séparez-les par des timestamps ou des IDs d’erreur. Pour les tableaux : un rang ou une entrée logique par bloc, avec les en-têtes de colonne en tant que texte préfixe. Pour le code : privilégiez la granularité par fonction plutôt que des fenêtres arbitraires de tokens afin que les questions relatives au point d’appel permettent de récupérer des fonctions complètes.
L’overlap en tant qu’outil chirurgical
Utilisez l’overlap lorsque l’évaluation révèle des erreurs de délimitation sur des phrases situées à la frontière entre les blocs. Une proportion de cinq à quinze pour cent de la taille du bloc constitue une valeur de départ courante ; mesurez les coûts de stockage et d’incorporation avant d’atteindre cinquante pour cent. L’overlap ne corrige jamais le troncement ; il se contente de dupliquer les préfixes visibles si la fenêtre est déjà pleine.
L’opération embed-small/return-big en pratique
Stockez section_id, parent_id ainsi que body_small / body_full (ou connectez-les à une table des sections). Effectuez des recherches sur les embeddings de body_small ; utilisez body_full pour les requêtes. Surveillez les cas où les corps courts manquent de signal suffisant — ajustez alors le séparateur, et non seulement topK.
Ensemble de tests pour verrouiller le comportement
- Sensibilité à la troncation : modifiez l’suffixe des blocs en dehors de la fenêtre ; vérifiez que la politique de modification du vecteur reste inchangée en cas de troncation.
- Niveau minimal de rappel : questions d’or contenant des segments dans la partie initiale, intermédiaire ou finale ; rejetez les constructions qui réduisent le rappel dans ces parties après modification du séparateur de blocs.
- Survie des citations : effectuez une récupération aléatoire ; assurez-vous que les clés de métadonnées sont présentes.
- Protection par longueur : l’ingestion échoue si le nombre de tokens générés par l’embedder dépasse la limite maximale autorisée.
Les tests lents qui téléchargent des poids ONNX doivent être exécutés dans les flux nocturnes ; conservez un chemin d’unité simplifié pour les tests CI des PR.
Liste de contrôle opérationnelle (détaillée)
Aligner les langages des tokens ; garantir que la fenêtre de traitement soit adaptée ; effectuer des splits en priorisant la structure ; assurer une superposition sparsa ; désigner des spécialistes pour les tableaux et le code ; utiliser une matrice de métadonnées ; mettre en place des outils pour préserver les citations ; effectuer une évaluation de référence avant et après les modifications ; utiliser la méthode embed-small/return-big lorsque la génération nécessite un contexte que l’embedder ne peut pas percevoir. Le découpage reste le goulot d’étranglement du système de récupération — assumez-le explicitement.