Научим Typesense возвращать фасеты (списки тегов с количеством документов), добавим фильтры в интерфейс поиска, настроим синонимы (в т.ч. для русских форм слов), сделаем "ручные" выдачи через overrides и подкрутим релевантность с помощью query_by_weights, text_match_type и сортировки.
Мы уже пометили поля tags, published_at, popularity как facet: true в схеме коллекции pages. Теперь осталось:
facet_by;facet_counts из ответа;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 умеет синонимы на уровне поискового движка - он трактует набор слов как эквивалентные при поиске.
Для русского это удобно для:
Поддерживаются:
Скрипт для настройки синонимов:
# 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()
Что происходит:
search_ru, flask_framework, python_lang;Плюс синонимов: не нужно переиндексировать документы - это поверх индекса.
Overrides позволяют задать правила вида: "если запрос такой-то, то закрепи/спрячь конкретные документы". Они применяются перед обычным ранжированием.
Типичный сценарий:
Допустим, страница документации в индекс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_typeTypesense умеет менять стратегию агрегации текстовых совпадений: 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 текста и искать ближайшие по смыслу документы.
Это уже следующий уровень:
embedding типа float[].На сайт с 100 000 страниц можно постепенно добавить гибрид: q + векторный поиск как дополнительный сигнал ранжирования.