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

Поиск по разрешённым Markdown-документам

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

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

От строки к словам

Функция tokens нормализует текст через NFKC, приводит регистр с помощью casefold и выделяет последовательности символов слова. Затем список превращается в множество. Это означает, что повторение одного слова в запросе не увеличивает его вес. Для наших коротких документов такая простая форма удобна: спрашивая «адрес адрес», читатель не получает искусственно усиленное совпадение только из-за повтора.

Нормализация помогает свести часть визуально близких представлений к общей форме, но не делает слова равными по смыслу. Русские формы «адрес» и «адреса» могут остаться разными токенами. casefold также не является морфологическим анализом или переводом. Поэтому поисковая фраза должна совпадать с формой в корпусе хотя бы частично. Это существенная граница лексической модели, которую следует объяснить пользователю вместо обещания ответа на любую естественную формулировку.

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

Сначала допустимые источники

Функция documents выбирает разрешённые записи до чтения и ранжирования. В ранних состояниях роль reader фиксирована, поэтому служебная заметка не входит в кандидаты. Это важнее, чем удалить её из готового топа. Если private сначала участвует в оценке, ограничения выдачи и подсчёт результатов могут зависеть от его содержимого. В нашей реализации предметный поиск работает только с набором, который уже допустим для текущей роли.

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

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

Порядок и ограничение

Кандидаты сортируются по убыванию оценки, а при равенстве — по id. Второй ключ создаёт устойчивый порядок. Если читать файлы в другой последовательности, равные результаты не должны случайно меняться местами. Это удобно и для интерфейса, и для сопоставления двух версий поиска. Но устойчивость не означает, что выбранный tie-breaker является лучшим редакционным порядком: мы лишь явно задаём поведение при одинаковой простой оценке.

Лимит применяется после сортировки. Сервер возвращает не больше указанного числа результатов, каждый с id, названием, URL, кратким фрагментом и score. Фрагмент берётся из начала документа, а не выделяет участок совпадения. Поэтому он может не показывать нужное слово, хотя документ участвовал в оценке. Это честная граница нашего snippet. Если нужен фрагмент вокруг совпадения, его следует добавить отдельной операцией и объяснить обработку границ текста.

Ожидаемый запрос «постоянный адрес» должен связать пользователя с записью urls, поскольку в авторском тексте присутствуют такие слова. Результат не подтверждён исполнением программы. Мы объясняем его по известным данным и функции. Если читатель изменит текст или манифест, ожидание нужно обновить. Не переносите примерный порядок в документацию как вечную характеристику любой Markdown-библиотеки с тем же названием инструмента.

Представьте запрос «цена хостинга». В корпусе нет соответствующего описания стоимости. Пустой результат является полезной информацией: сервер не нашёл основание в своих источниках. Модель, использующая инструмент, может попросить другой источник или уточнение, но не должна приписывать библиотеке выдуманные тарифы. MCP обеспечивает способ вызова; он не исправляет отсутствие материалов и не делает внешние сведения частью разрешённого корпуса автоматически.

Равная оценка двух источников

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

Сравним запрос из двух слов и запрос с теми же словами повторёнными несколько раз. Поскольку tokens возвращает множество, score не вырастет от повторов. Такое поведение полезно для маленького механизма, но отличается от моделей, учитывающих частоту. При замене ранжирования одинаковый интерфейс library.search может сохранить форму ответа, а значения score и порядок изменятся. Поэтому клиент не должен интерпретировать score как универсальный процент достоверности.

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

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

Фрагмент файла library.py из состояния lesson-04. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.

def tokens(text):
    return set(re.findall(r"\w+", unicodedata.normalize("NFKC", text).casefold()))

def search(query, limit):
    terms = tokens(query)
    if not terms:
        return {"items": []}
    scored = []
    for row in documents():
        score = len(terms & tokens(row["title"] + " " + row["text"]))
        if score:
            scored.append((score, row))
    scored.sort(key=lambda item: (-item[0], item[1]["id"]))
    return {"items": [{"id": r["id"], "title": r["title"], "url": r["url"], "snippet": r["text"][:300], "score": s} for s,r in scored[:limit]]}

Статус примера: not_run. При подготовке не выполнялись учебные программы, тесты, вызовы AI или деплой. Описанные эффекты ожидаемые.

Оглавление серии · Полные исходники

Первичные документы: Источник 1, Источник 2.