Сообщить об ошибке.

Автодополнение, живой поиск и JSON-API c Typesense

Содержание:

Сделаем быстрый JSON-API для поиска, подключим "живой" поиск (search-as-you-type) и подсказки заголовков. Покажу, как настроить префиксный поиск и ограничения, чтобы не положить Typesense на слабом сервере.

Зачем отдельный JSON-API для поиска

Сейчас у нас есть HTML-маршрут /search, который:

  • принимает q, tag, sort;
  • ходит в Typesense;
  • рендерит Bootstrap-страницу.

Для подсказок и "живого" поиска удобнее иметь отдельный лёгкий JSON-endpoint, который:

  • возвращает только JSON (без HTML);
  • отдаёт мало полей и мало документов (например, только id, slug, title);
  • оптимизирован под краткие запросы (2–3 символа и больше);
  • не делает подсветку, фасеты и т.п. - это всё лишняя работа.

С таким API мы сможем:

  • делать выпадающие подсказки под полем поиска;
  • реализовать "живой поиск" на странице списка, без перезагрузки.

Минимальный JSON-endpoint /api/search

Сделаем простой маршрут /api/search, который:

  • принимает q, limit;
  • ходит в Typesense;
  • возвращает JSON-массив результатов.
# app_api.py (или внутри app.py)
from flask import Flask, request, jsonify
from ts_client_search import client as ts_client # search-only ключ, если вынесено
# если не выносили — можешь использовать ts_client из предыдущих частей

app = Flask(__name__)
COLLECTION_NAME = "pages"


@app.route("/api/search")
def api_search():
    """
    Лёгкий JSON-API для поиска.
    Предназначен для AJAX / fetch-запросов на фронтенде.
    """
    q = (request.args.get("q") or "").strip()
    if not q:
        # Пустой запрос — ничего не ищем, возвращаем пустой список
        return jsonify({"hits": [], "found": 0})

    try:
        limit = int(request.args.get("limit", "5"))
    except ValueError:
        limit = 5

    # Ограничиваем лимит сверху, чтобы не положить Typesense
    limit = max(1, min(limit, 20))

    search_params = {
        "q": q,
        "query_by": "title,content",
        "per_page": limit,
        "page": 1,
        # для подсказок нам не нужны подсветки и фасеты
        "include_fields": "id,slug,title", # Typesense вернёт только эти поля
        # усилим роль заголовка
        "query_by_weights": "3,1",
        # хотим искать по префиксу (для "живого" поиска)
        "prefix": "true", # поведение зависит от версии Typesense, но идея такая
    }

    try:
        result = ts_client.collections[COLLECTION_NAME].documents.search(search_params)
    except Exception as e:
        # В проде лучше отдавать обобщённую ошибку и логировать детали
        return jsonify({"error": str(e)}), 500

    hits = []
    for hit in result.get("hits", []):
        doc = hit.get("document", {})
        hits.append({
            "id": doc.get("id"),
            "slug": doc.get("slug"),
            "title": doc.get("title"),
        })

    return jsonify({
        "hits": hits,
        "found": result.get("found", 0),
    })

Что важно:

  • include_fields - Typesense не будет таскать лишние поля;
  • per_page небольшой (5–10);
  • prefix="true" - позволяет работать как автодополнение;
  • без подсветки/фасетов - меньше нагрузки на CPU.

Маршрут подсказок заголовков /api/suggest

Иногда нужно именно подсказки по заголовкам, а не общий поиск.Сделаем специализированный endpoint, который ищет только по title.

@app.route("/api/suggest")
def api_suggest():
    """
    Подсказки по заголовкам страниц.
    Например, для выпадающего списка под полем поиска.
    """
    q = (request.args.get("q") or "").strip()
    if not q or len(q) < 2:
        # Не начинаем подсказывать с одной буквы — слишком жирно
        return jsonify({"suggestions": []})

    try:
        limit = int(request.args.get("limit", "8"))
    except ValueError:
        limit = 8

    limit = max(1, min(limit, 20))

    search_params = {
        "q": q,
        "query_by": "title",
        "per_page": limit,
        "page": 1,
        "include_fields": "id,slug,title",
        "query_by_weights": "1", # одно поле, просто для явности
        "prefix": "true", # префиксный поиск по заголовкам
        # Опционально чуть ослабляем опечатки для коротких запросов
        "num_typos": 1,
        "min_len_1typo": 4,
        "min_len_2typo": 8,
    }

    try:
        result = ts_client.collections[COLLECTION_NAME].documents.search(search_params)
    except Exception as e:
        return jsonify({"error": str(e)}), 500

    suggestions = []
    for hit in result.get("hits", []):
        doc = hit.get("document", {})
        suggestions.append({
            "id": doc.get("id"),
            "slug": doc.get("slug"),
            "title": doc.get("title"),
        })

    return jsonify({"suggestions": suggestions})

Логика:

  • не подсказываем, пока длина строки < 2;
  • ищем только по title => быстрый и предсказуемый результат;
  • можно вернуть только title и slug, этого достаточно для ссылок.

Фронтенд: "живой" поиск с Bootstrap и fetch

