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

Полнотекстовый поиск с Typesense

Архитектура, установка Typesense и базовая схема индекса

Содержание:

Typesense это современный, быстрый и открытый поисковый движок, предназначенный для добавления поиска в приложениях и веб-сайтах. Он разработан с акцентом на простоту использования, скорость работы, релевантность результатов и гибкость. Typesense часто рассматривается как альтернатива Elasticsearch и Algolia, но с более простой настройкой и более дружелюбным интерфейсом.

Он даёт:

  • полнотекстовый поиск по нескольким полям;
  • устойчивость к опечаткам;
  • сортировку, фильтрацию и фасеты;
  • REST API и официальный Python-клиент.

Важно: Typesense имеет локализации токенизации и для русского языка - достаточно указать locale: "ru" у нужных строковых полей в схеме коллекции.

Typesense распространяется под лицензией GNU General Public License v3.0. Это означает, что вы можете свободно использовать, модифицировать и распространять его. Скачать бинарник можно по ссылке https://typesense.org/downloads

Цель: сайт на Flask + Bootstrap + MySQL (~100 000 страниц). MySQL остаётся источником истины, Typesense - быстрый поисковый индекс.

Общая архитектура: Flask + MySQL + Typesense

Минимальная архитектура на одном сервере:

[Пользователь браузер]
        |
        v
  [Flask-приложение]
        |   \
        |    \ (поиск)
        |     \
        v      v
   [MySQL]  [Typesense]
   (данные) (поисковый индекс)
  • Flask ходит в MySQL за CRUD, админкой, страницами.
  • Отдельные обработчики Flask ходят в Typesense по API для поиска.
  • Индекс в Typesense периодически синхронизируется из MySQL (скриптом/воркером).

Оценка ресурсов для 100 000 страниц

Typesense хранит поисковый индекс в RAM, а сырой документ - на диске. Память ≈ 2–3× размер только тех полей, по которым вы ищете/фильтруете.

Допустим, в индекс идёт:

  • title: ~100 байт
  • content_snippet: ~500 байт (обрезанный текст/аннотация)
  • tags: ~50 байт
  • url: ~100 байт

Итого ~750 байт ≈ 0,75 KB на документ.На 100 000 документов: 100000 × 0,75 KB ≈ 75 MB исходных данных.

По формуле 2–3× нужно ~150–225 MB RAM под Typesense-индекс. Даже с запасом 512 MB под Typesense вы вписываетесь.

Практически:

  • Минимально комфортный сервер для Flask+MySQL+Typesense:

    • 2 vCPU;
    • 2–4 GB RAM;
    • SSD от 20 GB (в т.ч. для MySQL и Typesense-данных).

Установка Typesense на том же сервере (Docker)

Проще всего поднять Typesense в Docker-контейнере. Typesense - это единый бинарник, официально рекомендуют запускать через Docker или пакеты.

Пример docker-команды

sudo mkdir -p /var/lib/typesense-data

docker run -d --name typesense \
  -p 8108:8108 \
  -v /var/lib/typesense-data:/data \
  typesense/typesense:28.0 \
  --data-dir /data \
  --api-key=SUPER_SECRET_ADMIN_KEY \
  --enable-cors

Что здесь происходит:

  • -p 8108:8108 - публикуем порт API (по умолчанию 8108).
  • -v /var/lib/typesense-data:/data - постоянное хранилище индекса и данных.
  • --api-key=... - задаём админский API-ключ (обязательно меняем на свой).
  • --enable-cors - если потом планируете прямые запросы из браузера (в нашем случае поиск пойдёт через Flask, так что можно и не включать).

Проверка:

curl http://localhost:8108/health
# ожидаем: {"ok":true}

Подключение к Typesense из Python

Устанавливаем официальный Python-клиент (последняя версия 1.3.0, совместима с Typesense ≥ v26).

pip install typesense

Базовая инициализация клиента

# init_typesense_client.py
import typesense

TYPESENSE_ADMIN_API_KEY = "SUPER_SECRET_ADMIN_KEY"

client = typesense.Client({
    "nodes": [
        {
            "host": "127.0.0.1", # тот же сервер
            "port": "8108",
            "protocol": "http",
        }
    ],
    "api_key": TYPESENSE_ADMIN_API_KEY,
    "connection_timeout_seconds": 2,
})

def check_health():
    """
    Проверяем, что сервер Typesense жив.
    """
    health = client.health.retrieve() # запрос к /health
    print(health) # ожидаем {'ok': True}

if __name__ == "__main__":
    check_health()
  • Создаём экземпляр typesense.Client, указывая список нод (пока одна).
  • health.retrieve() ходит на /health и возвращает JSON со статусом.

Проектирование коллекции pages под сайт

