AACsearch
Руководства по миграции

Миграция с поиска в БД (SQL LIKE / FTS)

Миграция с `LIKE %query%`, MySQL fulltext, PostgreSQL `tsvector` или иного БД-нативного поиска в AACsearch.

С поиска в БД

Если сегодня поиск — WHERE name LIKE '%query%', MySQL fulltext или PostgreSQL tsvector, это ваш гайд. Паттерн ниже — самый общий шаблон миграции; другие гайды на сайте — его специализации.

Зачем уходить от поиска в БД

Без маркетинга. Честные причины:

  • LIKE %query% не использует индекс. После ~100k строк — самый медленный запрос в приложении.
  • MySQL fulltext — норм, но мало контроля над токенизером и без типо-толерантности.
  • PostgreSQL tsvector хорош на статичных корпусах, но переписывание индекса на каждый апдейт — дорого.
  • У всех трёх практически нет фасетов, курированного ранжирования, аналитики «из коробки».

Маленький корпус (< 10k) и роста не планируется — не мигрируйте. БД-поиск нормально. Поставьте GIN индекс на tsvector и живите дальше. AACsearch — когда переросли способность БД быть быстрым, полезным и наблюдаемым в поиске.

Маппинг

Общая форма:

DB row           →   Документ
DB column        →   Поле
DB index/FTS     →   `query_by` (какие колонки ищем)
WHERE            →   `filter_by`
ORDER BY         →   `sort_by` / `default_sorting_field`

Пример для products:

DB колонкаТипПоле AACsearch
idbigintid (string — Typesense использует строки)
namevarcharname (string, query_by)
descriptiontextdescription (string, query_by)
tags (jsonb)jsonbtags (string[], query_by, под фильтр тоже)
price_centsbigintprice (int64, sort_by, filter_by)
category_idbigintcategoryId (string, filter_by)
in_stockboolinStock (bool, filter_by)
created_attimestampcreatedAt (int64 epoch, sort_by)
tenant_idbiginttenantId (string, всегда в filter_by)

Два общих правила:

  1. ID — строка. Кастуйте числовые id в строки.
  2. Время — числа, не строки. Epoch seconds или ms; никогда ISO. Filter/sort по времени работают только с числами.

Описание схемы

import { client } from "@repo/api/client";

await client.searchIndex.create.call({
  slug: "products",
  fields: [
    { name: "name", type: "string" },
    { name: "description", type: "string", optional: true },
    { name: "tags", type: "string[]", facet: true },
    { name: "price", type: "int64" },
    { name: "categoryId", type: "string", facet: true },
    { name: "inStock", type: "bool", facet: true },
    { name: "createdAt", type: "int64" },
    { name: "tenantId", type: "string", facet: true },
  ],
  defaultSortingField: "createdAt",
  tokenSeparators: ["-"], // опционально — по вашим данным
});

tenantId — поле, по которому приложение будет фильтровать и на которое будут сужать scoped-токены. Не пропускайте.

Экспорт

PostgreSQL:

\copy (
  SELECT
    id::text                            AS id,
    name,
    description,
    tags,
    price_cents                         AS price,
    category_id::text                   AS "categoryId",
    in_stock                            AS "inStock",
    EXTRACT(EPOCH FROM created_at)::bigint AS "createdAt",
    tenant_id::text                     AS "tenantId"
  FROM products
  WHERE deleted_at IS NULL
)
TO '/tmp/products.ndjson'
WITH (FORMAT csv, HEADER false, DELIMITER '\t');

NDJSON через row_to_json:

\copy (
  SELECT row_to_json(t) FROM (
    SELECT
      id::text AS id,
      name,
      description,
      tags,
      price_cents AS price,
      category_id::text AS "categoryId",
      in_stock AS "inStock",
      EXTRACT(EPOCH FROM created_at)::bigint AS "createdAt",
      tenant_id::text AS "tenantId"
    FROM products
    WHERE deleted_at IS NULL
  ) t
)
TO '/tmp/products.ndjson';

Для ingest-пути ниже нужен NDJSON.

Ingest

