AACsearch
SDKРецепты

Мультиязычный каталог

Один товар, несколько языков. Единый индекс с фасетом `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 и нужно добавить другие языки:

  1. Добавьте поле locale в схему (это не ломает обратную совместимость).
  2. Дополните существующие документы полем locale: "en".
  3. Обновите коннектор для генерации строк на каждый язык.
  4. Разверните токены с ограничением по языку для клиентов.
  5. Когда все клиенты используют новые токены, изоляция достигнута.

Переход происходит без простоя — индекс только с EN остаётся доступным для запросов на протяжении всего процесса.

Для ~5 языков и типичного каталога это умножает количество документов в 5×. Поисковые единицы AACsearch считаются по одному на индексированный документ — учитывайте это при выборе тарифа.

Связанные страницы

On this page