Главная / Статьи

Чанкинг — это вся система поиска: ловушка Spring AI 800 против 128

Когда сепаратор генерирует блоки по 800 токенов, а эмбеддер считывает только 128 токенов, система поиска не может увидеть обрезанную часть данных. Размер блоков, окно и степень перекрытия — это параметры системы поиска.

Издатель: REACTAPP.TOP16 мин чтения3727 слов

Источники: Chunking Is the Whole Retrieval System

По умолчанию инструмент 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 может нарушить структуру семантического пространства; исключение заголовков может устранить единственный сигнал, помогающий разрешать неоднозначности. Сознательно выбирайте между встраиванием и получением метаданных — изменение одного параметра enum влияет как на качество воспроизведения, так и на качество цитирования.

Стандартный советник удаляет ваши цитаты

Типичный запрос типа «вопрос-ответ» кажется простым:

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 у каждого оставшегося токена есть скрытый вектор. Метод среднего агрегирования суммирует эти векторы и делит результат на количество немаскированных позиций. Токены-заполнители не вносят никакого вклада, когда маска верна; токены с обрезанным содержимым также не влияют, поскольку они никогда не участвовали в процессе обработки. Иногда люди предполагают, что «модель читала объект документа», но на самом деле она читала результаты работы токенизатора. Если в логах выводится полный текст фрагмента рядом с вектором, это означает, что вы одновременно видите реальность хранения данных и реальность поиска — причем они не совпадают, когда длина данных превышает заданный лимит.

Поиск по сходству затем классифицирует результаты на основе расстояния или косинусного сходства относительно этого агрегированного вектора. Запрос о исключении, связанном с возвратом средств, расположенный во второй половине фрагмента политики длиной 800 токенов, плохо конкурирует с фрагментами, в которых упоминание возврата средств происходит сразу в начале. Авторы не писали плохих политик; проблема заключается в том, что индекс не может обнаружить пункт о исключении.

Пайплайны советников и чистота цитирования

Клиенты чата, использующие советники для поиска информации, зачастую объединяют содержимое документов в одну строку контекста. Если ключи метаданных не сохраняются в объекте ответа или в специальных маркерах цитирования в шаблоне запроса, модель может дать правильный ответ, но приложение не сможет выделить соответствующий раздел руководства. В тестах рассматривайте поля цитирования как часть условий взаимодействия с системой поиска: после каждого ответа проверяйте, что каждый использованный идентификатор фрагмента соответствует source, chunk_index и, при наличии, заголовку.

Пользовательские советники могут вставлять сноски или блоки JSON, которые интерфейс может обработать. Стандартные демо-версии пропускают эту функциональность, поэтому многие внутренние инструменты RAG поставляются с «ответами без ссылок».

Экономика перекрытия данных за пределами формулы

Формула расчёта количества фрагментов с учётом перекрытия является инструментом планирования, а не гарантией качества. Перекрытие полезно, когда предложение проходит через границу; оно не может восстановить токены, утраченные при генерации. Оплата за перекрытие при сохранении значения max_length в 128 приводит к тратам средств на дублирование невидимых частей текста. Сначала устраните проблемы с подходом окна к данным, затем добавьте небольшое перекрытие, если при оценке всё ещё наблюдается проблема с выходом за границы.

Подробности разделения с упором на структуру

Рекурсивные средства разделения, которые сначала пробуют использовать заголовки, затем абзацы, потом предложения и в конце — отдельные символы, работают стабильно. Средства разделения с учетом заголовков сохраняют структуру вроде “## Возвраты” вместе с соответствующими пунктами. Разделение по абзацам поддерживает целостность изложения. Разделение по символам является крайним средством; оно должно проваливать тесты обработки, если занимает доминирующее положение в корпусе текстов — это свидетельствует о том, что документы не имеют структуры, достойной индексации как проза. Для работы с Markdown в API рекомендуется разделять текст по тегам ##, чтобы каждый метод представлял собой отдельный вектор.

Путь поиска Embed-small/return-big

