El chunking es todo el sistema de recuperación: la trampa entre Spring AI 800 y 128
Cuando el divisor emite fragmentos de 800 tokens pero el incrustador solo lee 128, el sistema de recuperación no puede ver la parte final truncada. El tamaño del fragmento, la ventana y la superposición son parámetros clave en el sistema de recuperación.
El divisor predeterminado de Spring AI divide el texto en fragmentos de aproximadamente 800 tokens, mientras que el modelo de embedding local incluido solo lee los primeros 128 tokens de cada uno. No existe
topK, umbral de similitud ni algoritmo de reordenamiento que pueda mostrar texto que nunca llegó a formar parte del vector.
El divisor decide qué puede contener cada vector. Cada etapa posterior al almacenamiento de vectores solo reordena los candidatos que el divisor hizo buscables.
El problema: ochocientos tokens, ciento veintiocho embedding
Tres líneas de código en Spring AI 2.0.1 constituyen el índice detrás de muchas demostraciones RAG en JVM, y dos números ocultos determinan qué puede encontrar ese índice:
List<Document> pages = new TikaDocumentReader(handbook).read();
List<Document> chunks = new TokenTextSplitter().apply(pages);
vectorStore.add(chunks);
El primer número se encuentra en el splitter. new TokenTextSplitter() divide el texto en aproximadamente 800 tokens según la codificación cl100k_base de OpenAI, y luego retrocede hasta el último ., ?, ! o salto de línea si quedan al menos unos 350 caracteres, lo que equivale a unas seiscientas palabras en inglés por bloque.
El segundo número está en el archivo JSON del tokenizador. Cuando la aplicación utiliza el TransformersEmbeddingModel local de Spring AI, carga por defecto all-MiniLM-L6-v2. El archivo tokenizer.json incluido configura el truncamiento de la siguiente manera:
"truncation": { "direction": "Right", "max_length": 128, "strategy": "LongestFirst", "stride": 0 }
DJL 0.36.0, la capa de tokenización de Spring AI 2.0.1, mantiene activada la truncación y respeta el valor de max_length. Por lo tanto, cada vector de fragmento se calcula a partir de los primeros 128 tokens WordPiece, dos de los cuales son [CLS] y [SEP]. Diferentes tokenizadores hacen que la proporción sea aproximada, no precisa, y aún así desequilibrada. El vector representa aproximadamente las primeras cien palabras de un fragmento de seiscientas palabras. El resto se almacena, se recupera y se pasa al modelo de chat, pero nunca participa en la búsqueda.
El procesamiento en bloques no es simplemente un paso de preprocesamiento que se realiza una vez antes del ajuste real del sistema de recuperación. Determina qué contenido puede ser recuperado en realidad. Aumentar el valor de topK desde su valor por defecto de 4 genera más bloques cuyas partes finales son igualmente invisibles, lo que puede incluir miles de tokens en el prompt. Un reordenador basado en codificador cruzado solo reorganiza los candidatos ya devueltos por la búsqueda vectorial; el texto invisible no puede ser priorizado. Los umbrales de similitud tienen poco margen cuando SearchRequest ya está configurado por defecto para aceptar todo con un valor de 0.0.
Al pasar a usar text-embedding-3-small de OpenAI se elimina el problema de truncamiento, ya que su límite de entrada es mucho mayor que el tamaño de los bloques, pero persisten modos de fallo menos evidentes, que se explican a continuación. Todo lo que ocurre después del almacén vectorial solo puede reorganizar aquello que el procesador en bloques permitió pasar.
Conceptos arquitectónicos fundamentales
Cuatro hechos sobre cómo un fragmento se convierte en un éxito se reflejan directamente en el comportamiento de Spring AI.
Un fragmento es un vector, y un vector es un promedio
TransformersEmbeddingModel ejecuta el modelo ONNX, lee last_hidden_state (384 dimensiones por token) y realiza un pooling promedio con la máscara de atención, omitiendo los espacios de relleno. Sea cual sea la longitud del fragmento, la búsqueda considera un solo vector:
v = Σ mᵢ·hᵢ / Σ mᵢ hᵢ = vector for token i, mᵢ = 1 for a real token, 0 for padding
score(question, chunk) = cos(q, v)
El pooling promedio diluye aún más las frases distintivas que quedan ocultas tras la ventana de truncamiento: nunca forman parte de la suma. Las puntuaciones de recuperación comparan el vector de consulta con ese promedio, por lo que los tokens principales dominan tanto lo que se incrusta como la similitud que puede detectarse.
Tres tokenizadores, y nada los compara
Los límites de los chunks, las entradas de incrustación y, con frecuencia, el prompt del chat utilizan diferentes lenguajes de tokens. Una regla práctica para las configuraciones locales de MiniLM es mantener los tokens de incrustación dentro del rango del modelo, dejando espacio para caracteres especiales:
chunk tokens (embedding tokenizer) ≤ window − 2 // 126 for the bundled MiniLM tokenizer
prompt tokens ≈ topK × chunk tokens + system + question + history
Si el divisor cuenta cl100k_base mientras que el incrustador utiliza WordPiece, “800 tokens” no equivale a “800 tokens de incrustación”. Los equipos que nunca comparan ambos valores generan índices que parecen correctos según las métricas de almacenamiento, pero fallan al responder preguntas que solo se encuentran en las partes finales de los chunks.
Tus metadatos se incrustan o se ignoran, según un valor de enum
Los chunks contienen metadatos como:
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...
Si esos campos se concatenan en la cadena incrustada o se excluyen depende de la configuración del formateador:
ContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("parent_document_id", "chunk_index", "total_chunks") // bookkeeping, not meaning
.build();
chunks.forEach(chunk -> chunk.setContentFormatter(formatter));
Incluir rutas source o UUIDs puede contaminar el espacio semántico; excluir los títulos puede eliminar la única señal que evita la ambigüedad. Elija conscientemente entre incrustar o recuperar metadatos: un simple cambio en la enumeración afecta tanto a la calidad de la recuperación como a la de las citaciones.
El asesor predeterminado descarta sus citaciones
Una llamada típica de tipo P&R parece sencilla:
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();
Los asesores predeterminados pueden pasar el texto de contexto al modelo sin conservar los campos de origen por sección en la respuesta estructurada. Aunque obtenga el párrafo correcto, seguirá sin poder mostrar la página PDF ni el título a menos que personalice el asesor o procese los documentos previamente al hacer la solicitud. Las citaciones son un aspecto relacionado con el flujo de trabajo, no un adorno de la interfaz.
Guía práctica de implementación
Mida la recuperación antes de modificar el divisor
Cree un pequeño conjunto de evaluación con preguntas acompañadas de fragmentos de respuesta que deben aparecer en el texto recuperado:
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();
}
}
Ejecute el índice actual, registre si hay coincidencias o no en un número fijo de topK, y solo entonces modifique la configuración del divisor. Sin un punto de referencia, cualquier cambio en el tamaño de los fragmentos no es más que especulación.
Ajuste cada fragmento dentro de la ventana de incrustación
Guarde los documentos para que la longitud del proceso de incrustación no los recorte silenciosamente:
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 configuración puede activar opciones relacionadas con los tokenizadores cuando sea apropiado:
spring.ai.embedding.transformer.tokenizer.options.maxLength=256
Si el modelo solo procesa 128 tokens, reduzca el tamaño de los fragmentos para que quepan o cámbie de modelo. No pretenda que un reordenador pueda generar señales que faltan.
Divida primero por estructura, luego por tamaño
Dé prioridad a los títulos, párrafos y límites de listas antes que al conteo bruto de tokens:
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;
}
}
Luego aplique las restricciones de la ventana de incrustación:
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)));
Las divisiones conscientes de la estructura mantienen las definiciones junto con sus términos y los pasos junto con sus procedimientos: exactamente las unidades sobre las que preguntan los usuarios.
La superposición aporta algo, pero se paga por ello en todas partes
La superposición aumenta la probabilidad de que una frase límite aparezca completamente dentro de algún bloque, a costa de generar más bloques:
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
Doblar la superposición duplica aproximadamente los vectores almacenados y el gasto en procesamiento. Utilice una superposición moderada cuando los límites corten oraciones; no considere la gran superposición como un sustituto para ajustar la ventana de incrustación.
Las tablas y los bloques de código arruinan toda estrategia
Las tablas de Markdown y los bloques de código enmarcados requieren un manejo especializado: divisiones por filas o extractores dedicados, para que un vector no mezcle todo el grid en algo sin estructura:
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;
}
Transfiera los títulos hacia adelante de modo que un bloque que comience a mitad de sección siga teniendo su contexto:
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;
}
}
Sin llevar un encabezado, la recuperación devuelve filas huérfanas que el modelo de chat no puede localizar.
Incluir contenido pequeño, devolver contenido grande
Un patrón útil consiste en separar lo que se incluye de lo que se devuelve. Indexar las secciones compactas para búsquedas y luego expandir a las secciones padre para el 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();
}
La ingestión puede analizar Markdown en secciones y almacenar tanto los elementos detallados como los cuerpos padre:
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 búsqueda mantiene su precisión con embeddings pequeños; la generación recibe un contexto más amplio sin saturar el vector con tokens que el incluidor ignoraría.
Pooling de medias y máscaras de atención en la práctica
Después de la pasada forward en ONNX, cada token que sobrevive tiene un vector oculto. El pooling promedio suma esos vectores y los divide entre la cantidad de posiciones no enmascaradas. Los tokens de relleno no aportan nada cuando la máscara es correcta; los tokens truncados tampoco aportan nada porque nunca existieron durante la pasada forward. A veces la gente asume que “el modelo leyó el objeto Documento”. En realidad, leyó la salida del tokenizador. Si su registro muestra el texto completo de un bloque junto con un vector, está observando al mismo tiempo la realidad del almacenamiento y la realidad de la búsqueda; ambas difieren siempre que la longitud excede el límite establecido.
Luego, la búsqueda de similitud clasifica los resultados según la distancia o la similitud coseno con respecto a ese vector agrupado. Una consulta sobre una excepción de reembolso oculta en la segunda mitad de un bloque de política compuesto por 800 tokens tiene pocas posibilidades de competir con bloques que mencionan los reembolsos desde el principio. Los autores no escribieron políticas deficientes; el índice se negó a reconocer la cláusula de excepción.
Procesos de asesores y higiene en las citaciones
Los clientes de chat que utilizan asesores de recuperación suelen concatenar el contenido de los documentos en una única cadena de contexto. A menos que las claves del mapa de metadatos se mantengan en el objeto de respuesta o en marcadores de citación deliberados dentro del plantilla de prompt, el modelo puede responder correctamente mientras la aplicación no puede resaltar la sección correspondiente del manual. En las pruebas, trate los campos de citación como parte del contrato de recuperación: después de cada respuesta, verifique que cada identificador de fragmento utilizado corresponda a source, chunk_index y al título correspondiente, si está presente.
Los asesores personalizados pueden insertar notas al pie o bloques JSON que la interfaz de usuario pueda analizar. Las demostraciones predeterminadas omiten esta funcionalidad, y por eso muchos herramientas internas de RAG se entregan con “respuestas pero sin enlaces”.
Economía de la superposición más allá de la fórmula
La fórmula del techo para el conteo de fragmentos en caso de solapamiento es una herramienta de planificación, no una garantía de calidad. El solapamiento ayuda cuando una oración se extiende más allá de un límite; no puede recuperar los tokens que el módulo de incrustación descarta. Pagar por solapamiento manteniendo max_length en 128 significa gastar dinero en duplicar partes invisibles del texto. Arregle primero el ajuste de la ventana y, si aún persisten errores en las evaluaciones debido a que el texto trasciende los límites, añada un pequeño solapamiento.
Detalles de la división basada en la estructura
Los divisores recursivos que prueban primero los títulos, luego los párrafos, después las oraciones y finalmente los caracteres en bruto lo hacen de manera adecuada. Los divisores que tienen en cuenta los títulos mantienen “## Reembolsos” junto con sus viñetas. Los divisores por párrafos preservan la cohesión de los argumentos. Los divisores por caracteres son una última opción que debería fallar en las pruebas de procesamiento si dominan un corpus, lo que indica que los documentos carecen de estructura suficiente para indexarse como prosa. Para referencias API en Markdown, considere dividir en los puntos finales ## de modo que cada método sea un vector independiente.
Ruta de recuperación Embed-small/return-big
Al realizar la consulta, la búsqueda por similitud devuelve IDs de secciones pequeñas. Una tabla de unión o un ID padre en los metadatos carga el cuerpo completo de la sección correspondiente. Ese patrón permite que MiniLM se especialice en unidades semánticas compactas, mientras que el LLM tiene acceso a las explicaciones circundantes. El costo de implementación consiste en un segundo almacenamiento o un campo de carga del padre; es más económico que pretender que una ventana de 128 tokens comprende toda una página.
Cultura de evaluación
Los conjuntos de referencia deben incluir preguntas cuyas respuestas se encuentren al principio, en medio o al final de secciones largas. Si solo pasan las respuestas ubicadas al inicio, la truncación sigue siendo un problema grave. Incluir al menos una pregunta orientada a tablas y otra orientada a código, si esos formatos aparecen en el corpus. Etiquetar las pruebas lentas que descargan pesos ONNX para que CI pueda omitirlas en las ejecuciones por defecto de los PR, mientras que las compilaciones nocturnas sigan realizando verificaciones de sensibilidad vectorial.
Configuración y buenas prácticas
Alinee las reglas de los tokens divisorios con el tokenizador del embebidor, mantenga los cuerpos de los fragmentos dentro de la ventana de embeidura, excluya los metadatos ruidosos de las representaciones mientras se conservan para las citaciones, prefiera divisiones estructurales, use la superposición con moderación y trate de forma especial las tablas y el código. Ejecute nuevamente un conjunto fijo de pruebas después de cada cambio.
Estrategia de pruebas
Las pruebas automatizadas deben asegurar las propiedades que generalmente se omiten en las demostraciones.
Demuestre que el final de un fragmento cambia su vector
@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 agregar texto distintivo más allá de la ventana no modifica el vector, se confirma la truncación.
Demuestre que el rendimiento en recuperación no disminuye al cambiar el divisor de fragmentos
@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
}
Mantenga un límite mínimo para el rendimiento en recuperación@K en todos los experimentos con divisores, de modo que las “mejoras” que destruyen respuestas conocidas no puedan pasar desapercibidas.
Verifique que cada fragmento indexado pueda ser citado
@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"));
}
Ejemplos de resultados de similitud y verificación de que los metadatos source y de encabezado se mantienen intactos. La ausencia silenciosa de una cita constituye un defecto del producto, incluso cuando el texto de la respuesta parece correcto.
Recursos
Documentación de Spring AI para divisores, modelos de incrustación y asesores; la ficha del modelo ONNX MiniLM y su tokenizer.json; el comportamiento del tokenizador DJL; además de tus propios dispositivos de evaluación de recuperación. La lección clave es arquitectónica: la calidad de la recuperación comienza con el procesamiento en bloques. Ajustar topK no permite recuperar texto que el modelo de incrustación nunca haya visto.
Por qué las demostraciones ocultan la discrepancia
Los tutoriales celebran la indexación de tres líneas porque funciona en una laptop y devuelve alguna respuesta. Rara vez imprimen las configuraciones del tokenizador o comparan la longitud de WordPiece con la longitud del divisor. Entonces, los fallos en producción parecen ser “RAG es poco fiable”, cuando en realidad el índice simplemente nunca incrustó el rango de la respuesta. Pasar un día configurando herramientas de evaluación antes de trabajar en la creación de prompts suele acortar el proyecto en su conjunto.
Lista de verificación operativa
- Imprima el valor
max_lengthdel incrustador y el costo adicional de los tokens especiales. - Mida la mediana y el p95 de las longitudes de los tokens en el tokenizador del incrustador.
- Interrumpa la ingestión cuando un fragmento supere el rango seguro.
- Mantenga un conjunto de preguntas de referencia con los rangos de respuestas y las expectativas de citación.
- Después de cada cambio en el divisor o los metadatos, ejecute pruebas de recuperación y citación de referencia.
Los equipos que consideran esos pasos opcionales los redescubren durante una demostración ejecutiva cuando la respuesta del manual se encuentra en la segunda página de un bloque de 800 tokens.
Cierre
El particionamiento en bloques representa el cuello de botella informativo del sistema de recuperación. Los vectores representan en promedio lo que se les muestra; no pueden inventar partes no vistas. Alinear los lenguajes de tokens, ajustarse al tamaño del ventana, dividir según la estructura, manejar tablas, preservar las citaciones y demostrar el comportamiento con pruebas. Luego topK, los umbrales y los reclasificadores operan finalmente sobre un conjunto de candidatos que realmente lo merece.
La incompatibilidad entre tokenizadores como clase de error de primera categoría
Las culturas de ingeniería que tratan los “800 tokens” como una unidad universal seguirán dimensionando incorrectamente los fragmentos para siempre. Imprima tanto la codificación del divisor como la del incrustador en los registros de inicio. Convierta una muestra de fragmentos de producción mediante ambos tokenizadores y las longitudes del histograma. Si el p95 del incrustador supera max_length - specials, estará truncando los datos en producción incluso cuando la longitud promedio parece correcta.
Los equipos que migran de los incrustadores de OpenAI a MiniLM local (o viceversa) deben volver a dividir los datos en fragmentos, no solo volver a incrustarlos. Un corpus diseñado para una ventana es inadecuado para la otra.
Por qué topK y los reordenadores no pueden compensar
Aumentar topK incrementa la probabilidad de obtener alguno fragmento parcialmente relevante, pero cada fragmento sigue ocultando su propia parte truncada. Los presupuestos de las solicitudes se disparan: diez fragmentos de 800 tokens equivalen a miles de tokens de texto que, en su mayoría, nunca se han buscado. Los codificadores cruzados que evalúan el texto completo del fragmento solo consideran los candidatos ya propuestos en la etapa vectorial; no pueden promocionar un fragmento cuyo prefijo buscable nunca coincidió. Los umbrales cercanos a cero ya aceptan casi todo lo que devuelve la búsqueda vectorial; ajustarlos más estrictamente descarta candidatos sin recuperar las secciones invisibles.
La solución está en la fase de ingestión: ajustar el tamaño del ventana, cambiar el modelo o dividir los datos hasta garantizar que encajen.
Matriz de políticas de metadatos
Decida por campo: incrustar, solo recuperar o eliminar. Las rutas y los UUID suelen contaminar las incrustaciones. Los títulos a menudo ayudan a disipar ambigüedades y deberían incrustarse en ocasiones y siempre recuperarse. Valores booleanos como deprecated pueden pertenecer a filtros en lugar de al texto. Documente la matriz junto al constructor del formateador para que las ediciones futuras no cambien casualmente los valores enumerados.
Contratos de citación en los asesores de Spring AI
Si el asesor predeterminado de P&R convierte los documentos en cadenas de texto, envuélvalo o reemplácelo para que cada fragmento utilizado mantenga source, chunk_index y el título. Las notas al pie de la interfaz de usuario deben referirse a esos campos. Añada una prueba que falle cuando las citaciones estén vacías pese a que el contexto no lo esté. La confianza en el producto depende más de esto que de las puntuaciones BLEU.
Recetas para la división estructural
Para Markdown de manual: divida por ## y luego por párrafos. Para comentarios similares a Javadoc: divida según los límites de las declaraciones. Para registros de logs: no incluya toda la información del stack como un único bloque; divídala por marcas de tiempo o códigos de error. Para tablas: use una fila o un registro lógico por bloque, incluyendo los encabezados de columna como texto de prefijo. Para código: prefiera la granularidad por función en lugar de ventanas arbitrarias de tokens, para que las consultas desde el punto de llamada puedan obtener funciones completas.
La superposición como herramienta quirúrgica
Utilice la superposición cuando la evaluación muestre errores en los límites de oraciones que se extienden entre los bloques. Un porcentaje del 5 al 15 del tamaño del bloque es un punto de partida común; mida el costo de almacenamiento e inclusión antes de pasar al 50 por ciento. La superposición nunca soluciona el problema del acortamiento; solo duplica los prefijos visibles si la ventana ya está llena.
Operatividad de Embed-small/return-big
Almacene section_id, parent_id y body_small / body_full (o únelos a una tabla de secciones). Realice búsquedas en los embeddings de body_small; use body_full como referencia. Monitoree los casos en los que los cuerpos pequeños carezcan de suficiente señal; ajuste entonces el divisor, no solo topK.
Juego de pruebas que fija el comportamiento
- Sensibilidad a la truncación: modifique el sufijo de los fragmentos más allá del rango permitido; verifique que la política de cambio de vector se mantenga (debería no cambiar si se produce truncación).
- Nivel mínimo de precisión: preguntas de referencia con segmentos en la parte inicial, media o final; rechace las construcciones que reduzcan la precisión en estas áreas tras cambios en el procesador de fragmentos.
- Sobrevivencia de las citaciones: realice recuperación de muestras; verifique que los campos de metadatos estén presentes.
- Protección por longitud: la ingestión fallará si el conteo de tokens del generador de embeddings excede el límite seguro.
Las pruebas lentas que descargan pesos ONNX deben realizarse en los canales nocturnos; mantenga un camino de unidad simplificado para las pruebas CI de los pull request.
Lista de verificación operativa (ampliada)
Alinear los idiomas de los tokens; asegurar que encajen dentro del marco establecido; realizar divisiones priorizando la estructura; aplicar solapamiento esporádico; designar especialistas en tablas y código; utilizar una matriz de metadatos; contar con asesores que preserven las citaciones; realizar evaluaciones de referencia antes y después de los cambios; emplear el método embed-small/return-big cuando la generación necesite contexto que el sistema de incrustación no pueda ver. El procesamiento en fragmentos sigue siendo el cuello de botella del sistema de recuperación de información: aborde este problema de manera explícita.