Перейти к содержанию

Фрагменты Markdown и устойчивые ссылки

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

Добавим chunks.py в состояние lesson-02. Он читает авторский JSON-корпус, проверяет идентификаторы и делит Markdown по заголовкам второго уровня и абзацам. После выполнения читателем ожидается список фрагментов с полями heading, text и part. Авторский запуск здесь не проводился; листинг задаёт механизм и ожидаемую форму результата.

Заголовок как граница смысла

Заметка cache содержит два раздела: «Проверка версии» и «Граница общего кэша». Первый объясняет условный запрос, второй напоминает об ограничениях для личных ответов. Если объединить их в одну строку без заголовков, поиск может вернуть текст, но читателю будет труднее понять, к какой операции относится найденная оговорка.

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

Наш разборщик поддерживает намеренно узкий формат. В corpus.json каждый Markdown-документ начинается с H2, а содержимое разделов состоит из обычных абзацев. Здесь нет YAML front matter, таблиц, fenced-кода и сложных вложенных конструкций. Если заменить корпус произвольными статьями, потребуется полноценный Markdown-парсер и правила для листингов. Регулярное выражение не становится таким парсером от увеличения размера корпуса.

Чтение и проверка идентификаторов

В load_corpus() разрешены короткие латинские идентификаторы. Дополнительно проверяется отсутствие повторов. Это важно не только для красивого имени: идентификатор связывает запись документа, его фрагменты и будущий адрес. Два источника с одним id сделали бы импорт неоднозначным, даже если их тексты различаются.

import hashlib, json, re
from pathlib import Path
SPLITTER='h2-paragraph-v1'
def load_corpus(path='corpus.json'):
    rows=json.loads(Path(path).read_text())
    seen=set()
    for doc in rows:
        if not re.fullmatch(r'[a-z][a-z0-9-]{0,40}',doc['id']) or doc['id'] in seen:
            raise ValueError('invalid or duplicate document ID')
        if doc['url']!=f"/documents/{doc['id']}.html" or doc['visibility'] not in {'public','editor'}:
            raise ValueError('unexpected source contract')
        seen.add(doc['id'])
    return rows
def split_doc(doc):
    sections=re.split(r'^##\s+(.+)$',doc['markdown'],flags=re.M)
    if sections[0].strip():raise ValueError('expected H2 before body')
    result=[]
    for heading,body in zip(sections[1::2],sections[2::2]):
        for paragraph in re.split(r'\n\s*\n',body.strip()):
            words=paragraph.split();pending=[];size=0
            for word in words:
                if len(word)>600:raise ValueError('word exceeds character budget')
                if pending and size+len(word)+1>600:
                    result.append({'heading':heading.strip(),'text':' '.join(pending)})
                    pending=[];size=0
                pending.append(word);size+=len(word)+1
            if pending:result.append({'heading':heading.strip(),'text':' '.join(pending)})
    if not result:raise ValueError('empty document')
    for part,row in enumerate(result):row['part']=part
    return result
def revision(doc,space):
    payload=json.dumps({'doc':doc,'splitter':SPLITTER,'space':space},ensure_ascii=False,sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()
if __name__=='__main__':
    print(json.dumps({d['id']:split_doc(d) for d in load_corpus()},ensure_ascii=False,indent=2))

Функция проверяет, что адрес соответствует разрешённой форме /documents/{id}.html. В учебном приложении это одновременно определяет маршрут будущей страницы. Такая проверка не предназначена для миграции старых URL ProfessorWeb: адреса рабочего сайта имеют другой договор и должны сохраняться по существующему реестру. Здесь мы контролируем только собственные демонстрационные документы.

Значение visibility также проверяется при чтении. Неизвестное значение вызывает ошибку, а не превращается в публичный доступ по умолчанию. Если автор написал publci, молчаливое исправление лишило бы его возможности заметить опечатку. На границе данных полезно остановиться и потребовать ясного решения.

Деление абзаца без потери слов

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

Этот бюджет измеряет символы Python, а не токены языковой модели и не байты UTF-8. Кириллица занимает разное число байтов, а токенизация зависит от модели. Поэтому нельзя заключить, что шестьсот символов обязательно равны определённому количеству токенов. Позднее будем ограничивать модельный запрос отдельным договором и обрабатывать отказ при превышении контекстного окна.

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

Обратите внимание на нумерацию part: она назначается после формирования всех фрагментов. Первый фрагмент получает ноль. В будущем интерфейс создаст соответствующий якорь part-00. Число определяет положение в конкретной ревизии, а не вечную личность абзаца: вставка нового раздела может сдвинуть дальнейшие номера.

Ревизия и повторный импорт

Функция revision() строит отпечаток из документа, версии разбиения и пространства эмбеддингов. Самого текста недостаточно. Изменение алгоритма разбиения меняет набор фрагментов; смена модели меняет их векторные представления. Обе операции должны создавать новое состояние индекса, даже если читатель видит прежние слова на странице.

sort_keys=True делает порядок ключей JSON предсказуемым для одного набора значений. Отпечаток помогает определить повторное состояние, но не служит криптографической подписью автора. Злоумышленник, способный заменить файл и пересчитать хеш, по-прежнему может изменить корпус. Для доверия к источникам нужен независимый механизм управления доступом и происхождением файлов.

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

Когда разбиение нужно изменить

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

Не добавляем такое поведение молча к текущему примеру. Сначала нужно расширить поддерживаемый формат, создать собственные случаи с листингами и обновить версию SPLITTER. Тогда старый индекс не будет выглядеть совместимым с новым только из-за совпадения названий файлов.

Нормализация пробелов тоже влияет на договор цитирования. Наш splitter соединяет слова одним пробелом, поэтому фрагмент сохраняет содержание обычного абзаца, но не его исходное форматирование. Для таблицы, стихотворной строки или блока кода такой переход может потерять существенную структуру. Именно поэтому вход лаборатории ограничен заголовками и обычными абзацами. Если расширите поддерживаемый Markdown, сначала определите, какие конструкции нельзя дробить и как хранить исходные позиции. Не называйте нормализованный фрагмент побайтовой копией файла. Для точного сравнения сохраните исходную ревизию отдельно; для отображения цитаты покажите, какое представление используется и к какому документу оно относится.

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

Исходники и документация

Оглавление серии · Все состояния проекта. Для этого урока откройте папку lesson-02.

pgvector, транзакции Psycopg, Ollama Embed, структурированные ответы.

Примеры подготовлены как исходники. Их выполнение, замеры, тесты и публикация при написании не проводились. Описанные результаты ожидаемые.