Сделаем простой пример:

  • поле поиска в шапке;
  • под ним - выпадающий список подсказок;
  • по клику - переход на страницу.

Разметка

Добавим в шаблон (например, в base.html или на конкретной странице):

<form class="form-inline my-2 my-lg-0 position-relative" onsubmit="return onSearchSubmit(event)">
    <input
        id="live-search-input"
        class="form-control mr-sm-2"
        type="search"
        placeholder="Поиск по сайту"
        aria-label="Поиск"
        autocomplete="off"
        oninput="onSearchInput(this.value)"
    >
    <div
        id="live-search-results"
        class="list-group position-absolute"
        style="top: 100%; left: 0; right: 0; z-index: 1000;"
    ></div>
</form>
  • position-relative на форме и position-absolute на блоке подсказок - делаем выпадающий список под полем.

JS-логика с дебаунсом

<script>
    let searchTimer = null;

    function onSearchInput(value) {
        const container = document.getElementById('live-search-results');

        // Чистим, если пусто
        if (!value || value.trim().length < 2) {
            container.innerHTML = '';
            return;
        }

        // Дебаунс: ждём 300 мс после последнего ввода
        if (searchTimer) {
            clearTimeout(searchTimer);
        }

        searchTimer = setTimeout(function () {
            fetch(`/api/suggest?q=${encodeURIComponent(value.trim())}&limit=8`)
                .then(response => response.json())
                .then(data => {
                    renderSuggestions(data.suggestions || []);
                })
                .catch(err => {
                    console.error('Search error', err);
                });
        }, 300);
    }

    function renderSuggestions(suggestions) {
        const container = document.getElementById('live-search-results');
        container.innerHTML = '';

        if (!suggestions.length) {
            return;
        }

        suggestions.forEach(item => {
            const a = document.createElement('a');
            a.className = 'list-group-item list-group-item-action';
            a.textContent = item.title || '(без заголовка)';
            a.href = `/pages/${item.slug}`;  // или url_for-путь, если хочешь динамически подставлять
            container.appendChild(a);
        });
    }

    function onSearchSubmit(event) {
        event.preventDefault();
        const input = document.getElementById('live-search-input');
        const value = input.value.trim();
        if (!value) {
            return false;
        }
        // Просто редиректим на обычную страницу поиска
        window.location.href = `/search?q=${encodeURIComponent(value)}`;
        return false;
    }

    // Закрывать подсказки при клике вне
    document.addEventListener('click', function (e) {
        const container = document.getElementById('live-search-results');
        const input = document.getElementById('live-search-input');
        if (!container.contains(e.target) && e.target !== input) {
            container.innerHTML = '';
        }
    });
</script>

Что делаем:

  • debounce 300 мс - сильно уменьшает количество запросов на сервер;
  • не отправляем запросы при длине строки < 2;
  • рендерим подсказки через list-group Bootstrap.

"Живой" поиск по результатам на странице

Иногда хочется не только подсказки, но и список результатов прямо под строкой (без перехода на /search):

  • пользователь вводит запрос => ниже появляется сокращённая выдача (например, 5 результатов);
  • при нажатии Enter => обычная страница /search для "полной" выдачи.

Для этого можно:

  • использовать тот же /api/search;
  • вместо renderSuggestions рисовать карточки с коротким описанием.

Пример адаптации renderSuggestions:

<div id="live-search-list"></div>

<script>
    function renderLiveResults(hits) {
        const container = document.getElementById('live-search-list');
        container.innerHTML = '';

        if (!hits.length) {
            return;
        }

        hits.forEach(item => {
            const div = document.createElement('div');
            div.className = 'mb-2';

            const a = document.createElement('a');
            a.href = `/pages/${item.slug}`;
            a.textContent = item.title || '(без заголовка)';
            a.className = 'font-weight-bold d-block';

            const p = document.createElement('p');
            p.textContent = item.content || '';
            p.className = 'mb-0 text-muted small';

            div.appendChild(a);
            div.appendChild(p);

            container.appendChild(div);
        });
    }

    // тогда в onSearchInput вместо /api/suggest дергаем /api/search
    // и передаём renderLiveResults(data.hits)
</script>

На слабом сервере важно:

  • не делать оба варианта (подсказки + "мини-выдача") одновременно для одной строки ввода;
  • следить за лимитами (limit 5–10).

Настройки Typesense для подсказок и "живого" поиска

Для облегчения нагрузки:

  1. Ограничить поля через include_fields.

  2. Снизить per_page до 5–10.

  3. Подкрутить опечатки:

    search_params.update({
        "num_typos": 1,
        "min_len_1typo": 4,
        "min_len_2typo": 8,
    })
    

    Так мы не будем слишком активно исправлять короткие слова (типично для русского).

  4. Проверить, что prefix включён только там, где нужен search-as-you-type (в обычном /search он не всегда желателен - может дать слишком много срабатываний).

Краткое резюме по части

  • Сделали два JSON-API: /api/search и /api/suggest.
  • Добавили "живой" поиск и выпадающие подсказки на Bootstrap
  • Ограничили поля и размеры выдачи, чтобы подсказки не душили Typesense на слабом сервере.
  • Использовали дебаунс и минимальную длину запроса для снижения нагрузки.