Мультиязычный каталог
Один товар, несколько языков. Единый индекс с фасетом `locale`, языково-специфичные текстовые поля и токен с ограничением, привязывающий пользователя к его языку.
Витрину на 5 языках можно смоделировать тремя способами: отдельные индексы на каждый язык, отдельные документы на каждый язык в одном индексе или один документ с полями, привязанными к языку. Этот рецепт использует второй паттерн — отдельные документы на каждый язык, один общий индекс — потому что он даёт наиболее чистую поисковую семантику и простейший скоринг.
Почему не «один документ со всеми языками»
// Антипаттерн
{
id: "product-123",
title_en: "Running shoes",
title_de: "Laufschuhe",
title_fr: "Chaussures de course",
title_es: "Zapatillas de correr",
title_ru: "Беговые кроссовки",
}Выглядит компактно, но вам придётся использовать queryBy: "title_en,title_de,title_fr,title_es,title_ru" при каждом поиске — что фрагментирует скоринг по 5 полям и сопоставляет французские запросы с немецкими заголовками. Плохие результаты.
Правильная форма
Один документ на (товар × язык):
type LocalizedProduct = {
id: string; // составной: <external_id>-<locale>
external_id: string;
locale: "en" | "de" | "es" | "fr" | "ru";
title: string;
description: string;
brand: string;
categories: string[]; // также локализованы
price: number;
currency: "USD" | "EUR" | "RUB";
in_stock: boolean;
};Схема индекса:
await admin.createIndex({
slug: "products",
fields: [
{ name: "title", type: "string", locale: "auto" },
{ name: "description", type: "string", locale: "auto" },
{ name: "brand", type: "string", facet: true },
{ name: "categories", type: "string[]", facet: true },
{ name: "locale", type: "string", facet: true },
{ name: "price", type: "float", facet: true },
{ name: "currency", type: "string", facet: true },
{ name: "in_stock", type: "bool", facet: true },
{ name: "external_id", type: "string", facet: true }, // для группировки
],
defaultSortingField: "price",
});locale: "auto" включает токенизацию с учётом языка (немецкие составные слова, русский стемминг и т.д.).
Поиск с фильтрацией по языку
Каждый поиск должен фильтроваться по языку пользователя, иначе он увидит результаты на 5 языках вперемешку:
import { useLocale } from "next-intl";
const locale = useLocale(); // "en" | "de" | "es" | "fr" | "ru"
const result = await client.search({
q: searchQuery,
queryBy: "title,description,brand",
filterBy: `locale:=${JSON.stringify(locale)}`,
facetBy: "brand,categories,price",
perPage: 24,
});Это работает, но каждый вызывающий должен помнить о фильтре. Следующий раздел устраняет этот риск.
Токены с ограничением по языку (рекомендуется)
Создайте токен с ограничением по языку на сервере. Токен комбинирует через И locale, так что клиент не может случайно выполнить поиск по всем языкам:
// app/api/search-token/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { orpc } from "@/lib/orpc";
const SUPPORTED_LOCALES = ["en", "de", "es", "fr", "ru"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
function detectLocale(): Locale {
// Чтение из cookie, заголовка или пути — ваша стратегия i18n
const fromHeader = headers()
.get("Accept-Language")
?.split(",")[0]
.split("-")[0] as Locale;
return SUPPORTED_LOCALES.includes(fromHeader) ? fromHeader : "en";
}
export async function POST() {
const locale = detectLocale();
const token = await orpc.search.createScopedToken.call({
organizationId: process.env.AACSEARCH_ORG_ID!,
indexSlug: "products",
scopedFilter: `locale:=${locale}`,
expiresInSeconds: 4 * 60 * 60,
name: `i18n-${locale}`,
});
return NextResponse.json({ token: token.token, locale });
}Теперь клиенту не нужно ничего помнить:
const { token, locale } = await fetch("/api/search-token", {
method: "POST",
}).then((r) => r.json());
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: token,
indexSlug: "products",
});
// Все поиски автоматически ограничены языком пользователя
const result = await client.search({ q: "shoes", queryBy: "title" });Валюта и цена
Документы каждого языка содержат свою цену и валюту. Логика отображения остаётся простой:
function formatPrice(product: LocalizedProduct) {
return new Intl.NumberFormat(product.locale, {
style: "currency",
currency: product.currency,
}).format(product.price);
}Для RU это возвращает "1 490 ₽". Для EN — "$19.99". Intl.NumberFormat делает всю тяжёлую работу.
Коннектор: создание одного документа на (external_id × язык)
В вашем коннекторе размножьте каждый товар по языкам:
async function syncProduct(source: SourceProduct) {
const docs = SUPPORTED_LOCALES.flatMap((locale) => {
const t = source.translations[locale];
if (!t) return []; // пропустить языки без перевода
return [
{
id: `${source.id}-${locale}`,
external_id: source.id,
locale,
title: t.title,
description: t.description,
brand: source.brand, // бренд не зависит от языка
categories: t.categories,
price: source.prices[currencyForLocale(locale)],
currency: currencyForLocale(locale),
in_stock: source.in_stock,
},
];
});
await fetch(`${BASE}/api/projects/${ORG}/sync/delta`, {
method: "POST",
headers: { Authorization: `Bearer ${CONNECTOR_TOKEN}` },
body: JSON.stringify({ products: docs }),
});
}Если у товара есть переводы только на 3 из 5 языков, он появляется только в результатах этих 3 языков. Резервный вариант на английский по умолчанию отсутствует — вы должны явно создать строку EN, чтобы товар был доступен в поиске EN.
Кроссязыковая каноникализация URL
Когда пользователь кликает по результату, вы обычно хотите, чтобы URL указывал на тот же товар на пути с префиксом языка:
function productUrl(product: LocalizedProduct) {
return `/${product.locale}/products/${product.external_id}`;
}external_id общий для всех языков — именно он позволяет сопоставить «один и тот же товар» между языками.
Миграция с одноязычного индекса
Если вы начинали только с EN и нужно добавить другие языки:
- Добавьте поле
localeв схему (это не ломает обратную совместимость). - Дополните существующие документы полем
locale: "en". - Обновите коннектор для генерации строк на каждый язык.
- Разверните токены с ограничением по языку для клиентов.
- Когда все клиенты используют новые токены, изоляция достигнута.
Переход происходит без простоя — индекс только с EN остаётся доступным для запросов на протяжении всего процесса.
Для ~5 языков и типичного каталога это умножает количество документов в 5×. Поисковые единицы AACsearch считаются по одному на индексированный документ — учитывайте это при выборе тарифа.
Связанные страницы
- Токены поиска с ограниченной областью действия — принудительное ограничение по языку
- Изоляция арендаторов маркетплейса — тот же паттерн, другой фильтр
- Жизненный цикл API коннектора — отправка локализованных документов
Изоляция арендаторов маркетплейса
Мульти-вендорный маркетплейс, где каждая витрина видит только свои SKU. Единый индекс, токены с ограничением по вендору, нулевая утечка данных между арендаторами.
Серверные хелперы
Пакетная обработка, идемпотентность, стратегия повторных попыток и проверка подписи вебхуков — паттерны, которым должен следовать каждый потребитель серверного SDK при индексации документов и получении вебхуков от AACsearch.