Миграция с поиска в БД (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 |
|---|---|---|
id | bigint | id (string — Typesense использует строки) |
name | varchar | name (string, query_by) |
description | text | description (string, query_by) |
tags (jsonb) | jsonb | tags (string[], query_by, под фильтр тоже) |
price_cents | bigint | price (int64, sort_by, filter_by) |
category_id | bigint | categoryId (string, filter_by) |
in_stock | bool | inStock (bool, filter_by) |
created_at | timestamp | createdAt (int64 epoch, sort_by) |
tenant_id | bigint | tenantId (string, всегда в filter_by) |
Два общих правила:
- ID — строка. Кастуйте числовые id в строки.
- Время — числа, не строки. 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:<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.
Рабочие паттерны:
- Outbox. Приложение пишет в БД и outbox-таблицу в одной транзакции. Воркер читает outbox и зовёт
searchDocument.bulkUpsert. Latency: 1–5 секунд. - CDC (Debezium, pgstream). WAL → AACsearch. Latency: sub-second. Больше движущихся частей.
- Sync on write. Приложение пишет в AACsearch в том же хендлере, что и в БД. Просто, но каждый аутаж поиска — аутаж записи. Не на high-volume.
Для e-commerce / CMS / CRM — обычно outbox.
Валидация
После экспорта/реингеста:
- Doc count. Сверить
SELECT count(*) FROM products WHERE deleted_at IS NULLсsearchIndex.getHealth.actualDocCount. Должно совпасть. - Сэмплинг 100 запросов из логов. Top-5 до и после, классификация.
- Edge-cases. Пустой query (фасеты без хитов), unicode, очень длинные запросы, запросы с операторами (
-not_this,"phrase"). - Tenant-фильтр. Поиск как один арендатор — чужих документов не видно.
Вывод БД-поиска
Параллельно 7 дней. Потом:
- Удалить
LIKE-индекс илиtsvector(вне query-пути — мёртвый груз). - Убрать поиск-колонки из
SELECT-листов. - Оставить тулзы экспорта — пригодятся для backfill при первой смене схемы.
Частые ошибки
- Импорт без
tenantId. Single-tenant индекс там, где должен быть multi-tenant. Сначала продумайте поле, потом импорт. - ID — числа. Typesense ждёт строки.
id: "42", неid: 42. - Время как строка.
createdAt: "2026-05-01T00:00:00Z"— невалидно.createdAt: 1714521600— да. - Пропуск валидации. «Это full-text search, насколько разное?» — достаточно разное, чтобы получить жалобы.
См. также
- Обзор миграции
- Чек-лист
- Изоляция арендаторов
- Реиндексирование — для неизбежного изменения схемы