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

Поиск по заголовку и основному тексту

После пакетного импорта документы существуют в library-v1. Теперь зададим способ искать их словами. Результат урока — серверная функция, которая строит известный Query DSL из небольшой строки пользователя и возвращает карточки с устойчивыми адресами. Начнём с запроса «HTTP кеширование», сохранив имена материалов исходного корпуса.

Полная функция build_query находится в query.py итогового проекта. В ней уже присутствуют фильтры, фасеты и подсветка следующих уроков; здесь разбираем полнотекстовую часть и состав ответа. Автор не выполнял поиск, поэтому число совпадений и порядок карточек не объявляются полученными результатами. Документация multi-match описывает выбранный тип запроса.

Пользователь передаёт намерение поиска

Перед первым поиском создайте стабильное имя чтения командой python manage.py aliases library-v1. Она связывает library-read с уже наполненным физическим индексом. Пока используем это имя как часть договора приложения; перенос между физическими версиями и атомарное переключение подробно рассмотрим в двенадцатом уроке. Без начальной связи команда search из архива не найдёт назначение запроса, даже если документы существуют в library-v1.

Внешний договор приложения принимает строку q, раздел, метку и параметры короткой страницы. Пользователь не передаёт весь Query DSL. Если разрешить произвольный объект запроса, публичный сервис начнёт поддерживать множество операций, для которых не определены пределы, стоимость и права. Гораздо легче объяснять и сопровождать несколько явно разрешённых возможностей.

В build_query проверяем длину строки после удаления крайних пробелов: от одного до ста двадцати символов. Пустая строка не превращается в неограниченный match_all. Для обзора библиотеки можно создать отдельный режим с собственными ограничениями, но поисковая строка не должна случайно менять назначение API в зависимости от отсутствия текста.

Ниже фрагмент объекта query.bool.must из полной функции. Переменная text содержит уже проверенную строку:

{'multi_match': {
    'query': text.strip(),
    'fields': ['title^3', 'body', 'body.technical'],
    'type': 'best_fields',
    'operator': 'and'
}}

Список fields задаёт поля, участвующие в совпадении. Запись title^3 увеличивает вклад заголовка, когда соответствующее совпадение найдено. Вес не означает «всегда поставить эту статью первой» и не заменяет проверку релевантности. Основной текст остаётся полезным, поскольку термин может быть объяснён в абзаце и отсутствовать в коротком названии.

Тип best_fields оценивает совпадения по отдельным полям с выбором наиболее подходящего. В сочетании с operator: and это следует понимать осторожно: условие всех разобранных терминов применяется внутри соответствующих полевых запросов. Запрос, где одно слово присутствует только в заголовке, а другое только в теле, не обязан вести себя как единое объединённое поле.

От совпадения к порядку

Предположим, article-107 имеет оба слова в заголовке и тексте, а article-108 описывает HTTP в теле статьи о Node.js. Первая запись является естественным кандидатом для запроса о кешировании, а вторая зависит от наличия второго терма. Если слово «кеширование» во втором документе отсутствует, повышение веса HTTP не должно искусственно удовлетворить требование всех терминов.

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

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

Сортируем сначала по _score в убывающем порядке, затем по doc_id. Второе поле делает порядок записей с одинаковой оценкой определённым в рамках одного запроса. Оно не замораживает всю выдачу между обновлениями индекса. Если содержимое меняется, меняется и множество кандидатов, и оценки. Для длинного просмотра понадобится отдельный договор пагинации.

Состав публичной карточки

В _source запрашиваем только нужные поля. Функция public_response затем формирует небольшой прикладной объект: идентификатор, заголовок, адрес, раздел, метки, редакцию и фрагменты текста. Внешний интерфейс не получает внутреннее имя физического индекса или контрольную сумму. Это уменьшает связь интерфейса с деталями хранения и облегчает будущую миграцию.

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

Схема ожидаемого ответа приложения выглядит так:

{
  "total": {"value": 1, "relation": "eq"},
  "hits": [{
    "doc_id": "article-107",
    "title": "HTTP и кеширование",
    "url": "/articles/http-cache.html",
    "section": "backend",
    "tags": ["http", "cache"],
    "revision": 1,
    "snippets": ["Браузер проверяет ETag перед загрузкой."]
  }],
  "facets": {}
}

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

Успешный HTTP ещё не означает полный поиск

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

В public_response проверяем оба признака до формирования карточек. Поле took не используем как измерение полной пользовательской задержки: оно относится к работе движка, а сетевой путь, обработка gateway и отображение в браузере происходят дополнительно. В этой серии нет выполненных замеров скорости, поэтому чисел эффективности не приводим.

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

Сохранение total вместе с relation помогает не потерять смысл количества. В проекте включён track_total_hits, но прикладной объект всё равно оставляет форму движка, а не превращает любой ответ в неподписанное число. При будущем ограничении подсчёта значение может означать нижнюю границу. Это следует отразить в интерфейсе, иначе оптимизация серверной работы незаметно станет неверным обещанием читателю.

Если ожидаемая статья отсутствует, последовательно проверьте её видимость, текущий физический индекс, токены анализатора и условие and. Такой разбор ведёт от доступности документа к совпадению, затем к порядку. Бессистемное увеличение boost не исправляет отсутствующий документ, неправильный alias или ошибку фильтра. В следующем уроке добавим точные фильтры, сохранив их отдельную роль.

Оглавление серии