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

Оптимизация Typesense на минимальном сервере

Содержание:

Как не убить слабый сервер: минимизируем размер индекса, аккуратно индексируем и ищем. Затем переносим Typesense на отдельный сервер, подключаемся по Tailscale-IP, настраиваем разные ключи (admin/search-only), делаем первичную заливку и инкрементальные обновления по сети.

Оптимизация "всё на одном сервере": Flask + MySQL + Typesense

Минимизируем размер индекса

Главная идея: в индекс только то, по чему ищем или фильтруем.

Уже сделано:

  • в Typesense мы храним сокращённый content (сниппет), а не полный текст;
  • поля с facet: true только те, что реально нужны (tags, published_at, popularity).

Дополнительно:

  1. Если есть "тяжёлые" поля (сырой HTML, большие JSON), вообще не добавляем их в Typesense - пусть лежат только в MySQL.
  2. Если очень много тегов - ограничь их:
    • при индексировании обрезай список до, скажем, 10 тегов на документ;
    • не хранить теги, по которым никто не фильтрует.
  3. Не делай слишком многоfacet-полей:
    • каждое facet: true поле удорожает индекс по памяти и поиску;
    • начни с 1–2 фасет (например, tags и published_year).

Щадящая индексация на маленьком железе

В скрипте initial_index_all() (Часть 2):

  • уменьши BATCH_SIZE до 200–500;
  • увеличь time.sleep до 0.2–0.5 секунд;
  • запускай ночью или в малоактивные часы.

Пример:

# initial_index.py (фрагмент)
BATCH_SIZE = 300 # было 1000
SLEEP_BETWEEN_BATCHES = 0.3

# ...

        index_batch(docs)
        last_id = rows[-1]["id"]
        total += len(rows)
        print(f"Проиндексировано страниц: {total}")

        time.sleep(SLEEP_BETWEEN_BATCHES)

Настройка Flask / WSGI под слабый сервер

Кратко (без углубления):

  • для gunicorn:
    • workers = числу CPU или чуть меньше (например, 2 для 2 vCPU);
    • timeout не слишком маленький, чтобы запросы поиска успевали.
  • в самом /search:
    • не делай лишних запросов в MySQL;
    • не считай лишние данные, которые не показываешь в шаблоне.

В идеале, поиск - это один HTTP-запрос к Typesense и один render_template.

Вынос Typesense на отдельный сервер

Теперь схема такая:

[Пользователь] -> [Flask + MySQL (сервер A)] -> (Tailscale VPN) -> [Typesense (сервер B)]
  • Сервер A: Flask-приложение + MySQL.
  • Сервер B: только Typesense.
  • Между ними уже настроен Tailscale => есть внутренний IP вида 100.x.x.x.

Плюсы:

  • Typesense съедает свою RAM/CPU отдельно;
  • Flask и MySQL больше не конкурируют за память с поиском;
  • можно взять более "жирный" сервер под поиск, если нужно.

Подключение к удалённому Typesense через Tailscale

Предположим:

  • сервер B (Typesense) имеет Tailscale-IP 100.64.0.10;
  • порт Typesense тот же: 8108.

Просто меняем конфиг клиента:

# ts_client_remote.py
import typesense

TYPESENSE_API_KEY = "SUPER_SECRET_ADMIN_KEY" # admin или search-only, см. ниже

client = typesense.Client({
    "nodes": [
        {
            "host": "100.64.0.10", # Tailscale IP сервера Typesense
            "port": "8108",
            "protocol": "http", # внутри VPN https обычно не обязателен
        }
    ],
    "api_key": TYPESENSE_API_KEY,
    "connection_timeout_seconds": 2,
})

Дальше:

  • в приложении Flask вместо старого ts_client импортируем ts_client_remote;
  • все наши вызовы client.collections[...] продолжают работать как раньше, только теперь ходят по VPN.

Важно:

  • не пробрасывать 8108 наружу в интернет (достаточно Tailscale);
  • следить, чтобы firewall/ufw на сервере B разрешал доступ с Tailscale-интерфейса.

Разделяем ключи: admin и search-only

Лучше всего:

  • admin-ключ - только в индексирующих скриптах (на сервере с MySQL);
  • search-only ключ - в Flask-приложении (для запросов /search).

Генерация search-only ключа

Скрипт, который с помощью admin-ключа создаёт ограниченный ключ:

# create_search_only_key.py
from ts_client_remote import client # здесь client с admin-ключом

def create_search_only_key():
    """
    Создаём ключ, который:
    - имеет доступ только на чтение;
    - только к коллекции 'pages';
    - может выполнять только search-запросы.
    """
    # описание прав зависит от версии Typesense, но идея такая:
    params = {
        "description": "Search-only key for pages collection (Flask app)",
        "actions": ["documents:search"], # только поиск
        "collections": ["pages"], # только коллекция pages
        # опционально ограничения по времени и IP
        # "expires_at": 1767225600, # unix timestamp
        # "value": "...", # можно задать своё значение, если нужно
    }
    key = client.keys.create(params)
    print(key)

if __name__ == "__main__":
    create_search_only_key()

После запуска получишь JSON с новым ключом вида:

{
  "id": 3,
  "value": "SEARCH_ONLY_KEY_XXX",
  "actions": ["documents:search"],
  "collections": ["pages"],
  ...
}
  • value - тот ключ, который вставляем в Flask.
  • Admin-ключ держим только там, где реально нужно создавать схемы/индексировать.

Используем search-only ключ во Flask

# ts_client_search.py
import typesense

TYPESENSE_SEARCH_API_KEY = "SEARCH_ONLY_KEY_XXX"

