Размер блоков и перекрытие в RAG: шаг, лимиты токенов и границы
Как разбить длинную книгу на части для RAG: выбор размера и степени перекрытия, понимание параметра stride, соблюдение лимита токенов для эмбеддингов, хранение метаданных и проверка степени перекрытия при реальном разделении.
Возьмем автобиографию Бенджамина Франклина с Project Gutenberg. Вставка всей книги в команду является расточительством, даже если окно контекста могло бы её вместить. На вопрос о том, почему Франклин хотел, чтобы Великобритания оставила себе Канаду, ответ, вероятно, содержится в двух-трех отрывках, а оставшиеся страницы займут всё пространство контекста и скроют доказательства. Для таких ситуаций существует технология генерации с усилением на основе поиска информации (RAG). Система находит отрывки, связанные с вопросом, и передаёт только их языковой модели. Книга остаётся в хранилище, а система поиска выбирает необходимые доказательства.
Эмбеддинги и причины разделения текста
RAG начинается с преобразования фрагментов текста в эмбеддинги — числовые представления смысла. Фрагменты, имеющие схожий смысл, находятся рядом друг от друга в высокомерномензиональном векторном пространстве, даже при различной формулировке. Когда поступает вопрос, он также преобразуется в эмбеддинг, находятся ближайшие хранимые векторы, и возвращаются соответствующие фрагменты текста. Вектор определяет местоположение текста, и именно этот текст считывается моделью.
Один эмбеддинг не может представить целую книгу. Один вектор, описывающий жизнь Франклина, смешает его детство в Бостоне, эксперименты с печатанием и электричеством, произведение «Бедный Ричард» и свидетельства в парламенте в нечто, что плохо отражает ни один из этих аспектов. Поэтому текст необходимо разбивать на части перед преобразованием в эмбеддинги.
Размер, перекрытие и шаг
Разумная отправная точка:
DEFAULT_CHUNK_SIZE = 800 # characters
DEFAULT_CHUNK_OVERLAP = 150 # characters
Люди часто рассматривают их как два независимых регулятора. На самом деле они определяют одну и ту же вещь — насколько окно смещается вперед при каждом шаге:
stride = chunk_size − chunk_overlap
= 800 − 150
= 650 characters
Эта величина указывает на количество нового текста, который добавляется каждым новым фрагментом.
Рассматривайте размер фрагмента как максимальный лимит, а не как конкретную цель. Инструменты разделения предпочитают делать разрез в естественных местах — будь то абзац, строка, предложение или слово, поэтому запрос на 800 символов может привести к фрагментам длиной 640, 731 или ровно 800.
Перекрытие — это текст, общий для соседних фрагментов, и он действительно выполняет важную функцию. При отсутствии перекрытия разрез может оставить неполный фрагмент вроде этого:
"…but left school at the age of ten."
Сама по себе эта строка мало что говорит. При наличии перекрытия следующий фрагмент сохраняет достаточно предыдущего контекста, чтобы оставаться читаемым и находимым. Это особенно важно для длинных предложений и тесно связанных аргументов. В письме Франклина о Канаде есть длинная цепочка предложений, которые могут простираться через границу, в результате чего один фрагмент остается без вывода, а следующий — без посылки. Умеренное перекрытие позволяет сохранить связь между обоими записями.
Цена этого — дублирование. Соседние записи с одинаковым формулированием могут находиться вместе, что является нормальным. Когда дубликаты вытесняют более достоверные данные, метод, учитывающий разнообразие, такой как максимальная граничная релевантность (MMR), помогает в решении проблемы.
Что содержит каждый сохранённый фрагмент
Каждый фрагмент содержит четыре поля:
id
document
embedding
metadata
Пример записи:
id 7c2a1e90-4b11-4d3e-9f08-12a6c0e84b21
document "His boyhood in Boston was a stern beginning of the habit
of hard work and rigid economy which marked the man. For
a year he went to the Latin Grammar School on School
Street, but left off at the age of ten to help his father
in making soap and candles."
embedding 384 floats — [-0.0412, 0.0187, 0.0621, -0.0094, 0.0330, …]
norm = 1.0
metadata {
book: "Franklin's Autobiography",
source: "https://www.gutenberg.org/cache/epub/36151/pg36151-images.html",
gutenberg_id: 36151,
page_label: "5",
chunk_index: 2
}
Эмбеддинги предназначены для поиска, а оригинальный текст — для чтения, цитирования и ссылок. Метаданные также требуют внимания. Если издание содержит обозначения страниц, такие как [Pg 5], сохраняйте их, чтобы ссылка вела на страницу, которую читатели могут найти в своем экземпляре.
Перед индексацией удаляйте материалы низкой ценности: титульные страницы, шаблонный текст, условия лицензии, повторяющиеся заголовки и блоки сносок. Они создают слабые эмбеддинги и снижают качество поиска.
Нормализованные векторы
Векторы эмбеддингов обычно нормализуются по стандарту L2, что позволяет сделать длину каждого вектора равной 1:
‖v‖ = √(v₁² + v₂² + … + vₙ²)
Похожесть косинуса сравнивает два вектора следующим образом:
cos θ = (a · b) / (‖a‖ ‖b‖)
Когда у обоих величин единичная длина, это сводится к простому скалярному произведению:
If ‖a‖ = ‖b‖ = 1:
cos θ = a · b
Рейтинг отражает степень совпадения направлений двух значений, причем величина вектора не играет никакой роли, что обеспечивает быстрый и значимый поиск.
Токены, а не символы
Настройки, указанные в единицах символов, могут скрывать жесткое ограничение, поскольку модели эмбеддингов учитывают токены. Модель all-MiniLM-L6-v2 принимает не более 256 токенов. Более длинный фрагмент может быть обрезан без какого-либо предупреждения. В базе данных всё ещё хранится полный текст, но его эмбеддинг охватывает только начальную часть, а обрезанный конец становится трудно или невозможно восстановить. Осторожная оценка:
maximum safe chunk size in characters
≈ model token limit × 4
У модели с 256 токенами получается примерно 1 000 символов. Использование четырех символов на токен оставляет запас, поскольку диалоги, аббревиатуры, знаки препинания и форматированный текст могут быть разбиты на токены более плотно, чем проза.
Более новые модели могут обрабатывать гораздо более длинные входные данные, но разбиение на части по-прежнему важно. Больший объем данных не решает проблемы извлечения информации, поскольку чрезмерно большие блоки смешивают несвязанные темы в один размытый вектор.
Процесс обработки
Сначала необходимо разбить загруженные страницы на части:
from rag_qa.gutenberg import load_pages
from rag_qa.chunk import chunk_documents
from rag_qa.config import load_settings
s = load_settings()
print(s.summary())
# {
# 'chunk_size_chars': 800,
# 'chunk_overlap_chars': 150,
# 'stride_chars': 650, # size − overlap; this sets chunk count
# 'model': 'all-MiniLM-L6-v2',
# 'model_max_tokens': 256, # hard cap; overflow is silent
# 'embedding_dims': 384,
# 'max_safe_chunk_chars': 1024, # 256 × 4
# 'book': "Franklin's Autobiography",
# 'gutenberg_id': 36151,
# }
pages = load_pages()
chunks = chunk_documents(pages, s.chunk_size, s.chunk_overlap)
print(len(pages), len(chunks), s.stride)
Во-вторых, проверьте каждый фрагмент с учётом лимита токенов модели:
from sentence_transformers import SentenceTransformer
from rag_qa.tokens import check_chunk
m = SentenceTransformer("all-MiniLM-L6-v2")
print(m.max_seq_length) # 256
for c in chunks:
r = check_chunk(c)
if r.truncated:
print("silent truncate:", r.n_chars, "chars /", r.n_tokens, "tokens")
Скрипт выводит максимальную длину модели, затем перечисляет только те фрагменты, которые будут беззвучно обрезаны. При обычной работе выводится только этот лимит:
256
Когда всё помещается, вы видите только это. Если некоторые части превышают размер, появляются дополнительные строки:
256
silent truncate: 1820 chars / 412 tokens
silent truncate: 960 chars / 301 tokens
Как читать эти строки:
В-третьих, посмотрите, что в итоге сохраняется:
from rag_qa.store import ingest, open_store
store = open_store()
ingest(chunks, store)
row = store.get(limit=1)
print(row["ids"][0])
print(row["documents"][0][:200])
print(len(row["embeddings"][0]), row["metadatas"][0])
# 384 floats, norm 1.0, metadata.page_label == printed [Pg N]
Этот фрагмент показывает один кусок данных после их обработки и выводит три строки:
doc-12-p3-c0
She left school at the age of ten. The next sentence continues on the same page…
384 {'page_label': '3', 'source': 'notes.pdf'}
В-четвертых, сделайте запрос к нему:
from rag_qa.retrieve import search, search_mmr
q = "why did Franklin want Britain to keep Canada"
for hit in search(q, k=5):
print(f"{hit['score']:.3f} p.{hit['metadata']['page_label']} {hit['document'][:120]}")
# overlap makes near-duplicate hits; MMR trades a little score for diversity
for hit in search_mmr(q, k=5):
print(hit["metadata"]["page_label"], hit["score"])
Пример результатов:
0.812 p.7 I have long been of opinion that the foundations of the future grandeur and stability of the British empire lie in America
0.781 p.7 they are, nevertheless, broad and strong enough to support the greatest political structure that human wisdom ever yet
0.744 p.7 I am, therefore, by no means for restoring Canada. If we keep it all the country from the St. Lawrence to the Mississippi
0.691 p.8 I left England about the end of August, 1762, in company with ten sail of merchant ships
0.640 p.6 In this Autobiography Franklin tells of his own life to the year 1757, when he went to England
Самые популярные результаты часто приходят с одной и той же страницы. Это проявление перекрытия, поскольку соседние фрагменты одного предложения полностью совпадают.
Перекрытие на небольшом примере
Возьмем короткий отрывок примерно из 190 символов из введения к книге, описывающий детство автора в Бостоне: ранние привычки усердия и экономии, год обучения в Латинской грамматической школе на Улице Школы и уход из неё в возрасте десяти лет. Разделим его на фрагменты размером по 60 символов каждый.
При отсутствии перекрытия инструмент разделения делает разрез ровно по установленному лимиту:
[0] 59 His boyhood in Boston was a stern beginning of the habit of
[1] 55 hard work and rigid economy which marked the man. For a
[2] 58 year he went to the Latin Grammar School on School Street,
[3] 31 but left off at the age of ten.
Чанк 0 заканчивается на фразе «привычка к», а чанк 1 начинается с фразы «усердная работа», причем между ними нет никакой связи. Фраза «привычка к усердной работе» никогда не входит полностью ни в один чанк. Если разделение происходит посередине предложения, каждый вектор кодирует лишь половину идеи, и запрос, требующий полного предложения, плохо совпадает с обеими частями.
При наложении 15 символов:
[0] 59 His boyhood in Boston was a stern beginning of the habit of
[1] 57 ⟦the habit of⟧ hard work and rigid economy which marked the
[2] 55 ⟦marked the⟧ man. For a year he went to the Latin Grammar
[3] 58 ⟦Latin Grammar⟧ School on School Street, but left off at the
[4] 22 ⟦off at the⟧ age of ten.
Маркер ниже показывает, какой текст повторяется из предыдущего фрагмента:
⟦…⟧ = text repeated from the previous chunk
Ни один фрагмент не достигает 60 символов, что снова показывает, что это лишь верхняя граница. Игнорирование перекрытий приводит к меньшему индексу и отсутствию дублирующихся результатов, но всё, что случайно оказалось разрезано при разделении, теряется. Перекрытия обеспечивают связность фрагментов и не являются дополнительным контекстом ради самого факта наличия перекрытий.
Четыре задачи и инструменты для их выполнения
RAG включает четыре этапа: загрузка текста, его разделение, встраивание и хранение/поиск векторов. Нет единой библиотеки, которая была бы именно «RAG». Инструменты для разделения текста и координации операций:
Модели встраивания:
Популярные базы данных векторов дополняют этот набор инструментов.