Запуск поиска по каталогу товаров часто упирается в одну и ту же инфраструктурную проблему — внедрение ElasticSearch. Инженеры поднимают кластер, пишут маппинги, настраивают стеммеры и заливают словари синонимов. Настройка лексического поиска требует постоянного ручного тюнинга весов и правил. Пользователь пишет «ноут для кодинга», а поисковый движок выдает пустую страницу, так как в базе лежит только «Ноутбук Apple MacBook Pro 14». Поддержка такого решения обходится дорого. Для каталогов малого и среднего размера (до 50–100 тысяч товаров) классический лексический поиск избыточен по затратам и неэффективен по результатам.
Вместо точного совпадения строк бизнесу нужен семантический поиск, понимающий контекст. RAG (Retrieval-Augmented Generation) на минималках решает эту задачу точнее и на порядки дешевле. Архитектура сводится к двум этапам: быстрому поиску по смыслу через векторные представления (эмбеддинги) и формированию человекочитаемого ответа с помощью LLM.
Мы разберем реализацию такого поиска на современном стеке: Next.js (App Router), Vercel AI SDK и единый шлюз RouterAPI. Главное архитектурное решение нашего подхода: жесткий отказ от серверной векторной базы данных. Мы перенесем тяжелую часть векторного поиска прямо на устройство клиента.
Переход к эмбеддингам и отказ от маппингов
Векторное представление переводит текст в многомерный массив чисел. Семантически близкие понятия (например, «для кодинга» и «мощный процессор») получают близкие векторы. Для создания поискового индекса мы собираем карточку товара (название, категорию, ключевые характеристики, цену) в единый текстовый блок и пропускаем его через модель генерации эмбеддингов.
Для работы с нейросетями мы используем RouterAPI. Это унифицированный шлюз, который позволяет обращаться к десяткам провайдеров через единый интерфейс, полностью совместимый с OpenAI. RouterAPI автоматически управляет балансировкой нагрузки, ретраями при падении провайдеров, фолбеком между моделями и нормализацией стоимости токенов.
Процесс индексации выглядит как простой фоновый скрипт на Node.js. Мы проходим по базе товаров и пакетами запрашиваем эмбеддинги.
import fs from 'node:fs/promises';
const ROUTERAPI_KEY = process.env.ROUTERAPI_KEY;
const ROUTERAPI_URL = 'https://routerapi.net/v1';
async function buildCatalogIndex(products) {
const vectors = [];
for (const product of products) {
const textContext = `${product.name}. Категория: ${product.category}. Описание: ${product.description}. Цена: ${product.price} руб.`;
const response = await fetch(`${ROUTERAPI_URL}/embeddings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${ROUTERAPI_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: textContext
})
});
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const { data } = await response.json;
vectors.push({
id: product.id,
embedding: data[0].embedding
});
}
return vectors;
}
Модель text-embedding-3-small возвращает вектор из 1536 чисел с плавающей точкой. Массив из 10 000 таких векторов весит около 60 мегабайт в сыром формате JSON. Отдавать такой объем текста на фронтенд напрямую недопустимо — клиент будет скачивать индекс десятки секунд.
Клиентский векторный поиск: бинарные форматы и квантование
Чтобы поиск происходил мгновенно и без сетевых запросов к бэкенду, мы сжимаем векторный индекс и передаем его на клиент для локальных вычислений.
Первый шаг оптимизации — отказ от текстового JSON в пользу бинарных форматов. Сериализация массива векторов в типизированный Float32Array немедленно сокращает размер файла до 15-20 МБ.
Второй шаг — скалярное квантование. Мы преобразуем 32-битные числа с плавающей точкой в 8-битные целые числа (Int8). В результате жесткого квантования размер индекса на 10 000 товаров падает до 3–4 МБ. Это средний объем одного тяжелого изображения на сайте. Современный браузер кэширует этот бинарный файл, и последующий поиск происходит с абсолютным нулем сетевой задержки.
Алгоритм поиска сводится к вычислению косинусного сходства между вектором запроса пользователя и векторами товаров. Чтобы не блокировать UI-поток браузера при переборе 10 000 многомерных массивов, мы выносим чистую математику в отдельный Web Worker.
// search-worker.js
function cosineSimilarity(vecA, vecB) {
let dotProduct = 0, normA = 0, normB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] * vecA[i];
normB += vecB[i] * vecB[i];
}
return normA === 0 || normB === 0 ? 0 : dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
self.onmessage = function(e) {
const { queryEmbedding, indexData, topK = 5 } = e.data;
// Вычисляем дистанцию для каждого товара
const scores = indexData.map(item => ({
id: item.id,
score: cosineSimilarity(queryEmbedding, item.embedding)
}));
// Возвращаем ID товаров с наивысшим совпадением
const topResults = scores
.sort((a, b) => b.score - a.score)
.slice(0, topK);
self.postMessage(topResults);
};
При вводе текста пользователем клиентское React-приложение делает один быстрый API-запрос к легковесному эндпоинту для векторизации строки запроса через RouterAPI. Этот единичный вектор передается в Web Worker, который за считанные миллисекунды возвращает ID самых релевантных товаров.
Next.js Route Handlers и потоковая генерация ответа (Vercel AI SDK)
Выдать правильные карточки товаров — это только половина задачи. Пользователю нужен связный контекст: почему конкретный ноутбук подойдет для программирования. За формирование экспертного обоснования отвечает LLM. Мы используем Vercel AI SDK для потоковой передачи (стриминга) ответа прямо в UI, а Next.js App Router служит безопасным прокси-сервером.
Vercel AI SDK обладает встроенной поддержкой кастомных OpenAI-совместимых провайдеров. Мы инициализируем инстанс клиента, направляя его в шлюз RouterAPI, что скрывает реальные ключи от фронтенда.
Создаем Route Handler для обработки чата:
// app/api/chat/route.js
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
const резервный канал RouterAPI = createOpenAI({
apiKey: process.env.ROUTERAPI_KEY,
baseURL: 'https://routerapi.net/v1',
});
export async function POST(req) {
const { messages, contextProducts } = await req.json;
const productDetails = contextProducts.map(p =>
`ID: ${p.id}. ${p.name}. Описание: ${p.description}. Цена: ${p.price} руб.`
).join('\n');
const systemPrompt = `Ты — строгий технический консультант интернет-магазина.
Ответь на запрос пользователя, опираясь исключительно на список предоставленных товаров.
Укажи технические причины, почему конкретная модель решает задачу пользователя.
Будь краток, оперируй фактами, не придумывай отсутствующие характеристики.
Доступные для рекомендации товары:
${productDetails}`;
const result = await streamText({
model: резервный канал RouterAPI('gpt-4o-mini'),
system: systemPrompt,
messages,
});
return result.toDataStreamResponse;
}
На стороне клиента хук useChat из пакета @ai-sdk/react берет на себя управление стейтом переписки и рендеринг потока. Как только локальный Web Worker возвращает ID топовых товаров, клиентский компонент подтягивает полные метаданные по этим товарам (из кэша или Zustand-стора) и отправляет POST-запрос в наш Route Handler.
// app/page.jsx
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { getProductMetadata } from '@/utils/catalog';
export default function SearchPage {
const [topProducts, setTopProducts] = useState([]);
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: '/api/chat',
body: {
// Отправляем полные данные о товарах в LLM
contextProducts: topProducts
}
});
async function onSubmit(e) {
e.preventDefault;
// 1. Получаем эмбеддинг запроса пользователя
const queryVector = await fetchQueryEmbedding(input);
// 2. Ищем товары локально через Web Worker (0ms network latency)
const resultIds = await searchViaWorker(queryVector);
const populatedProducts = resultIds.map(id => getProductMetadata(id));
setTopProducts(populatedProducts);
// 3. Запускаем стриминг ответа от LLM
handleSubmit(e);
}
return (
<div className="search-container">
<form onSubmit={onSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Найти ноутбук для работы.."
/>
<button type="submit">Найти</button>
</form>
<div className="product-grid">
{topProducts.map(product => (
<ProductCard key={product.id} data={product} />
))}
</div>
<div className="chat-area">
{messages.map(m => (
<div key={m.id} className={`message ${m.role}`}>
{m.role === 'user' ? 'Вы: ' : 'AI-Консультант: '}{m.content}
</div>
))}
</div>
</div>
);
}
В результате пользователь видит гибридный интерфейс. Снизу моментально отрисовываются карточки товаров, найденных векторной математикой. А сверху, со скоростью чтения, печатается персональный комментарий, объясняющий выбор и подсвечивающий сильные стороны каждой модели.
Архитектурные итоги и инсайты
Переход на гибридный RAG-подход полностью исключает сложную серверную инфраструктуру из уравнения.
Во-первых, мы ликвидировали серверный лексический поисковик. Настройка стеммеров и поддержка словарей заменены вызовом одной модели эмбеддингов, которая «из коробки» понимает синонимы, опечатки и скрытый контекст на десятках языков.
Во-вторых, мы отказались от содержания тяжелых векторных баз данных (Pinecone, Qdrant или кластеров PostgreSQL с pgvector). Вынос математики косинусного сходства в Web Worker пользовательского браузера сократил серверные расходы до нуля и снизил задержки на поиск до абсолютного минимума. Квантование индекса до Int8 превратило базу в компактный статический файл.
В-третьих, интеграция RouterAPI в качестве единой точки доступа снимает риск вендор-лока. Если текущий провайдер нейросетей вводит жесткие лимиты или неадекватно повышает цены, разработчик меняет строку model на claude-3-haiku или открытую llama-3.1, не переписывая логику интеграции. RouterAPI забирает на себя агрегацию, фолбеки и кэширование одинаковых поисковых запросов.
Такой минималистичный подход к AI-поиску идеально ложится в бессерверную парадигму Next.js. Сложная вычислительная проблема поиска решается на уровне клиента, оставляя бэкенду лишь роль тонкого и дешевого прокси-сервера к нейромоделям.