Разделение документа на смысловые фрагменты
Загрузчик уже возвращает проверенные документы, но целая статья слишком велика для удобного поиска и ответа. Вопрос о том, попадают ли черновики в sitemap, должен привести к соответствующему разделу, а не заставлять помощника передавать всю инструкцию по обновлению сайта. Разделим документы на ограниченные фрагменты, сохранив их принадлежность источнику. Новый результат — список объектов, каждый из которых имеет текст, заголовок раздела, публичный URL и отпечаток исходного документа.
Используем lesson-03 того же проекта. Новый файл chunks.py получает объекты от loader.py; сам загрузчик не меняется. Публичные и служебные источники пока остаются в локальном наборе оператора. На этой стадии мы обсуждаем форму единицы поиска, а не выдаём её пользователю. Важно не путать разбиение текста с предоставлением доступа: корректный фрагмент служебного документа всё ещё является служебным.
Граница раздела
Проще всего отрезать каждые несколько сотен символов. Однако такой способ может оставить в одном фрагменте конец объяснения постоянного URL и начало совершенно другой темы. Читатель увидит контекст, который выглядит связным лишь потому, что предложения стояли рядом в файле. Мы сначала замечаем Markdown-заголовки. При новом заголовке накопленный текст предыдущего раздела сбрасывается в отдельную группу. Следующие абзацы связываются с новым названием. Этот простой приём сохраняет хотя бы первую смысловую границу исходной статьи.
В учебном корпусе используются обычные строки заголовков, начинающиеся с #. Наш разбор не является полноценным парсером Markdown. Он не понимает Setext-заголовки, вложенные блоки, сложные цитаты и все правила CommonMark. Поэтому формат источников ограничен явно, а замена парсера позже будет отдельным изменением. Умение назвать такую границу полезнее, чем спрятать простой построчный алгоритм под обещанием «поддерживается Markdown». Если импортировать произвольные статьи, сначала нужно определить требуемое подмножество синтаксиса.
Почему исключаем блоки кода
Строки fenced-блоков между тройными обратными кавычками пропускаются. В нашей первой версии поиск предназначен для объясняющей прозы: кодовые идентификаторы и повторяющиеся команды могут отвлекать ранжирование от самого понятия. Флаг fenced переключается на открывающей и закрывающей границе. При этом заголовок внутри кода не становится новым разделом. Это важная деталь: строка # комментарий в Python не должна неожиданно разрезать статью на ложный раздел с названием комментария.
Такой выбор имеет цену. На вопрос «Какой параметр команды включает импорт realm?» помощник может не найти ответ, если точная команда осталась только в коде. Мы не пытаемся устранить этот недостаток догадкой. Для поиска по исходникам понадобятся отдельный тип фрагмента, язык блока и другая обработка идентификаторов. Сейчас цель скромнее: сохранить объяснение, которое можно процитировать человеку. Различие текста и кода станет видимым ограничением корпуса и позже попадёт в сценарии оценки качества.
После отделения раздела разбиваем текст по пустым строкам и собираем слова в части размером примерно до семисот символов. Ограничение считается в символах Python, а не в байтах UTF-8 и не в токенах языковой модели. Русская буква обычно занимает несколько байтов, поэтому эти величины не взаимозаменяемы. Наш предел помогает управлять небольшим корпусом без дополнительного токенизатора; он не позволяет заранее обещать точное количество входных токенов любого внешнего рантайма.
Какие свойства переезжают во фрагмент
Каждый фрагмент копирует doc_id, title, section, url, visibility и doc_hash. Текст получает собственное поле text. Функция flatten добавляет идентификатор вида sitemap:1. Первая часть связывает его с документом, номер обозначает порядок в текущем разбиении. Этот идентификатор удобен внутри версии индекса, но не является постоянной публичной ссылкой. Если в начало документа добавили новый абзац, последующие номера могут сдвинуться. Поэтому ответ должен хранить ещё и версию источника либо индекса.
URL остаётся адресом целой статьи. Мы не придумываем якорь по названию раздела, потому что код не проверял наличие такого якоря на реальном сайте. Ссылка на несуществующий #sitemap выглядела бы точнее, но не дала бы лучшего перехода. При реальном импорте якоря можно извлекать из того же генератора, который выпускает страницы. В текущей лаборатории заголовок раздела выводится рядом с фрагментом, а переход ведёт к заданному редактором постоянному адресу документа.
Обратите внимание на поведение очень длинного абзаца. Алгоритм продолжает накапливать слова, пока следующее слово не превышает предел, затем начинает новую часть. Одно слово длиннее предела остаётся целиком: мы не ломаем URL или идентификатор посередине. Это осознанная мягкая граница, а не абсолютная гарантия размера. Дальнейшая упаковка контекста может пропустить такую часть. Если нужны строгие лимиты для недоверенного корпуса, нужно определить отдельную политику длинных токенов и объяснить её пользователю.
У этого разбиения нет перекрытия соседних частей. Значит, предложение, оказавшееся у границы, иногда теряет соседнее пояснение. Перекрытие могло бы улучшить контекст, но одновременно увеличило бы повторение источника и число кандидатов. Мы пока выбираем прозрачность. В следующих уроках легче увидеть, какая часть отвечает на вопрос, когда один абзац не размножен в трёх почти одинаковых объектах. Если оценка покажет систематическую потерю связей, изменение разбиения следует сравнивать на тех же контрольных вопросах.
Полный файл ниже не требует внешней библиотеки. После самостоятельного запуска python3 app.py ожидается список фрагментов с сохранённым visibility. В документе о шаблонах вы увидите отдельную часть о общей оболочке и часть о навигации уроков. Название раздела помогает понять, почему совпадение слова «урок» в одной части полезнее для вопроса о соседних страницах. Сам текст пока не ранжируется; порядок соответствует обходу манифеста и разделов. Это позволит сначала проверить форму данных независимо от поисковой формулы.
Для собственного разбора добавьте в templates.md fenced-блок с несколькими строками и заголовком внутри. Ожидайте, что ни код, ни его псевдозаголовок не появятся в текстовой части. Затем добавьте обычный новый раздел: он должен получить своё значение section. Сценарии не выполнялись при подготовке. Когда структура фрагментов станет понятной, следующий урок введёт одинаковое преобразование текста вопроса и текста источников, частоты слов и ранжирование BM25 без скрытого векторного сервиса.
Полный файл chunks.py из состояния lesson-03:
import re
def chunks(document, max_chars=700):
section, paragraphs, fenced = document["title"], [], False
for line in document["text"].splitlines():
if line.startswith("```"):
fenced = not fenced
continue
if fenced:
continue
if line.startswith("#"):
if paragraphs:
yield from split_paragraphs(document, section, paragraphs, max_chars)
paragraphs = []
section = line.lstrip("# ").strip()
else:
paragraphs.append(line)
yield from split_paragraphs(document, section, paragraphs, max_chars)
def split_paragraphs(document, section, lines, max_chars):
text = "\n".join(lines).strip()
if not text:
return
groups, current = [], ""
for paragraph in re.split(r"\n\s*\n", text):
words = paragraph.split()
for word in words:
candidate = f"{current} {word}".strip()
if len(candidate) > max_chars and current:
groups.append(current)
current = word
else:
current = candidate
if current and len(current) > max_chars * 0.7:
groups.append(current)
current = ""
if current:
groups.append(current)
for part in groups:
yield {"doc_id": document["id"], "title": document["title"],
"section": section, "url": document["url"],
"visibility": document["visibility"], "text": part,
"doc_hash": document["hash"]}
def flatten(documents):
rows = []
for document in documents:
for ordinal, chunk in enumerate(chunks(document), 1):
rows.append({**chunk, "id": f'{document["id"]}:{ordinal}'})
return rows
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.