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

Фасеты, фильтры, синонимы в Typesense

Тонкая настройка релевантности Typesense

Содержание:

Научим Typesense возвращать фасеты (списки тегов с количеством документов), добавим фильтры в интерфейс поиска, настроим синонимы (в т.ч. для русских форм слов), сделаем "ручные" выдачи через overrides и подкрутим релевантность с помощью query_by_weights, text_match_type и сортировки.

Фасеты и фильтры: показываем "фильтры слева"

Мы уже пометили поля tags, published_at, popularity как facet: true в схеме коллекции pages. Теперь осталось:

  1. попросить у Typesense фасеты параметром facet_by;
  2. разобрать facet_counts из ответа;
  3. отрисовать в шаблоне список тегов с количеством.

Typesense умеет считать фасеты на лету: достаточно указать facet_by=tags,published_at и он вернёт структуру facet_counts.

Дополняем маршрут /search

Добавим запрос фасетов по тегам и годам публикации:

# app.py (фрагмент: обновлённый /search)
from flask import Flask, request, render_template
from ts_client import client as ts_client

app = Flask(__name__)
COLLECTION_NAME = "pages"


@app.route("/search")
def search():
    q = (request.args.get("q") or "").strip()
    page_arg = request.args.get("page", "1")
    tag = (request.args.get("tag") or "").strip()
    sort = (request.args.get("sort") or "").strip()

    try:
        page = int(page_arg)
        if page < 1:
            page = 1
    except ValueError:
        page = 1

    per_page = 10

    search_params = {
        "q": q or "*",
        "query_by": "title,content",
        "page": page,
        "per_page": per_page,
        "highlight_full_fields": "title,content",
        "highlight_start_tag": "<mark>",
        "highlight_end_tag": "</mark>",
        # ВАЖНО: просим фасеты по тегам и году публикации
        # published_year мы сейчас сделаем на лету через вычисляемое поле (см. ниже),
        # либо заранее заведём отдельное поле в коллекции. Пока покажу только tags.
        "facet_by": "tags",
        # max 50 значений на фасет, чтобы не раздувать ответ
        "max_facet_values": 50,
    }

    filter_clauses = []
    if tag:
        filter_clauses.append(f"tags:=[{tag}]")

    if filter_clauses:
        search_params["filter_by"] = " && ".join(filter_clauses)

    if sort == "new":
        search_params["sort_by"] = "published_at:desc"
    elif sort == "popular":
        search_params["sort_by"] = "popularity:desc"

    try:
        result = ts_client.collections[COLLECTION_NAME].documents.search(search_params)
    except Exception as e:
        return render_template(
            "search.html",
            q=q,
            tag=tag,
            sort=sort,
            error=str(e),
            hits=[],
            found=0,
            page=page,
            pages_total=0,
            facets={},
        )

    hits_raw = result.get("hits", [])
    found = result.get("found", 0)
    facet_counts = result.get("facet_counts", []) # список фасетов

    # Разбираем hits (как в Части 3, опускаю повторяющиеся комментарии)
    hits = []
    for hit in hits_raw:
        doc = hit.get("document", {})
        highlights = hit.get("highlights", [])

        title = doc.get("title", "")
        content = doc.get("content", "")

        title_hl = title
        content_hl = content

        for h in highlights:
            field = h.get("field")
            snippet = h.get("snippet")
            if field == "title" and snippet:
                title_hl = snippet
            elif field == "content" and snippet:
                content_hl = snippet

        hits.append({
            "id": doc.get("id"),
            "slug": doc.get("slug"),
            "title": title,
            "title_hl": title_hl,
            "content": content,
            "content_hl": content_hl,
            "tags": doc.get("tags", []),
            "published_at": doc.get("published_at"),
            "popularity": doc.get("popularity"),
        })

    pages_total = (found + per_page - 1) // per_page if found else 0

    # Преобразуем facet_counts в удобный словарь:
    # {
    #   "tags": [
    #       {"value": "python", "count": 42},
    #       {"value": "flask", "count": 17},
    #   ]
    # }
    facets = {}
    for facet in facet_counts:
        field_name = facet.get("field_name")
        counts = facet.get("counts", [])
        facets[field_name] = [
            {"value": c.get("value"), "count": c.get("count")}
            for c in counts
        ]

    return render_template(
        "search.html",
        q=q,
        tag=tag,
        sort=sort,
        hits=hits,
        found=found,
        page=page,
        pages_total=pages_total,
        per_page=per_page,
        facets=facets,
    )