Стримим NDJSON батчами в ingest-эндпоинт:

import { client } from "@repo/api/client";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const BATCH = 500;
const stream = createInterface({
  input: createReadStream("/tmp/products.ndjson"),
});
let batch: Record<string, unknown>[] = [];

for await (const line of stream) {
  batch.push(JSON.parse(line));
  if (batch.length >= BATCH) {
    await client.searchDocument.bulkUpsert.call({
      indexSlug: "products",
      documents: batch,
    });
    batch = [];
  }
}
if (batch.length > 0) {
  await client.searchDocument.bulkUpsert.call({
    indexSlug: "products",
    documents: batch,
  });
}

Ingest складывает в буфер; воркер флашит в Typesense. Лаг ingest вырастет — это нормально. Смотрите мониторинг; лаг сходит к 0 через минуты после конца экспорта.

Перевод запросов

| Database | AACsearch | | ------------------------------------------- | ------------------------------------------------- | --- | --- | ----------------------- | -------------------------------- | | WHERE name ILIKE '%' | | $1 | | '%' | q=$1, query_by=name | | WHERE to_tsvector(name) @@ to_tsquery($1) | q=$1, query_by=name (плюс типо-толерантность) | | WHERE tenant_id = $1 | filter_by=tenantId:=$1 | | WHERE in_stock = true AND price < 5000 | filter_by=inStock:=true && price:&lt;5000 | | ORDER BY created_at DESC LIMIT 20 | sort_by=createdAt:desc, per_page=20 | | WHERE name ILIKE '%' | | $1 | | '%' AND tenant_id = $2 | q=$1, filter_by=tenantId:=$2 |

Большие отличия:

  • Пагинация. page + per_page вместо OFFSET/LIMIT. Производительность та же до page=250; глубже — cursor по sort-key.
  • Типо-толерантность. По умолчанию включена. num_typos=0 для строгого matching.
  • Highlighting. highlight_full_fields=name,description — сервер вернёт <mark>-обёрнутые сниппеты. В БД этого нет, делали клиентом.

Real-time апдейты

В БД-поиске индекс обновляется при обновлении строки (триггеры или read-time). В AACsearch — пушите через ingest.

Рабочие паттерны:

  1. Outbox. Приложение пишет в БД и outbox-таблицу в одной транзакции. Воркер читает outbox и зовёт searchDocument.bulkUpsert. Latency: 1–5 секунд.
  2. CDC (Debezium, pgstream). WAL → AACsearch. Latency: sub-second. Больше движущихся частей.
  3. Sync on write. Приложение пишет в AACsearch в том же хендлере, что и в БД. Просто, но каждый аутаж поиска — аутаж записи. Не на high-volume.

Для e-commerce / CMS / CRM — обычно outbox.

Валидация

После экспорта/реингеста:

  1. Doc count. Сверить SELECT count(*) FROM products WHERE deleted_at IS NULL с searchIndex.getHealth.actualDocCount. Должно совпасть.
  2. Сэмплинг 100 запросов из логов. Top-5 до и после, классификация.
  3. Edge-cases. Пустой query (фасеты без хитов), unicode, очень длинные запросы, запросы с операторами (-not_this, "phrase").
  4. Tenant-фильтр. Поиск как один арендатор — чужих документов не видно.

Вывод БД-поиска

Параллельно 7 дней. Потом:

  1. Удалить LIKE-индекс или tsvector (вне query-пути — мёртвый груз).
  2. Убрать поиск-колонки из SELECT-листов.
  3. Оставить тулзы экспорта — пригодятся для backfill при первой смене схемы.

Частые ошибки

  • Импорт без tenantId. Single-tenant индекс там, где должен быть multi-tenant. Сначала продумайте поле, потом импорт.
  • ID — числа. Typesense ждёт строки. id: "42", не id: 42.
  • Время как строка. createdAt: "2026-05-01T00:00:00Z" — невалидно. createdAt: 1714521600 — да.
  • Пропуск валидации. «Это full-text search, насколько разное?» — достаточно разное, чтобы получить жалобы.

См. также

On this page