Во время запроса поиск по сходству возвращает идентификаторы небольших фрагментов. С помощью таблицы связей или поля родительского идентификатора в метаданных загружается более полный текст фрагмента для обработки запроса. Такой подход позволяет MiniLM сосредотачиваться на узких семантических единицах, в то время как большая языковая модель получает контекстное описание. Стоимость реализации заключается в использовании дополнительного хранилища или поля с данными родителя — это дешевле, чем притворяться, что окно размером 128 токенов способно понять целую страницу.

Культура оценки

Идеальные наборы должны включать вопросы, на которые ответы расположены в начале, середине и конце длинных разделов. Даже если проходят только ответы, находящиеся в начале, проблема обрезки текста остается актуальной. Если в корпусе присутствуют такие форматы, необходимо включить по крайней мере один вопрос, связанный с таблицами, и один — с кодом. Отмечайте медленные тесты, которые загружают веса в формате ONNX, чтобы система CI могла их пропускать при стандартных проверках пул-реквестов, в то время как ночные сборки продолжают выполнять проверки чувствительности к векторам.

Конфигурация и рекомендации

Согласуйте правила разделения токенов с токенизатором инструмента для встраивания, сохраняйте содержимое блоков внутри окна эмбеддинга, исключайте шумные метаданные из эмбеддингов, но сохраняйте их для цитирования, предпочитайте структурное разделение, используйте перекрытие осторожно и учитывайте особенности таблиц и кода. После каждой изменения повторно запустите фиксированный набор тестов.

Стратегия тестирования

Автоматизированные тесты должны контролировать свойства, которые обычно игнорируются в демонстрациях.

Докажите, что окончание блока изменяет его вектор

@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
}

Сохраняйте минимальный уровень показателя воспроизводимости@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 ненадежен», хотя на самом деле индекс просто не включил нужный фрагмент ответа. Потрачение дня на разработку инструментов оценки перед созданием структуры запросов обычно сокращает общий срок реализации проекта.