У нас есть MySQL-таблица (упрощённо):

CREATE TABLE pages (
    id INT PRIMARY KEY AUTO_INCREMENT,
    slug VARCHAR(255) NOT NULL,
    title VARCHAR(255) NOT NULL,
    content MEDIUMTEXT NOT NULL,
    tags VARCHAR(255) NULL,
    published_at DATETIME NOT NULL,
    popularity INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

В Typesense мы создадим коллекцию pages примерно с такими полями:

  • id - числовой идентификатор (будет ключом документа);
  • title - заголовок (поиск, locale: "ru");
  • slug / url - строка, по которой будем отдавать страницу;
  • content - текст (поиск, locale: "ru");
  • tags - массив строк, используется как фасет (фильтры);
  • published_at - UNIX-timestamp (целое, сортировка/фильтрация);
  • popularity - число для сортировки по популярности.

Typesense поддерживает типы string, int32, int64, float, bool и массивы строк/чисел.

Создание коллекции pages с поддержкой русского языка

Основная фишка - указать locale: "ru" для текстовых полей, где нам важен русский. Typesense имеет кастомизации токенизатора для ru, включая морфологические особенности.

# create_pages_collection.py
import typesense

TYPESENSE_ADMIN_API_KEY = "SUPER_SECRET_ADMIN_KEY"

client = typesense.Client({
    "nodes": [
        {
            "host": "127.0.0.1",
            "port": "8108",
            "protocol": "http",
        }
    ],
    "api_key": TYPESENSE_ADMIN_API_KEY,
    "connection_timeout_seconds": 2,
})

schema = {
    "name": "pages",
    "fields": [
        # PK документа, по нему будем обновлять/удалять
        {"name": "id", "type": "int32"},

        # Заголовок страницы (русский язык, полнотекстовый)
        {"name": "title", "type": "string", "locale": "ru"},

        # URL / slug для генерации ссылки
        {"name": "slug", "type": "string"},

        # Краткий текст (сниппет), по которому тоже ищем
        {"name": "content", "type": "string", "locale": "ru"},

        # Теги — массив строк, используется как фасет (фильтры по темам)
        {"name": "tags", "type": "string[]", "facet": True},

        # Время публикации в виде unix-timestamp (секунды)
        {"name": "published_at", "type": "int64", "facet": True},

        # Популярность/рейтинг для сортировки (просмотры/клики)
        {"name": "popularity", "type": "int32", "facet": True},
    ],
    # Поле сортировки по умолчанию (если не указать sort_by при поиске)
    "default_sorting_field": "popularity",
}

def create_collection():
    # На всякий случай удалим старую коллекцию (в dev-среде)
    try:
        client.collections["pages"].delete()
        print("Старая коллекция 'pages' удалена.")
    except Exception:
        # Если нет коллекции — ничего страшного
        pass

    created = client.collections.create(schema)
    print("Создана коллекция:", created)

if __name__ == "__main__":
    create_collection()

Что тут важно:

  • locale: "ru" для title и content - включает русскоязычную токенизацию.
  • facet: True у tags, published_at, popularity - позже сможем строить фильтры и фасеты.
  • default_sorting_field - базовая сортировка, если клиент не задаёт свою.

Позже, в продвинутых частях, сюда можно будет добавить:

  • stem: true - базовое стеммирование (walk / walked-style для поддерживаемых языков);
  • infix: true - поиск по подстрокам;
  • свои словари стемминга под русский.

Минимальная интеграция с Flask: проверка подключения

Сделаем простой маршрут, который проверяет связь Flask => Typesense. Это поможет отловить сетевые/ключевые ошибки на раннем этапе.

# app.py (фрагмент)
from flask import Flask, jsonify
import typesense

app = Flask(__name__)

TYPESENSE_ADMIN_API_KEY = "SUPER_SECRET_ADMIN_KEY"

typesense_client = typesense.Client({
    "nodes": [
        {
            "host": "127.0.0.1", # для удалённого сервера позже будет Tailscale IP
            "port": "8108",
            "protocol": "http",
        }
    ],
    "api_key": TYPESENSE_ADMIN_API_KEY,
    "connection_timeout_seconds": 2,
})

@app.route("/typesense-health")
def typesense_health():
    """
    Простой health-check для Typesense.
    Удобно подвесить под мониторинг или использовать руками.
    """
    try:
        health = typesense_client.health.retrieve() # {'ok': True} при успехе
        return jsonify({"ok": bool(health.get("ok", False))}), 200
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500

if __name__ == "__main__":
    app.run(debug=True)
  • Если всё ок - получаем { "ok": true }.
  • Если ключ/хост/порт неверен - увидим ошибку в JSON и статус 500.