Typesense в поле facet_counts возвращает список фасетов, каждый содержит field_name и список counts с value и count.

Дорисовываем фильтры по тегам в шаблон

В search.html добавим колонку слева с фасетами. Предположу, что у нас уже есть row с результатами, обернём всё в row и col-*.

{# внутри <body> в templates/search.html #}
<div class="container mt-4">
    <h1 class="mb-4">Поиск по сайту</h1>

    {# форма поиска — как раньше #}
    <!-- ... форма ... -->

    <div class="row">
        <div class="col-md-3">
            <h5>Фильтры</h5>

            {% if facets.tags %}
                <div class="mb-3">
                    <h6>Теги</h6>
                    <ul class="list-unstyled">
                        {% for f in facets.tags %}
                            <li>
                                <a href="{{ url_for('search', q=q, sort=sort, tag=f.value) }}">
                                    {{ f.value }}
                                </a>
                                <span class="text-muted">({{ f.count }})</span>
                                {% if tag == f.value %}
                                    <span class="badge badge-primary">выбран</span>
                                {% endif %}
                            </li>
                        {% endfor %}
                    </ul>
                </div>
            {% endif %}
        </div>

        <div class="col-md-9">
            {# сюда переносим блок "Найдено документов" и список результатов #}
            <!-- ... блок found / hits / пагинация ... -->
        </div>
    </div>
</div>

Теперь пользователь видит список тегов с количеством документов и может кликнуть по тегу, чтобы включить фильтр.

На маленьком сервере не злоупотребляй количеством фасетов и полей в facet_by, чтобы не гонять лишние данные. max_facet_values держи небольшим (20–50).

Синонимы: "поиск" ≈ "искать" ≈ "поисковый"

Typesense умеет синонимы на уровне поискового движка - он трактует набор слов как эквивалентные при поиске.

Для русского это удобно для:

  • разных форм слова: "поиск", "поиска", "поисковый";
  • разговорных/альтернативных: "фреймворк", "framework";
  • транслита/англ. названий.

Поддерживаются:

  • one-way synonyms (односторонние);
  • multi-way synonyms (все эквивалентны).

Создаём набор синонимов для русского поиска

Скрипт для настройки синонимов:

# setup_synonyms.py
import typesense
from ts_client import client as ts_client

COLLECTION_NAME = "pages"

def create_synonyms():
    """
    Создаём несколько наборов синонимов.
    Каждый набор имеет свой ID (строка).
    """
    synonyms = [
        {
            "id": "search_ru",
            "synonyms": ["поиск", "поиска", "поисковый", "искать", "поиски"],
        },
        {
            "id": "flask_framework",
            "synonyms": ["flask", "фласк", "flask.py"],
        },
        {
            "id": "python_lang",
            "synonyms": ["python", "питон"],
        },
    ]

    for s in synonyms:
        synonym_id = s["id"]
        body = {
            # multi-way synonyms: все термины взаимозаменяемы
            "synonyms": s["synonyms"],
        }
        # upsert-поведение: если синоним с таким id есть — обновим
        try:
            ts_client.collections[COLLECTION_NAME].synonyms[synonym_id].delete()
        except Exception:
            pass

        created = ts_client.collections[COLLECTION_NAME].synonyms.create(body, {
            "id": synonym_id
        })
        print(f"Создан набор синонимов: {synonym_id} -> {created}")

if __name__ == "__main__":
    create_synonyms()

Что происходит:

  • создаём 3 набора синонимов с ID search_ru, flask_framework, python_lang;
  • каждый набор - список слов, которые Typesense считает эквивалентными;
  • при поиске по любому из этих слов движок будет находить документы с любыми из остальных.

Плюс синонимов: не нужно переиндексировать документы - это поверх индекса.

Overrides: вручную "подкручиваем" выдачу

Overrides позволяют задать правила вида: "если запрос такой-то, то закрепи/спрячь конкретные документы". Они применяются перед обычным ранжированием.

Типичный сценарий:

  • Если пользователь ищет "документация", первым должен быть раздел "Документация сайта" вне зависимости от популярности.
  • Промо-страницы или важные статьи.

Пример override: закрепляем главную документацию

Допустим, страница документации в индексe имеет id = 1.

# setup_overrides.py
from ts_client import client as ts_client

COLLECTION_NAME = "pages"

def create_overrides():
    """
    Создаём override, который будет поднимать нужную страницу в выдаче
    при определённом запросе.
    """
    overrides = [
        {
            "id": "docs_exact",
            "rule": {
                # когда запрос точно равен "документация"
                "query": "документация",
                "match": "exact", # или "contains"
            },
            "includes": [
                # закрепляем документ с id=1 на позиции 1
                {"id": "1", "position": 1},
            ],
            "excludes": [
                # можно спрятать какие-то результаты, если надо
                # {"id": "999"},
            ],
        },
    ]

    for o in overrides:
        override_id = o["id"]

        body = {
            "rule": o["rule"],
            "includes": o["includes"],
            "excludes": o["excludes"],
        }

        try:
            ts_client.collections[COLLECTION_NAME].overrides[override_id].delete()
        except Exception:
            pass

        created = ts_client.collections[COLLECTION_NAME].overrides.create(body, {
            "id": override_id
        })
        print(f"Создан override: {override_id} -> {created}")


if __name__ == "__main__":
    create_overrides()

Ключевые моменты:

  • rule.query и rule.match определяют, когда срабатывает правило;
  • includes (pinned hits) закрепляют документы на конкретных позициях;
  • excludes (hidden hits) прячут документы всегда, когда правило сработало.

Если нужно временно отключить overrides для какого-то поиска, можно передать enable_overrides=false в параметры поиска.

Тонкая настройка релевантности

Typesense уже делает разумное ранжирование по _text_match, опечаткам, близости слов и т.д.Но мы можем вполне сильно на это влиять.

Вес полей через query_by_weights

Например, мы хотим, чтобы совпадение в title было важнее, чем в content. Тогда задаём веса:

search_params = {
    "q": q or "*",
    "query_by": "title,content",
    # title вес 3, content вес 1
    "query_by_weights": "3,1",
    # остальное как раньше...
}

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

Режимы text match: text_match_type

Typesense умеет менять стратегию агрегации текстовых совпадений: max_score (по умолчанию) и sum_score.

  • max_score: учитывается максимум по полям - полезно, если главное поле, например, title;
  • sum_score: суммирует вклады всех полей - хорошо, когда важен "общий контекст" по многим полям.

Пример:

search_params = {
    "q": q or "*",
    "query_by": "title,content",
    "query_by_weights": "3,1",
    "text_match_type": "max_score", # или "sum_score"
}

Комбинированная сортировка: _text_match + свежесть/популярность

Можно явно переопределять sort_by, включив _text_match. Например:

if sort == "best_recent":
    search_params["sort_by"] = "_text_match:desc,published_at:desc"
elif sort == "best_popular":
    search_params["sort_by"] = "_text_match:desc,popularity:desc"

Тогда:

  • сначала документы идут по релевантности;
  • при равенстве _text_match более новые/популярные будут выше.

Настройка опечаток и префиксов (кратко)

Пара важных ручек (можешь добавить по мере надобности):

  • num_typos - сколько опечаток допускать (0–2);
  • min_len_1typo, min_len_2typo - минимальная длина слова, чтобы разрешить 1/2 опечатки;
  • prefix - true / false / false-аналог; управление поиском по префиксу (для подсказок).

Например, для более строгого поиска:

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

Это поможет не "растягивать" слишком короткие русские слова (типа "в", "на", "по").

Куда двигаться дальше (семантический/векторный поиск)

Typesense умеет векторный (semantic) поиск: можно сохранить embeddings текста и искать ближайшие по смыслу документы.

Это уже следующий уровень:

  1. Обучаешь/берёшь готовую модель для эмбеддингов (например, русскоязычную).
  2. Генерируешь вектора для страниц, добавляешь в коллекцию поле embedding типа float[].
  3. Используешь search или vector-search API с передачей вектора запроса.

На сайт с 100 000 страниц можно постепенно добавить гибрид: q + векторный поиск как дополнительный сигнал ранжирования.