client = typesense.Client({
    "nodes": [
        {
            "host": "100.64.0.10", # Tailscale IP Typesense-сервера
            "port": "8108",
            "protocol": "http",
        }
    ],
    "api_key": TYPESENSE_SEARCH_API_KEY,
    "connection_timeout_seconds": 2,
})

Во всех Flask-частях (/search, фасеты, подсказки и т.д.) используем ts_client_search.client.В индексирующих скриптах (initial_index.py, reindex_single_page.py, настройка синонимов, overrides) оставляем admin-клиент.

Первичная заливка данных на удалённый Typesense

Тут есть два варианта:

  1. Индексировать с сервера A (MySQL + Flask)
    • скрипты читают MySQL локально и отправляют документы по Tailscale на сервер B.
    • Плюсы: быстро читаем БД, не трогаем сеть до Typesense (только HTTP).
  2. Индексировать с сервера B (Typesense)
    • нужно либо открыть доступ к MySQL по Tailscale (DB => Typesense), либо предварительно выгрузить дамп данных.

Проще и логичнее - вариант 1: изменяем наши индексирующие скрипты (Часть 2), чтобы они использовали удалённый ts_client_remote.

Пример: в initial_index.py меняем импорт:

# initial_index.py (замена клиента)
from ts_client_remote import client as ts_client

Всё остальное остаётся прежним:

  • fetch_batch тянет из локального MySQL;
  • index_batch отправляет документы в Typesense по Tailscale.

Если сеть не очень быстрая:

  • ещё сильнее уменьшить BATCH_SIZE, например, до 100–200;
  • при необходимости увеличить паузу между батчами.

Инкрементальные обновления через Tailscale

Теперь нужно поддерживать Typesense в актуальном состоянии, когда:

  • добавляются новые страницы;
  • изменяются существующие;
  • удаляются.

Обновление по событию (из кода приложения)

Самый точный вариант:

  • после успешного сохранения/обновления/удаления записи в MySQL вызвать функцию, которая обновит (или удалит) документ в Typesense.

Пример интеграции при сохранении страницы:

# services/search_index.py
from ts_client_remote import client as ts_client
from mapper import row_to_typesense_doc # как в Части 2
from db import get_connection

COLLECTION_NAME = "pages"

def reindex_page(page_id: int):
    conn = get_connection()
    try:
        with conn.cursor() as cursor:
            cursor.execute(
                """
                SELECT id, slug, title, content, tags, published_at, popularity
                FROM pages
                WHERE id = %s
                """,
                (page_id,),
            )
            row = cursor.fetchone()
    finally:
        conn.close()

    if not row:
        # Страницы нет в БД — удалим её из Typesense, если была
        try:
            ts_client.collections[COLLECTION_NAME].documents[str(page_id)].delete()
        except Exception:
            # если в индексе её уже нет — игнорируем
            pass
        return

    doc = row_to_typesense_doc(row)
    ts_client.collections[COLLECTION_NAME].documents.upsert(doc)

В обработчике сохранения (например, в админке):

# где-то в коде после commit в MySQL
reindex_page(page_id)

Периодическая синхронизация по updated_at

Если в таблице pages есть поле updated_at, можно:

  1. Добавить таблицу search_sync или просто файл с отметкой последней синхронизации.
  2. Cron-скриптом раз в N минут выполнять:
    SELECT * FROM pages WHERE updated_at > last_sync_time;
    
  3. Для каждой записи - upsert в Typesense.

Схема скрипта:

# incremental_sync.py
from datetime import datetime
from db import get_connection
from ts_client_remote import client as ts_client
from mapper import row_to_typesense_doc

COLLECTION_NAME = "pages"
BATCH_SIZE = 200

def get_last_sync_time() -> datetime:
    # тут можно читать из отдельной таблицы или файла.
    # Для примера возьмём "очень старое" время.
    return datetime(1970, 1, 1)

def set_last_sync_time(dt: datetime):
    # аналогично — сохраняем куда-то (таблица, файл, etc.)
    pass

def incremental_sync():
    last_sync = get_last_sync_time()
    conn = get_connection()
    try:
        with conn.cursor() as cursor:
            # берём только изменённые после last_sync
            cursor.execute(
                """
                SELECT id, slug, title, content, tags, published_at, popularity, updated_at
                FROM pages
                WHERE updated_at > %s
                ORDER BY updated_at ASC
                """,
                (last_sync,),
            )
            rows = cursor.fetchall()
    finally:
        conn.close()

    if not rows:
        print("Нет изменений.")
        return

    docs = [row_to_typesense_doc(r) for r in rows]
    ts_client.collections[COLLECTION_NAME].documents.import_(
        docs,
        {"action": "upsert"},
        batch_size=BATCH_SIZE,
    )

    new_last_sync = max(r["updated_at"] for r in rows)
    set_last_sync_time(new_last_sync)
    print(f"Синхронизировано {len(rows)} документов, last_sync = {new_last_sync}")

if __name__ == "__main__":
    incremental_sync()

Когда вынос Typesense оправдан

Имеет смысл выносить Typesense на отдельный сервер, если:

  • RAM на основном сервере кончается из-за поиска;
  • нужно масштабировать поиск независимо от веб-приложения;
  • планируется расширение (другие проекты будут использовать тот же поисковый сервер);
  • хотите более агрессивные настройки Typesense (например, vector search) без риска положить веб-часть.

Можно оставить всё на одном сервере, если:

  • индекс небольшой (100k записей / немного полей);
  • трафик умеренный;
  • есть хотя бы 2–4 GB RAM и вы аккуратно оптимизировали схему.