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

Извлечение контекста для вопроса

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

В самостоятельном состоянии lesson-06 функция ask читает публичные записи SQLite и возвращает четыре лучших кандидата. Она принимает строку вопроса и ограничивает её длину пятьюстами символами. Ограничение не имеет отношения к истинности вопроса. Оно лишь задаёт понятный бюджет входа для этой лаборатории и не позволяет случайно передать целую статью в качестве короткого поискового запроса. Большой документ следует добавлять в корпус через загрузчик, а не маскировать под вопрос.

Извлечение как отдельный контракт

Кандидат содержит больше, чем текст. Нам нужны id, doc_id, title, section, url, visibility, doc_hash, score и coverage. Часть полей описывает происхождение, часть — результат текущего поиска. Не следует сохранять score как постоянное свойство документа: при другом вопросе оно изменится. Идентификатор части тоже должен трактоваться в контексте версии индекса. Такой объект позволяет позже показать источник, разобрать решение и выяснить, на каком этапе возникла ошибка.

Рассмотрим вопрос order предыдущего урока. В разделе о навигации есть explanation, что order сортирует каталог, а последовательность задаётся явно. Кандидат должен не просто содержать слово «урок», а попасть среди верхних результатов благодаря сочетанию нужных термов. Если выдача содержит подходящий документ на четвёртом месте, поиск формально нашёл его, но бюджет контекста позже может оставить лишь первые два. Поэтому оценка retrieval и оценка итогового ответа требуют разных наблюдений. Наличие источника среди всех записей базы ничего не говорит о его фактическом использовании.

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

Видимость до ранжирования

Сначала read_rows("reader") оставляет публичные фрагменты, затем rank считает частоты и оценки. Если сначала ранжировать весь корпус, служебный документ может вытеснить публичный из первых четырёх кандидатов. Последующее удаление служебного результата даст неполную выдачу, хотя публичное основание существовало. Ещё хуже, если закрытый текст уже передали модели и лишь убрали его ссылку из интерфейса. Поэтому граница доступа должна предшествовать поиску и передаче контекста, а не быть косметическим фильтром готового ответа.

Даже статистика BM25 меняется при составе набора: редкость слова вычисляется по разрешённым фрагментам. В этой реализации публичное ранжирование не получает частоты из служебной заметки. Это позволяет яснее объяснить результат и сокращает побочные зависимости между разными группами доступа. При промышленном полнотекстовом движке нужно отдельно разобраться, когда применяются ACL-фильтры и по какому корпусу рассчитываются статистики. Наличие параметра filter в API ещё не означает, что внутренний механизм устроен точно так же, как наша небольшая лаборатория.

Аргумент role уже есть в функции, но до урока о доступе он не используется для расширения набора: источник всегда читается как reader. Это удерживает публичное поведение на простом уровне. Не стоит менять вызов на read_rows(role), одновременно разрешив посетителю передавать ?role=editor. Роль должна поступать из доверенного серверного контекста. В CLI можно дать её оператору, но это не превращает строковый аргумент в проверенную учётную запись. Позже мы явно разделим локальную команду редактора и публичную веб-страницу.

Похожесть и покрытие

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

Покрытие использует уникальные нормализованные термы. Служебные слова удалены, а окончания не приводятся к общей форме. Следовательно, низкое значение иногда объясняется русской морфологией, а не отсутствием знания. Мы не прячем это под словом confidence. Число coverage — прозрачная доля совпадений конкретного алгоритма. Перед введением порога нужно посмотреть вопросы, где нужный фрагмент известен человеку, и понять, какие термы потерялись. Иначе строгий отказ просто замаскирует слабую нормализацию текста.

В данном состоянии ask возвращает сами кандидаты, включая их служебные поля. Это удобное представление для локального анализа, но публичный интерфейс позднее покажет лишь нужные человеку сведения. Score с шестью знаками после запятой выглядит точно, однако не помогает посетителю проверить объяснение. Куда полезнее назвать источник и привести цитату. Числовую диагностику стоит оставить редактору, чтобы разбирать неудачные вопросы. Разные представления одного результата позволяют не путать обучающий инструмент отладки и пользовательский продукт.

Наблюдение результата

Полный app.py ниже показывает порядок: загрузить документы, синхронизировать базу, принять вопрос, получить публичные части и ранжировать. При самостоятельном запуске с вопросом о sitemap ожидается список подходящих фрагментов. Вопрос тариф стоимость рублей должен дать пустой список, потому что ни один разрешённый источник не содержит таких термов. Вопрос о вымышленной дате из editor-notes также не должен раскрыть служебную заметку. Примеры не выполнялись при подготовке, поэтому эти результаты остаются ожидаемыми по разбору кода.

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

Полный файл app.py из состояния lesson-06:

import json
import sys
from pathlib import Path
from loader import load_documents
from chunks import flatten
from ranking import rank
from storage import sync, read_rows

def ask(question, role="reader"):
    if len(question) > 500:
        raise ValueError("Question is too long")
    rows = read_rows("reader")
    candidates = rank(question, rows)
    return candidates

def main():
    changes = sync(load_documents(), incremental=False)
    question = sys.argv[1] if len(sys.argv) > 1 else "сохранить старых URL"
    role = sys.argv[2] if len(sys.argv) > 2 else "reader"
    print(json.dumps(ask(question, role), ensure_ascii=False, indent=2))

if __name__ == "__main__":
    main()

Оглавление серии · Исходники всех шагов · Учебные маршруты

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