Чек-лист операций

  1. Выпишите значение max_length устройства встраивания данных и дополнительные затраты на обработку специальных токенов.
  2. Измерьте медианную и p95 длины токенов фрагментов в токенизаторе устройства встраивания.
  3. Прерывайте процесс обработки, если размер фрагмента превышает допустимые значения.
  4. Сохраняйте эталонный набор вопросов с фрагментами ответов и требованиями к цитированию.
  5. После каждой изменения разделителя или метаданных выполняйте тесты на восстановление информации и проверку цитат.
  • Рекомендуется использовать embed-small/return-big, когда контекст родителя имеет значение при генерации.
  • Команды, считающие эти шаги необязательными, снова сталкиваются с ними во время демонстрации для руководства, когда ответ из руководства находится на второй странице блока из 800 токенов.

    Блокировка — это узкое место в системе поиска информации. Векторы представляют среднее из того, что им показано; они не могут создавать элементы, которых раньше не видели. Необходимо согласовать языки токенов, адаптировать окно обработки, делить данные по структуре, корректно обрабатывать таблицы, сохранять цитаты и проверять поведение с помощью тестов. Только после этого topK, пороги и алгоритмы переранжирования смогут работать с набором кандидатов, который действительно этого заслуживает.

    Культуры инжиниринга, рассматривающие «800 токенов» как универсальную единицу, будут вечно неправильно определять размер блоков. В логах запуска необходимо выводить как кодировку разделителя, так и кодировку встраивания. Преобразуйте примеры блоков из продакшена с помощью обоих токенизаторов и метода на основе длин гистограмм. Если значение p95 для встраивателя превышает max_length - specials, происходит обрезка данных в продакшене, даже если средняя длина кажется нормальной.

    Команды, переходящие от встраивания данных OpenAI к локальному MiniLM (или наоборот), должны заново формировать блоки, а не просто заново встраивать данные. Корпус, адаптированный для одного формата окна, не подходит для другого.

    Почему методы topK и rerankers не могут компенсировать проблему

    Увеличение значения topK повышает вероятность получения некоторого частично релевантного фрагмента, но каждый фрагмент по-прежнему скрывает свой собственный обрезанный конец. Бюджеты запросов растут стремительно: десять фрагментов по 800 токенов составляют тысячи токенов текста, который в основном никогда не ищут. Кросс-кодеры, оценивающие полный текст фрагмента, видят только кандидатов, уже предложенных на этапе векторного анализа; они не могут выбрать фрагмент, чей поисковый префикс никогда не совпадал. Пороги, близкие к нулю, уже принимают практически все результаты векторного поиска; их ужесточение приводит к отбрасыванию кандидатов без восстановления скрытых участков текста.

    Решение заключается в моменте загрузки данных: необходимо подобрать подходящее окно, изменить модель или разделить данные до тех пор, пока не будет гарантировано соответствие.

    Матрица политики метаданных

    Решайте для каждого поля: встраивание, только получение или удаление. Пути и UUID обычно портят структуру встраиваемого контента. Заголовки часто помогают избежать неоднозначностей, поэтому их иногда следует встраивать, а всегда — получать. Логические значения вроде deprecated лучше размещать в фильтрах, а не в тексте. Опишите структуру рядом с инструментом формирования форматтера, чтобы при последующих изменениях не происходило случайной замены значений из перечислений.

    Контракты цитирования в советниках Spring AI

    Если стандартный советник для вопросов и ответов преобразует документы в строки, оберните его или замените так, чтобы каждый используемый фрагмент сохранял поля source, chunk_index и заголовок. Примечания в интерфейсе должны ссылаться на эти поля. Добавьте тест, который будет срабатывать при пустых цитатах даже в случае наличия непустого контекста. Доверие к продукту зависит от этого больше, чем от показателей BLEU.

    Способы структурного разделения

    Для руководства по Markdown: делите на ##, а затем на абзацы. Для комментариев в стиле Javadoc: делите по границам объявлений. Для логов: не вставляйте целые стеки ошибок как единый блок; делите по временным меткам или идентификаторам ошибок. Для таблиц: в каждом блоке — одна строка или один логический запись с заголовками столбцов в качестве префикса. Для кода: предпочитайте разбиение по функциям вместо произвольных окон токенов, чтобы при поиске информации о месте вызова возвращались целые функции.

    Перекрытие как хирургический инструмент

    Используйте перекрытие, когда анализ показывает пропуски границ у предложений, расположенных на границах разделов. Обычно начинают с 5–15 процентов от размера блока; измерьте затраты на хранение и встраивание перед тем, как перейти к 50 процентам. Перекрытие никогда не устраняет обрезку данных; оно лишь дублирует видимые префиксы, если окно уже заполнено.

    Операционное применение метода Embed-small/return-big

    Храните section_id, parent_id и body_small / body_full (или свяжите их с таблицей разделов). Проводите поиск с использованием эмбеддингов body_small; для получения дополнительной информации используйте body_full. Отслеживайте случаи, когда у коротких текстов недостаточно сигнала — в таких случаях корректируйте механизм разделения текста, а не только параметр topK.

    Набор тестов для фиксации поведения

    • Чувствительность к обрезке: изменяйте суффиксы фрагментов за пределами заданного окна; проверяйте политику изменения векторов (она не должна меняться при обрезке).
    • Минимальный уровень воспроизводимости: используйте эталонные вопросы с фрагментами в начале, середине и конце текста; отклоняйте те версии, у которых снижается воспроизводимость в середине или конце после изменений в механизме разделения текста.
    • Сохранение цитат: проверяйте процесс извлечения примеров; убедитесь, что ключи метаданных присутствуют.
    • Контроль длины: процесс ввода данных не должен завершаться с ошибкой, если количество токенов в эмбеддингах превышает допустимый максимум.

    Медленные тесты с загрузкой весов ONNX следует выполнять в ночных циклах; для проверки в PR CI сохраняйте упрощённый путь к модулю.

    Чек-лист операций (расширенный)

    Согласовывайте языки токенов; обеспечивайте соответствие размера окна; сначала делайте разбиение по структуре; используйте редкое перекрытие; назначайте специалистов по таблицам и коду; формируйте матрицу метаданных; применяйте инструменты для сохранения цитат; выполняйте золотой тест до и после изменений; используйте подход embed-small/return-big, когда генерация требует контекста, недоступного для эмбеддера. Чанкинг по-прежнему остаётся узким местом в системе поиска — прямо берите на себя ответственность за это.