Фрагментація є всією системою пошуку: пастка Spring AI між 800 та 128.
Коли сепаратор генерує фрагменти по 800 токенів, а ембеддер читає лише 128, система пошуку не може бачити обрізану частину. Розмір фрагментів, вікно та перекриття є складовими системи пошуку.
Стандартний розділювач Spring AI ділить текст приблизно на фрагменти по 800 токенів, тоді як вбудована локальна модель ембеддингів читає лише перші 128 токенів кожного фрагмента. Жодні параметри на кшталт
topK, порогу схожості чи алгоритму переупорядкування не можуть вивести на поверхню текст, який ніколи не потрапляв до вектора.
Розділювач визначає, що може містити кожен вектор. Кожен наступний етап після сховища векторів лише переупорядковує кандидатів, які були зроблені доступними для пошуку розділювачем.
Проблема: вісімсот токенів — сто двадцять вісім ембеддингів
Три рядки коду Spring AI 2.0.1 створюють індекс, який використовується у багатьох демонстраціях технології JVM RAG, і два приховані показники визначають, що може знайти цей індекс:
List<Document> pages = new TikaDocumentReader(handbook).read();
List<Document> chunks = new TokenTextSplitter().apply(pages);
vectorStore.add(chunks);
Перше число знаходиться у розділювачі. Функція new TokenTextSplitter() розділяє текст на приблизно 800 токенів за кодуванням cl100k_base від OpenAI, а потім повертається до останнього символа ., ?, ! або нового рядка, якщо залишилося щонайменше близько 350 символів — це приблизно шістсот англійських слів на частину.
Друге число знаходиться у JSON-файлі токенізатора. Коли система використовує локальну модель TransformersEmbeddingModel від Spring AI, за замовчуванням завантажується модель all-MiniLM-L6-v2. Файл конфігурації tokenizer.json, який постачається разом, налаштовує процес обрізання тексту ось так:
"truncation": { "direction": "Right", "max_length": 128, "strategy": "LongestFirst", "stride": 0 }
DJL 0.36.0, шар токенізації в рамках Spring AI 2.0.1, залишає функцію обрізання активною та дотримується значення max_length. Тож кожен вектор фрагмента обчислюється на основі перших 128 токенів WordPiece, два з яких є [CLS] та [SEP]. Різні токенізатори змушують співвідношення бути приблизним, а не точним — причому все одно спотвореним. Вектор приблизно відображає перші сто слів із фрагмента довжиною у шістсот слів. Решта зберігається, отримується та передається моделі чату, проте вона ніколи не бере участі у пошуку.
Чанкінг — це не просто попередня обробка, яку налаштовують раз перед справжньою настройкою механізму пошуку. Він визначає, що взагалі може бути отримано. Підвищення значення topK від стандартних 4 призводить до отримання більшої кількості чанків, чиї кінцеві частини також залишаються невидимими, що може призвести до додавання тисяч токенів у запит. Крос-кодер-реранкер лише переставляє кандидатів, вже повернених векторним пошуком; невидимий текст не може бути піднятий у пріоритет. Пороги схожості майже не мають запасу, оскільки SearchRequest за замовчуванням приймає все з значенням 0.0.
Перехід на модель OpenAI text-embedding-3-small усуває проблему обрізання, оскільки її ліміт вхідних даних значно перевищує розмір чанків, але залишаються менш помітні форми збоїв — про них йдеться нижче. Усе, що знаходиться після сховища векторів, може лише переставляти те, що дозволив процесор чанків.
Основні архітектурні концепції
Чотири факти про те, як фрагмент стає популярним, безпосередньо впливають на поведінку Spring AI.
Фрагмент — це один вектор, а один вектор — це середнє значення
TransformersEmbeddingModel запускає модель ONNX, читає last_hidden_state (384 виміри на кожен токен) та за допомогою методу середнього пулінгу з маскою уваги ігнорує додаткові символи. Незалежно від довжини фрагмента пошук бачить лише один вектор:
v = Σ mᵢ·hᵢ / Σ mᵢ hᵢ = vector for token i, mᵢ = 1 for a real token, 0 for padding
score(question, chunk) = cos(q, v)
Метод середнього пулінгу ще більше зменшує значущість фраз, які знаходяться після меж обрізки: вони ніколи не потрапляють до суми. Оцінки пошуку порівнюють вектор запиту з цим середнім значенням, тому провідні токени визначають як те, що буде імплантовано, так і ступінь схожості.
Три токенайзери, і немає нічого, що могло б їх порівняти
Межі чанків, дані для ембеддингу та запити до чату зазвичай використовують різні мови токенів. Практичне правило для локальних налаштувань MiniLM — тримати токени ембеддингу всередині вікна моделі, залишаючи місце для спеціальних символів:
chunk tokens (embedding tokenizer) ≤ window − 2 // 126 for the bundled MiniLM tokenizer
prompt tokens ≈ topK × chunk tokens + system + question + history
Якщо інструмент розділення підраховує cl100k_base, а інструмент ембеддингу — WordPiece, то „800 токенів“ не означає „800 токенів ембеддингу“. Команди, які ніколи не порівнюють ці значення, створюють індекси, які виглядають нормальними за показниками зберігання, але не справляються з запитаннями, які можна відповісти лише на основі даних з кінця чанків.
Ваша метадані або включується до ембеддингу, або ігнорується, залежно від одного енумераційного значення
Чанки містять таку метадані, як:
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...
Те, чи будуть ці поля об’єднані в один рядок ембеддингу чи виключені, залежить від налаштувань форматувальника:
ContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("parent_document_id", "chunk_index", "total_chunks") // bookkeeping, not meaning
.build();
chunks.forEach(chunk -> chunk.setContentFormatter(formatter));
Вбудовування шляхів source або UUID може забруднити семантичний простір; виключення заголовків може позбавити нас єдиного сигналу для усунення неоднозначностей. Ретельно обирайте між вбудовуванням та отриманням метаданих — зміна одного значення з енумерації впливає як на точність пошуку, так і на якість цитування.
Стандартний радник видаляє ваші цитати
Типовий діалог у форматі «питання-відповідь» здається простим:
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();
Стандартні радники можуть передавати текст контексту моделі, не зберігаючи поля джерела для кожного фрагмента у структурованій відповіді. Ви отримуєте потрібний абзац, але все одно не можете показати сторінку PDF чи заголовок, якщо тільки не налаштуєте радника або не обробите документи до постановки запиту. Цитування — це питання процесу обробки даних, а не елемент користувацького інтерфейсу.
Практичний посібник з реалізації
Вимірюйте ефективність пошуку перед тим, як коригувати механізм розділення даних
Створіть невеликий набір запитань для оцінки, поєднаних із фрагментами відповідей, які мають з’являтися у отриманому тексті:
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();
}
}
Запустіть поточний індекс, зафіксуйте результати пошуку чи невдач у значенні topK, і лише потім змінюйте налаштування розділювача. Без базового показника кожна зміна розміру частин є суб’єктивною інтерпретацією.
Розмістіть кожну частину в межах вікна ембеддингу
Захистіть документи, щоб довжина ембеддингу не могла таємно обрізати їх:
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;
}
}
У налаштуваннях можна, за потреби, активувати відповідні опції токенізатора:
spring.ai.embedding.transformer.tokenizer.options.maxLength=256
Якщо модель бачить лише 128 токенів, або зменште розмір частин, щоб вони помістилися, або змініть модель. Не вдавайте, що реранкер зможе створити відсутні сигнали.
Спочатку розділяйте за структурою, потім за розміром
Віддавайте перевагу заголовкам, абзацам та межам списків перед простою кількістю токенів:
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;
}
}
Потім дотримуйтесь обмежень вікна ембеддингу:
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)));
Розділення з урахуванням структури зберігає визначення разом із їхніми термінами, а кроки — разом із їхніми процедурами, саме ті одиниці, про які запитують користувачі.
Перекриття дає певну перевагу, але за неї доводиться платити скрізь
Перекриття збільшує ймовірність того, що фраза-межа повністю опиниться всередині певного фрагмента, але це коштує більшої кількості фрагментів:
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
Подвоєння рівня перекриття приблизно подвоює кількість збережених векторів та витрати на їхнє створення. Використовуйте помірне перекриття, коли межі розрізають речення; не вважайте велике перекриття заміною налаштування вікна для ембеддингу.
Таблиці та блоки коду руйнують кожну стратегію
Таблиці Markdown та код у рамках вимагають спеціалізованого оброблення — розділення за рядками або спеціальні засоби вилучення даних — щоб вектор не змішував усю сітку в єдину масу:
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;
}
Переносіть заголовки далі, щоб фрагмент, який починається посеред розділу, все одно зберігав свій контекст:
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;
}
}
Без передачі заголовка процес отримання даних повертає «сирітські» рядки, які модель чату не може ідентифікувати.
Вбудовуйте мале, повертайте велике
Корисний підхід — розділяти те, що ви вбудовуєте, від того, що повертаєте. Створюйте компактні розділи для пошуку, а потім розширюйте їх до батьківських розділів для запиту:
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();
}
Процес обробки може розбирати Markdown на розділи та зберігати як деталізовані дані, так і їхні батьківські структури:
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
}
Пошук залишається ефективним для малих вбудованих даних; процес генерації отримує більший контекст, не перевантажуючи вектор токенами, які були б проігноровані механізмом вбудовування.
Метод середнього значення та маски уваги на практиці
Після виконання операції ONNX кожен залишений токен має прихований вектор. Метод середнього пулінгу підсумовує ці вектори та ділить результат на кількість немаскованих позицій. Токени заповнення не впливають на результат, якщо маска правильна; токени, які були обрізані, також не впливають, оскільки їх ніколи не існувало під час виконання операції. Іноді люди припускають, що «модель читала об’єкт Document». Насправді вона читала результат роботи токенайзера. Якщо у вашому журналі логів текст повного фрагмента виводиться разом із вектором, ви одночасно бачите реальність зберігання та реальність пошуку — і вони не збігаються, коли довжина перевищує встановлений ліміт.
Пошук за схожістю потім ранжує результати за відстанню або косинусовою схожістю з цим пульованим вектором. Запит щодо винятку з поверненням грошей, який знаходиться у другій половині фрагмента політики довжиною 800 токенів, погано конкурує з фрагментами, де про повернення грошей йдеться відразу. Автори не писали поганих політик; індекс просто відмовлявся бачити пункт про виняток.
Канали роботи порадників та чистота цитування
Клієнти чату, які використовують порадників для пошуку інформації, часто об’єднують вміст документів у один рядок контексту. Якщо ключі мапи метаданих не потрапляють до об’єкта відповіді чи до спеціальних маркерів цитування у шаблоні запиту, модель може дати правильну відповідь, проте додаток не зможе підкреслити відповідний розділ посібника. У тестах слід розглядати поля цитування як частину умов пошуку: після кожної відповіді перевіряйте, чи кожен використаний ідентифікатор фрагмента відповідає source, chunk_index та, за наявності, заголовку.
Скориставшись власними порадниками, можна додати примітки чи блоки JSON, які буде парсити інтерфейс. Стандартні демонстрації пропускають цей етап, тому багато внутрішніх інструментів типу RAG поставляються з „відповідями без посилань“.
Економіка перекриття поза формулою
Формула для обчислення кількості фрагментів з урахуванням перекриття є інструментом планування, а не гарантією якості. Перекриття допомагає, коли речення проходить через межу; воно не може відновити токени, які було втрачено під час ембеддингу. Оплата за перекриття при збереженні значення max_length на рівні 128 означає витрати грошей на дублювання невидимих частин тексту. Спочатку вирішіть проблему вміщення тексту у вікно, а потім додайте невелике перекриття, якщо під час оцінки все ще спостерігається проблема з перетином меж.
Деталі розділення за принципом структури
Рекурсивні розділювачі, які спочатку пробують розділити на заголовки, потім на абзаци, далі на речення та насамкінець на сирі символи, працюють ефективно. Розділювачі, які враховують заголовки, зберігають „## Refunds“ разом із його пунктами. Розділення на абзаци зберігає цілісність аргументів. Розділення на символи є останнім засобом, і воно має провалювати тести обробки, якщо воно домінує у корпусі — це свідчить про те, що документи не мають структури, гідної індексування як прози. Для API-референсів Markdown рекомендується розділяти текст за допомогою кінцевих точок ##, щоб кожен метод був окремим вектором.
Шлях отримання даних Embed-small/return-big
Під час запиту пошук схожості повертає ідентифікатори невеликих розділів. За допомогою таблиці з’єднання або ідентифікатора батька в метаданих завантажується повний текст розділу для обробки запиту. Така схема дозволяє MiniLM спеціалізуватися на компактних семантичних одиницях, тоді як LLM бачить навколишнє пояснення. Витрати на реалізацію полягають у додатковому сховищі або полі з даними батька — це дешевше, ніж удавати, що вікно з 128 токенів розуміє цілий сторінку.
Культура оцінювання
Золоті набори мають включати запитання, відповіді на які знаходяться на початку, в середині та в кінці довгих розділів. Якщо проходять лише відповіді, розташовані на початку, проблема обрізки все одно залишається. Якщо в корпусі є запитання у форматі таблиць чи коду, необхідно включити принаймні по одному такому запитанню. Позначте повільні тести, які завантажують ваги у форматі ONNX, щоб CI міг пропускати їх під час стандартних виконань PR, тоді як щовечірні збірки все одно будуть перевіряти чутливість до векторів.
Конфігурація та найкращі практики
Узгодьте правила розділення токенів із токенайзером ембеддера, зберігайте тіло блоку всередині вікна ембеддингу, виключайте шумні метадані з ембеддингів, залишаючи їх для цитування, віддавайте перевагу структурному розділенню, обережно використовуйте перекриття та ставіться особливо до таблиць та коду. Після кожної зміни перезапустіть фіксований набір тестів.
Стратегія тестування
Автоматизовані тести повинні контролювати властивості, які зазвичай ігноруються у демонстраціях.
Доведіть, що кінець блоку змінює його вектор
@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));
}
Якщо додавання унікального тексту поза межами вікна не змінює вектор, це підтверджує необхідність обрізання даних.
Доведіть, що рівень відтворення не знижується при зміні інструменту розділення блоків
@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
}
Зберігайте мінімальний рівень recall@K під час експериментів із розділенням, щоб «покращення», які знищують відомі відповіді, не залишалися непоміченими.
Перевірте, чи кожен індексований блок можна цитувати
@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"));
}
Приклади результатів порівняння та перевірка на те, чи збереглися метадані source та заголовків. Відсутність посилань є дефектом продукту, навіть якщо текст відповіді виглядає правильно.
Ресурси
Документація Spring AI щодо інструментів розділення даних, моделей ембеддингу та консультантів; інформація про модель ONNX MiniLM та її файл tokenizer.json; характеристики токенізатора DJL; а також власні інструменти для оцінки процесу пошуку даних. Ключовий урок полягає у архітектурному підході: якість пошуку починається з правильного розділення даних на частини. Налаштування параметра topK не може відновити текст, якого ніколи не бачив ембеддер.
Чому демонстрації приховують невідповідності
У навчальних матеріалах рекомендується використання трьохрядного індексування, оскільки воно працює на ноутбуку та повертає якусь відповідь. Рідко коли там друкують конфігурації токенізатора чи порівнюють довжину WordPiece з довжиною розділювача. Тоді проблеми в продакшені здаються наслідком „ненадійності RAG“, хоча насправді індекс просто ніколи не включив потрібний фрагмент відповіді. Перед тим, як приступити до створення поетичних запитів, витрачання дня на розробку інструментів оцінки зазвичай скорочує загальний термін реалізації проекту.
Чек-лист для роботи
- Виведіть значення
max_lengthу модулі інкорпорації та додаткові витрати на обробку спеціальних токенів. - Виміряйте медіанну та p95 довжини токенів у токенізаторі модуля інкорпорації.
- Заблокуйте процес обробки, якщо розмір фрагмента перевищує допустимі межі.
- Зберігайте набір „золотих“ запитань із фрагментами відповідей та вимогами щодо посилань.
- Після кожної зміни розділювача чи метаданих проводьте тести на відтворення результатів та перевірку посилань.
Команди, які вважають ці кроки необов’язковими, знову їх відкривають під час демонстрації для керівництва, коли відповідь з посібника знаходиться на другій сторінці блоку з 800 токенів.
Заключні зауваження
Розділення на блоки є вузьким місцем у системі пошуку інформації. Вектори середньостатистично відображають те, що їм показано; вони не можуть створювати невидимі елементи. Необхідно узгодити мови токенів, підлаштувати вікно обробки, розділяти інформацію за структурою, керувати таблицями, зберігати посилання та підтверджувати поведінку за допомогою тестів. Лише тоді topK, порогові значення та алгоритми переранжування зможуть працювати з набором кандидатів, який справді їх заслуговує.
Невідповідність токенайзерів як окремий клас помилок
Культури інжинірингу, які вважають „800 токенів“ універсальною одиницею, завжди будуть неправильно визначати розмір чанків. Друкуйте інформацію про кодування розділювача та вбудовувача у журнали запуску. Перетворіть зразок чанків з реального використання за допомогою як токенізаторів, так і алгоритмів обчислення довжин гістограм. Якщо значення p95 для вбудовувача перевищує max_length - specials, ви обрізаєте дані під час роботи системи, навіть якщо середня довжина здається прийнятною.
Команди, які мігрують від ембеддингів OpenAI до локальних моделей MiniLM (або навпаки), повинні не лише змінювати спосіб вбудовування даних, а й перерозбивати їх на чанки. Розмір корпусу, підібраний для одного формату, є непридатним для іншого.
Чому topK та алгоритми переранкінгу не можуть компенсувати проблеми
Підвищення значення topK збільшує ймовірність отримання деякого частково релевантного фрагмента, але кожен фрагмент все одно приховує свій власний обрізаний кінець. Бюджети запитів різко зростають: десять фрагментів по 800 токенів становлять тисячі токенів переважно ніколи не шуканого тексту. Крос-енкодери, які оцінюють повний текст фрагмента, бачать лише кандидатів, яких вже запропонував етап векторів; вони не можуть підняти у пріоритет фрагмент, чий пошуковий префікс ніколи не збігався. Пороги, близькі до нуля, вже приймають майже все, що повертає векторна пошукова система; їх збільшення призводить до відкидання кандидатів без відновлення невидимих частин тексту.
Виправлення полягає у процесі надходження даних: потрібно адаптувати вікно, змінити модель чи розділити дані, поки не буде гарантовано їх вміст у межах вікна.
Матриця політики метаданих
Вирішуйте для кожної галузі: вбудовувати, лише отримувати чи видаляти. Шляхи та UUID зазвичай псують структуру вбудованих даних. Заголовки часто допомагають у розрізненні та іноді слід їх вбудовувати, а завжди — отримувати. Булеві значення на кшталт deprecated краще розміщувати у фільтрах, а не в тексті. Документуйте матрицю поруч із конструктором форматувальника, щоб майбутні зміни випадково не змінювали значення енумерацій.
Контракти цитування в консультантах Spring AI
Якщо стандартний консультант типу Q&A перетворює документи на рядки, обгорніть його або замініть так, щоб кожен використаний фрагмент зберігав поля source, chunk_index та заголовок. Примітки в інтерфейсі мають посилатися на ці поля. Додайте тест, який буде провалюватися, якщо цитування порожні, незважаючи на наявність контексту. Довіра до продукту залежить від цього більше, ніж від значень BLEU.
Способи структурного розділення
Для посібника з Markdown: розділяйте текст на ##, а потім на абзаци. Для коментарів у стилі Javadoc: розділяйте їх за межами оголошень. Для журналів подій: не вставляйте всю інформацію про стек викликів як один блок; розділяйте її за часовими позначками або ідентифікаторами помилок. Для таблиць: у кожен блок входить один рядок або одна логічна запис, з заголовками стовпців як префіксом. Для коду: віддавайте перевагу дробленню на функції замість довільних фрагментів, щоб запити про місце виклику повертали цілі функції.
Перекриття як хірургічний інструмент
Використовуйте перекриття, коли оцінка показує пропуски меж у реченнях, які проходять через межі блоків. Зазвичай починають з 5–15 відсотків розміру блоку; виміряйте витрати на зберігання та вставку перед тим, як переходити на 50 відсотків. Перекриття ніколи не вирішує проблему обрізання тексту; воно лише дублює видимі префікси, якщо вже немає місця у блоку.
Операція «вставити мало/повернути багато» у практиці
Зберігайте section_id, parent_id та body_small / body_full (або підключіться до таблиці sections). Виконуйте пошук за ембеддингами body_small; використовуйте body_full для отримання додаткової інформації. Слідкуйте за випадками, коли короткі текстові фрагменти не мають достатньо сигналу — тоді коригуйте механізм розділення, а не лише параметр topK.
Набір тестів для фіксації поведінки
- Чутливість до обрізання: змінюйте суфікси фрагментів поза межами визначеного окна; перевіряйте політику змін вектора (вона має залишатися незмінною при обрізанні).
- Мінімальний рівень відтворення: перевіряйте „золоті“ запитання з фрагментами у початку, середині та кінці; відхиляйте версії, у яких після змін механізму розділення знижується рівень відтворення у середині чи кінці.
- Збереження посилань: перевіряйте процес отримання зразків; стверджуйте, що ключі метаданих присутні.
- Захист від надмірної довжини: процес імпорту зазнає невдачі, якщо кількість токенів у ембеддері перевищує допустимий максимум.
Повільні тести, які завантажують ваги ONNX, мають виконуватися у нічних сесіях; для перевірки PR залиште лише фіктивний шлях до одиниці.
Чек-лист для роботи (розширений)
Узгоджуйте мови токенів; забезпечуйте відповідність розміру вікна; робіть розділення на частини, спочатку структуруючи даних; використовуйте розріджене перекриття; майте спеціалістів з таблицями та кодом; створюйте матрицю метаданих; використовуйте консультантів для збереження посилань; проводьте ідеальну оцінку до та після змін; використовуйте метод embed-small/return-big, коли під час генерації потрібен контекст, якого не бачить ембеддер. Чанкування залишається вузьким місцем у системі пошуку — чітко визначте його проблеми.