Эмбеддинг — это вектор из сотен чисел, который модель строит по тексту так, что близкие по смыслу тексты получают близкие векторы. Векторы можно хранить прямо в PostgreSQL с расширением pgvector и искать ближайшие по косинусному расстоянию оператором <=>, а для больших таблиц добавить приближённый индекс HNSW или IVFFlat. Эмбеддинги можно получать локальной моделью без ключей или через облачные API — главное, кодировать документы и запросы одной и той же моделью.
После материала вы сможете
- получить эмбеддинги текстов локальной моделью через
@huggingface/transformersи объяснить, что означает косинусная близость - сохранить векторы в PostgreSQL с pgvector и найти ближайшие документы оператором
<=> - добавить индекс HNSW или IVFFlat, настроить
hnsw.ef_searchиivfflat.probesи измерить recall@10 - совместить векторный и полнотекстовый поиск через Reciprocal Rank Fusion
Что знать заранее
Проверено на
PostgreSQL 17.11 + pgvector 0.8.7 (образ pgvector/pgvector:pg17)Node.js 24.18@huggingface/transformers 4.3.1, модель Xenova/multilingual-e5-smallpg 8.23.1, pgvector (npm) 0.3.0openai 7.32.0 (проверено на мок-сервере)Docker 29
Сверено с документацией: 12 октября 2026 г.
Содержание
- Эмбеддинг: текст как точка в пространстве смыслов
- Расстояния: косинус, скалярное произведение, L2
- Откуда брать эмбеддинги: локальная модель или API
- pgvector в Docker: таблица, загрузка и первый поиск
- Индексы HNSW и IVFFlat
- Гибридный поиск: смысл плюс ключевые слова
- Нарезка, частые ошибки и когда нужна отдельная векторная БД
- Упражнения
Эмбеддинг: текст как точка в пространстве смыслов
Обычный поиск по ключевым словам находит документ, только если в нём встречается слово из запроса. Клиент пишет «нужно приложение для телефона», а в описании услуги стоит «мобильные приложения для iOS и Android» — общих слов почти нет, и LIKE или полнотекстовый поиск промахнутся. Векторный поиск сравнивает не слова, а смысл.
Модель эмбеддингов (embedding model — нейросеть, которая превращает текст в вектор) читает текст и возвращает массив чисел фиксированной длины. В этом уроке это многоязычная модель multilingual-e5-small: 384 числа на любой текст до 512 токенов, длиннее — обрезается. По карточке модели она поддерживает около ста языков, включая русский и английский. Сами числа ничего не значат по отдельности; важно взаимное расположение векторов: тексты о похожем оказываются рядом.
Близость двух векторов измеряют косинусом угла между ними: скалярное произведение, делённое на произведение длин. Совпадающие направления дают 1, перпендикулярные — 0. Посчитаем косинус вручную на трёх фразах — без базы данных, только модель и арифметика:
import { embed } from "./embed.ts";
function dot(a: number[], b: number[]): number {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
function norm(a: number[]): number {
return Math.sqrt(dot(a, a));
}
// Косинусная близость: скалярное произведение, делённое на произведение длин
export function cosine(a: number[], b: number[]): number {
return dot(a, b) / (norm(a) * norm(b));
}
const texts = [
"Нужен интернет-магазин с каталогом товаров",
"Разработка сайта с корзиной и онлайн-оплатой",
"Как настроить резервное копирование базы данных",
];
const vectors = await embed(texts, "query");
console.log("размерность:", vectors[0].length);
console.log("длина вектора:", norm(vectors[0]).toFixed(4));
console.log("первые 5 чисел:", vectors[0].slice(0, 5).map((x) => x.toFixed(4)).join(", "));
for (let i = 0; i < texts.length; i++) {
for (let j = i + 1; j < texts.length; j++) {
console.log(`${i + 1}↔${j + 1}: ${cosine(vectors[i], vectors[j]).toFixed(3)}`);
}
}$ node src/cosine.ts
размерность: 384
длина вектора: 1.0000
первые 5 чисел: -0.0070, -0.0174, -0.0829, -0.0610, 0.0510
1↔2: 0.859
1↔3: 0.794
2↔3: 0.801Расстояния: косинус, скалярное произведение, L2
pgvector поддерживает несколько функций расстояния, у каждой свой оператор и свой класс операторов для индекса. Расстояние — чем меньше, тем ближе, поэтому сортировка всегда ORDER BY … ASC.
<=>
- Что считает
- Косинусное расстояние = 1 − косинусная близость
- Класс операторов для индекса
vector_cosine_ops
<#>
- Что считает
- Скалярное произведение со знаком минус (индексы умеют только ASC)
- Класс операторов для индекса
vector_ip_ops
<->
- Что считает
- Евклидово расстояние (L2)
- Класс операторов для индекса
vector_l2_ops
<+>
- Что считает
- Манхэттенское расстояние (L1)
- Класс операторов для индекса
vector_l1_ops
Если векторы нормализованы до длины 1, все три основные метрики дают один и тот же порядок результатов: косинус равен скалярному произведению, а L2 монотонно с ним связан. Документация OpenAI и руководство по эмбеддингам Voyage AI прямо пишут, что их векторы уже нормализованы. Мы нормализуем сами — опция normalize: true в пайплайне Transformers.js.
- Берите
<=>иvector_cosine_ops, если не уверены, нормализованы ли векторы, — косинус не зависит от длины - Для нормализованных векторов pgvector советует
<#>: скалярное произведение считается быстрее - Индекс работает только для той метрики, с классом операторов которой он создан: индекс
vector_l2_opsне ускорит запрос с<=>
Откуда брать эмбеддинги: локальная модель или API
Для урока нужна модель, которая работает без ключей и регистрации. Transformers.js (@huggingface/transformers) запускает модели в формате ONNX прямо в Node.js: при первом вызове скачивает веса с Hugging Face Hub и кеширует на диске. Мы берём квантованную версию q8 — около 120 МБ вместо 470 МБ у полной fp32; на наших запросах обе версии ставили на первое место один и тот же документ.
import { env, pipeline } from "@huggingface/transformers";
// Кеш весов — в папке проекта, а не внутри node_modules
// (переживёт переустановку пакетов; добавьте .models в .gitignore)
env.cacheDir = "./.models";
// Многоязычная модель: 384 измерения, понимает русский и английский.
// Первый запуск скачивает веса с Hugging Face Hub (~120 МБ для q8).
const MODEL = "Xenova/multilingual-e5-small";
const extractor = await pipeline("feature-extraction", MODEL, { dtype: "q8" });
// E5 обучали с префиксами: "query: " для вопроса, "passage: " для документа.
export type Kind = "query" | "passage";
export async function embed(texts: string[], kind: Kind): Promise<number[][]> {
const output = await extractor(
texts.map((t) => `${kind}: ${t}`),
{ pooling: "mean", normalize: true }, // среднее по токенам, длина вектора 1
);
return output.tolist() as number[][];
}
export const DIMENSIONS = 384;Кеш по умолчанию лежит внутри node_modules/@huggingface/transformers/.cache. Мы переносим его в ./.models через env.cacheDir — так документирует Transformers.js. На Windows это заодно спасло от реальной ошибки: путь к файлу модели внутри node_modules превысил 260 символов, и ONNX Runtime ответил «File doesn't exist» для существующего файла.
В продакшене эмбеддинги часто получают через API. У Anthropic нет собственной модели эмбеддингов: документация Claude описывает Voyage AI (теперь в составе MongoDB) как одного из провайдеров и советует сравнить нескольких под свою задачу. У OpenAI есть text-embedding-3-small и text-embedding-3-large. Ниже — обёртки для обоих; живые запросы мы не отправляли, код проверен на локальном мок-сервере, который принимает те же запросы.
import OpenAI from "openai";
// Ключи — только из окружения (.env на сервере), никогда в коде и не во фронтенде
const openai = new OpenAI(); // читает OPENAI_API_KEY
export async function embedOpenAI(texts: string[]): Promise<number[][]> {
const res = await openai.embeddings.create({
model: "text-embedding-3-small",
input: texts,
dimensions: 384, // укоротить вектор под колонку vector(384)
});
return res.data.map((d) => d.embedding);
}
// Voyage AI — провайдер из руководства Anthropic по эмбеддингам.
// Ключ создаётся в MongoDB Atlas (раздел AI Model APIs).
const VOYAGE_URL = process.env.VOYAGE_BASE_URL ?? "https://ai.mongodb.com/v1";
export async function embedVoyage(
texts: string[],
inputType: "query" | "document",
): Promise<number[][]> {
const res = await fetch(`${VOYAGE_URL}/embeddings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
},
body: JSON.stringify({ input: texts, model: "voyage-4", input_type: inputType }),
});
if (!res.ok) throw new Error(`Voyage ${res.status}: ${await res.text()}`);
const json = (await res.json()) as { data: { embedding: number[]; index: number }[] };
return json.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
}multilingual-e5-small
- Где работает
- Локально, Transformers.js
- Размерность
- 384
- Что учесть
- Префиксы
query:/passage:, до 512 токенов
text-embedding-3-small
- Где работает
- API OpenAI
- Размерность
- 1536, укорачивается параметром
dimensions - Что учесть
- Векторы нормализованы
voyage-4
- Где работает
- API Voyage AI (MongoDB Atlas)
- Размерность
- 1024 по умолчанию; 256, 512, 2048
- Что учесть
- Параметр
input_type, векторы нормализованы
pgvector в Docker: таблица, загрузка и первый поиск
Официальный образ pgvector/pgvector:pg17 — это обычный PostgreSQL 17 с уже собранным расширением, в нашем запуске — версии 0.8.7. Корпус для поиска — двенадцать коротких описаний направлений работы студии: веб- и мобильная разработка, CRM, ERP, интеграции, аудит и так далее.
# PostgreSQL 17 + pgvector; пароль test — только для локального одноразового контейнера
docker run -d --rm --name kb-pgvector-emb -e POSTGRES_PASSWORD=test -p 55440:5432 pgvector/pgvector:pg17
npm init -y && npm pkg set type=module
npm install @huggingface/transformers@4.3.1 pg@8.23.1 pgvector@0.3.0
npm install -D typescript @types/node@24 @types/pg
# схема, загрузка корпуса и первый поиск
docker exec -i kb-pgvector-emb psql -U postgres -v ON_ERROR_STOP=1 < sql/01-schema.sql
export DATABASE_URL=postgres://postgres:test@localhost:55440/postgres
node src/seed.ts
node src/search.ts "нужно приложение для телефона"< используйте Get-Content sql/01-schema.sql | docker exec -i …, а переменную задайте как $env:DATABASE_URL="…". Node.js 24 выполняет .ts-файлы напрямую[
{"slug":"web-development","title":"Разработка сайтов","body":"Корпоративные и продуктовые сайты, каталоги и интернет-магазины: структура, UX/UI-дизайн, frontend, SEO-ready архитектура, подключение аналитики и запуск на домене заказчика."},
{"slug":"web-app-development","title":"Веб-приложения","body":"Личные кабинеты, внутренние порталы и панели управления: роли и права доступа, бизнес-логика, база данных, API и интеграции с внешними сервисами."},
{"slug":"mobile-development","title":"Мобильные приложения","body":"Приложения для iOS и Android: сценарии, дизайн под гайдлайны платформ, backend с API, авторизация, push-уведомления и публикация в App Store и Google Play."},
…
]CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE services (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text UNIQUE NOT NULL,
title text NOT NULL,
body text NOT NULL,
-- размерность фиксирована моделью: multilingual-e5-small даёт 384 числа
embedding vector(384) NOT NULL,
-- для гибридного поиска: лексемы русского текста, обновляются сами
tsv tsvector GENERATED ALWAYS AS (
to_tsvector('russian', title || ' ' || body)
) STORED
);
CREATE INDEX services_tsv_idx ON services USING gin (tsv);Тип vector(384) отклонит вектор другой длины — это защита от смешения моделей. Колонка tsv понадобится для гибридного поиска; она GENERATED … STORED, поэтому PostgreSQL сам пересчитывает её при каждом изменении текста. Подключение к базе вынесено в отдельный модуль: pgvector.registerTypes учит драйвер pg передавать и читать тип vector.
import pg from "pg";
import pgvector from "pgvector/pg";
// Строка подключения — из окружения, пароль в коде не храним
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
// регистрируем тип vector для каждого нового соединения пула
onConnect: async (client) => {
await pgvector.registerTypes(client);
},
});import { readFile } from "node:fs/promises";
import pgvector from "pgvector/pg";
import { pool } from "./db.ts";
import { embed } from "./embed.ts";
type Doc = { slug: string; title: string; body: string };
const docs: Doc[] = JSON.parse(await readFile("data/services.json", "utf8"));
// Документы кодируем с префиксом passage — одним батчем
const vectors = await embed(docs.map((d) => `${d.title}. ${d.body}`), "passage");
for (const [i, d] of docs.entries()) {
await pool.query(
`INSERT INTO services (slug, title, body, embedding) VALUES ($1, $2, $3, $4)
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title, body = EXCLUDED.body, embedding = EXCLUDED.embedding`,
[d.slug, d.title, d.body, pgvector.toSql(vectors[i])],
);
}
console.log(`загружено документов: ${docs.length}`);
await pool.end();import pgvector from "pgvector/pg";
import { pool } from "./db.ts";
import { embed } from "./embed.ts";
const question = process.argv[2] ?? "нужно приложение для телефона";
const [q] = await embed([question], "query");
// <=> — косинусное расстояние; близость = 1 - расстояние
const { rows } = await pool.query(
`SELECT slug, title, round((1 - (embedding <=> $1))::numeric, 3) AS similarity
FROM services
ORDER BY embedding <=> $1
LIMIT 3`,
[pgvector.toSql(q)],
);
console.log(`«${question}»`);
console.table(rows);
await pool.end();$ node src/seed.ts
загружено документов: 12
$ node src/search.ts "нужно приложение для телефона"
┌─────────┬──────────────────────┬────────────────────────┬────────────┐
│ (index) │ slug │ title │ similarity │
├─────────┼──────────────────────┼────────────────────────┼────────────┤
│ 0 │ 'mobile-development' │ 'Мобильные приложения' │ '0.888' │
│ 1 │ 'support' │ 'Сопровождение' │ '0.834' │
│ 2 │ 'optimization' │ 'Оптимизация продукта' │ '0.833' │
└─────────┴──────────────────────┴────────────────────────┴────────────┘
$ node src/search.ts "хотим, чтобы заказы из Kaspi сами попадали в учёт"
│ 0 │ 'integrations' │ 'Интеграции и API' │ '0.829' │
│ 1 │ 'support' │ 'Сопровождение' │ '0.812' │
│ 2 │ 'saas-development' │ 'SaaS-продукты' │ '0.801' │
$ node src/search.ts "who can review our code and security?"
│ 0 │ 'support' │ 'Сопровождение' │ '0.779' │
│ 1 │ 'optimization' │ 'Оптимизация продукта' │ '0.773' │
│ 2 │ 'web-app-development' │ 'Веб-приложения' │ '0.770' │
$ node src/search.ts "technical audit of an existing app"
│ 0 │ 'audit' │ 'Технический аудит' │ '0.821' │
│ 1 │ 'support' │ 'Сопровождение' │ '0.794' │
│ 2 │ 'mobile-development' │ 'Мобильные приложения' │ '0.790' │Первые два запроса не содержат слов «мобильные» и «интеграции», но нужный документ на первом месте. Английский вопрос «who can review our code and security?» модель сопоставила с русскими текстами хуже: аудит не вошёл в тройку. Переформулировка «technical audit of an existing app» уже находит его. Маленькая многоязычная модель понимает другие языки, но слабее, чем родной язык корпуса, — это повод проверять поиск на реальных вопросах пользователей, а не на удобных примерах.
Индексы HNSW и IVFFlat
Без индекса pgvector выполняет точный поиск: считает расстояние до каждой строки и сортирует. Это гарантирует идеальную полноту, но время растёт линейно с таблицей. Приближённые индексы жертвуют частью полноты ради скорости — документация pgvector предупреждает, что после их создания результаты запросов могут измениться.
Двенадцать строк индекс не ускорит — планировщик выберет последовательное чтение. Поэтому для замеров строим стенд на 20 000 векторов размерности 384, сгенерированных облаками вокруг случайных центров, и считаем recall@10: какую долю из десяти точных соседей возвращает запрос через индекс.
-- Стенд: 20 000 векторов той же размерности, что у модели.
-- Настоящие эмбеддинги образуют смысловые «облака», поэтому векторы
-- генерируем вокруг 200 случайных центров, а не равномерно.
SELECT setseed(0.42);
CREATE FUNCTION rnd_vec(scale float8) RETURNS vector LANGUAGE sql VOLATILE AS
$$ SELECT array_agg((random() - 0.5) * scale)::vector FROM generate_series(1, 384) $$;
CREATE TABLE centers AS SELECT k, rnd_vec(1) AS c FROM generate_series(0, 199) AS k;
CREATE TABLE bench (id int PRIMARY KEY, embedding vector(384));
INSERT INTO bench
SELECT i, c + rnd_vec(1.4) FROM generate_series(1, 20000) AS i JOIN centers ON k = i % 200;
-- 50 запросов и «правильные ответы» — точный поиск, пока индекса нет
CREATE TABLE queries AS
SELECT q, (c + rnd_vec(1.4))::vector(384) AS v FROM generate_series(1, 50) AS q JOIN centers ON k = q * 3;
CREATE TABLE truth AS
SELECT q.q, b.id
FROM queries q
CROSS JOIN LATERAL (SELECT id FROM bench ORDER BY embedding <=> q.v LIMIT 10) b;
ANALYZE bench;
EXPLAIN (COSTS OFF)
SELECT id FROM bench ORDER BY embedding <=> (SELECT v FROM queries WHERE q = 1) LIMIT 10;
\timing on
CREATE INDEX bench_hnsw ON bench USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
\timing off
EXPLAIN (COSTS OFF)
SELECT id FROM bench ORDER BY embedding <=> (SELECT v FROM queries WHERE q = 1) LIMIT 10;
-- recall@10: какая доля точных соседей нашлась через индекс
CREATE FUNCTION recall_at_10() RETURNS numeric LANGUAGE sql AS $$
SELECT round(count(*)::numeric / (SELECT count(*) FROM truth), 3)
FROM queries q
CROSS JOIN LATERAL (SELECT id FROM bench ORDER BY embedding <=> q.v LIMIT 10) a
JOIN truth t ON t.q = q.q AND t.id = a.id
$$;
SET hnsw.ef_search = 10; SELECT 'ef_search=10' AS setting, recall_at_10();
SET hnsw.ef_search = 40; SELECT 'ef_search=40' AS setting, recall_at_10();
SET hnsw.ef_search = 200; SELECT 'ef_search=200' AS setting, recall_at_10();
SELECT pg_size_pretty(pg_relation_size('bench')) AS table_size,
pg_size_pretty(pg_relation_size('bench_hnsw')) AS hnsw_size;DROP INDEX bench_hnsw;
-- lists ≈ rows / 1000 для таблиц до миллиона строк (рекомендация pgvector)
\timing on
CREATE INDEX bench_ivf ON bench USING ivfflat (embedding vector_cosine_ops) WITH (lists = 20);
\timing off
SET ivfflat.probes = 1; SELECT 'probes=1' AS setting, recall_at_10();
SET ivfflat.probes = 4; SELECT 'probes=4' AS setting, recall_at_10();
SELECT pg_size_pretty(pg_relation_size('bench_ivf')) AS ivfflat_size;$ docker exec -i kb-pgvector-emb psql -U postgres < sql/03-index.sql
Limit
-> Sort
Sort Key: ((bench.embedding <=> (InitPlan 1).col1))
-> Seq Scan on bench
CREATE INDEX
Time: 1698.993 ms (00:01.699)
Limit
-> Index Scan using bench_hnsw on bench
Order By: (embedding <=> (InitPlan 1).col1)
ef_search=10 | 0.910
ef_search=40 | 1.000
ef_search=200 | 1.000
table_size | hnsw_size
31 MB | 39 MB
$ docker exec -i kb-pgvector-emb psql -U postgres < sql/04-ivfflat.sql
CREATE INDEX
Time: 234.909 ms
probes=1 | 0.916
probes=4 | 0.962
ivfflat_size
31 MBПлан подтверждает главное: с индексом вместо Seq Scan и Sort появляется Index Scan с Order By по расстоянию. Индекс используется, только если в запросе есть ORDER BY по оператору расстояния и LIMIT; условие вида WHERE embedding <=> $1 < 0.3 без сортировки индекс не задействует.
m
- Где задаётся
- HNSW, при создании
- По умолчанию
- 16
- Что меняет
- Число связей на слой графа: больше — выше полнота, больше памяти
ef_construction
- Где задаётся
- HNSW, при создании
- По умолчанию
- 64
- Что меняет
- Ширина поиска при построении: больше — лучше граф, дольше сборка
hnsw.ef_search
- Где задаётся
- HNSW,
SETперед запросом - По умолчанию
- 40
- Что меняет
- Сколько кандидатов просматривать при поиске: больше — выше полнота, медленнее
lists
- Где задаётся
- IVFFlat, при создании
- По умолчанию
- —
- Что меняет
- Число кластеров; старт — rows / 1000 до миллиона строк, √rows выше
ivfflat.probes
- Где задаётся
- IVFFlat,
SETперед запросом - По умолчанию
- 1
- Что меняет
- Сколько кластеров проверять; старт — √lists
HNSW
- Многослойный граф, лучше соотношение скорость/полнота
- Можно создать на пустой таблице
- Строится медленнее и занимает больше памяти (у нас 39 МБ при таблице 31 МБ)
IVFFlat
- Кластеры k-means, строится быстрее и меньше по размеру
- Создавать после загрузки данных — кластеры учатся на них
- Созданный на почти пустой таблице, отдаёт меньше строк — документация советует удалить его до накопления данных
- Создавайте индекс после первичной загрузки — так быстрее; на рабочей базе —
CREATE INDEX CONCURRENTLY, чтобы не блокировать запись - Сборка HNSW заметно быстрее, когда граф помещается в
maintenance_work_mem - Сортируйте по самому оператору расстояния по возрастанию:
ORDER BY embedding <=> $1индекс использует, аORDER BY 1 - (embedding <=> $1) DESC— нет - Полноту в продакшене проверяют так же, как здесь: сравнивают ответ индекса с точным поиском при
SET LOCAL enable_indexscan = off
Гибридный поиск: смысл плюс ключевые слова
Векторный поиск хорошо ловит перефразировки, но может недооценить точное совпадение: название продукта, артикул, «1С», «Excel». Полнотекстовый поиск PostgreSQL, наоборот, точен по словам и не понимает синонимов. pgvector рекомендует объединять их, а результаты сливать методом Reciprocal Rank Fusion (RRF): каждый документ получает сумму 1 / (k + место) по двум спискам, где k = 60, как в примере из репозитория pgvector-node.
import pgvector from "pgvector/pg";
import { pool } from "./db.ts";
import { embed } from "./embed.ts";
const question = process.argv[2] ?? "Kaspi";
const [q] = await embed([question], "query");
// Reciprocal Rank Fusion: складываем 1 / (k + место) из двух списков.
// k = 60 сглаживает разницу между первыми местами.
const { rows } = await pool.query(
`WITH semantic AS (
SELECT id, rank() OVER (ORDER BY embedding <=> $2) AS r
FROM services
ORDER BY embedding <=> $2
LIMIT 20
),
keyword AS (
SELECT id, rank() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS r
FROM services, websearch_to_tsquery('russian', $1) AS query
WHERE tsv @@ query
ORDER BY ts_rank_cd(tsv, query) DESC
LIMIT 20
)
SELECT s.slug, semantic.r AS semantic_rank, keyword.r AS keyword_rank,
round((coalesce(1.0 / ($3 + semantic.r), 0)
+ coalesce(1.0 / ($3 + keyword.r), 0))::numeric, 4) AS score
FROM semantic
FULL JOIN keyword USING (id)
JOIN services s USING (id)
ORDER BY score DESC
LIMIT 5`,
[question, pgvector.toSql(q), 60],
);
console.log(`«${question}»`);
console.table(rows);
await pool.end();$ node src/hybrid.ts "импорт из Excel"
┌─────────┬───────────────────────┬───────────────┬──────────────┬──────────┐
│ (index) │ slug │ semantic_rank │ keyword_rank │ score │
├─────────┼───────────────────────┼───────────────┼──────────────┼──────────┤
│ 0 │ 'crm-development' │ '4' │ '1' │ '0.0320' │
│ 1 │ 'integrations' │ '1' │ null │ '0.0164' │
│ 2 │ 'optimization' │ '2' │ null │ '0.0161' │
│ 3 │ 'saas-development' │ '3' │ null │ '0.0159' │
│ 4 │ 'business-automation' │ '5' │ null │ '0.0154' │
└─────────┴───────────────────────┴───────────────┴──────────────┴──────────┘В описании CRM действительно сказано про импорт клиентов и сделок из Excel, но модель сочла ближе общие тексты про интеграции. Ключевое слово вытянуло правильный документ. websearch_to_tsquery выбран намеренно: по документации PostgreSQL он принимает произвольный пользовательский ввод и никогда не выдаёт синтаксическую ошибку. Конфигурация 'russian' приводит слова к основе, и «импорт» находит «импортировать».
Нарезка, частые ошибки и когда нужна отдельная векторная БД
Наш корпус — короткие абзацы, поэтому каждый документ — один вектор. Реальные документы длиннее предела модели (у E5 — 512 токенов, остальное отбрасывается), а один вектор на длинную страницу размывает смысл. Документы режут на фрагменты (chunks) по разделам или абзацам и хранят рядом метаданные: источник, заголовок раздела, дату, язык, права доступа. Подробно нарезка разбирается в уроке о RAG.
- Документы и запросы закодированы одной моделью, название модели хранится рядом с вектором
- Для E5 добавлены префиксы
query:/passage:, для Voyage —input_type - Метрика запроса совпадает с классом операторов индекса (
<=>↔vector_cosine_ops) - В запросе есть
ORDER BYпо расстоянию иLIMIT— иначе индекс не используется - Фильтры по метаданным проверены на полноту; при необходимости включён iterative scan
- Ключи облачных провайдеров лежат в переменных окружения на сервере
- Полнота индекса измерена на своих данных, а не принята на веру
Когда хватает PostgreSQL. По нашему опыту, если векторы — дополнение к обычным данным (товары, статьи, заявки), pgvector удобнее отдельного сервиса: те же транзакции, JOIN, права, бэкапы и репликация. Для экономии памяти pgvector предлагает тип halfvec и бинарное квантование с переранжированием.
Когда смотреть на отдельную векторную БД — это инженерный компромисс, а не правило: если векторный поиск стал основной нагрузкой, индекс не помещается в память одного сервера, нужна горизонтальная масштабируемость именно поиска или встроенные функции вроде готового гибридного поиска с переранжированием. Сравнение подходов к хранению — в материале о том, когда нужен NoSQL.
Упражнения
Следующий шаг — урок о RAG: на этом же стенде мы нарежем FAQ, найдём фрагменты под вопрос, соберём их в промпт и измерим качество поиска на наборе контрольных вопросов.
Как проверено6 · пройдено1 · частичносверено 12 октября 2026 г.
- запущено, работает
src/embed.ts, src/cosine.ts (Transformers.js, Xenova/multilingual-e5-small q8)
tsc 7.0.2 --noEmit + node 24.18 во временном проекте; модель скачана и запущена, вывод в статье — реальный. Найдена и устранена ошибка Windows MAX_PATH (кеш внутри node_modules) переносом env.cacheDir в ./.models
- запущено, работает
sql/01-schema.sql, src/db.ts, src/seed.ts, src/search.ts, src/search-ip.ts (решение упражнения)
docker pgvector/pgvector:pg17 (PostgreSQL 17.11, pgvector 0.8.7), контейнер kb-pgvector-emb --rm; pg 8.23.1, pgvector 0.3.0; чистый прогон с нуля, вывод в статье — реальный
- запущено, работает
sql/03-index.sql, sql/04-ivfflat.sql (HNSW, IVFFlat, recall@10)
выполнено через psql в том же контейнере; EXPLAIN подтверждает Index Scan; recall и время — один прогон, между прогонами меняются (случайность построения), в статье это оговорено
- запущено, работает
src/hybrid.ts (RRF + websearch_to_tsquery('russian'))
node 24 против контейнера; вывод «импорт из Excel» в статье — реальный
- запущено, работает
sql/05-exercise-filter.sql (решение упражнения 3)
psql: без iterative_scan в среднем 4,46 строки из 10, со strict_order — 10
- запущено, работает
EN-вариант кода (переведены только комментарии и подписи)
отдельная копия en/: tsc + seed/search/cosine/hybrid против того же контейнера
- частично
src/cloud.ts (OpenAI SDK embeddings, Voyage через fetch)
tsc + node-тест с локальным мок-сервером (openai 7.32.0, OPENAI_BASE_URL/VOYAGE_BASE_URL): проверены путь, заголовок Authorization, тело запроса, разбор ответа и порядок по index. Живые вызовы OpenAI и Voyage — NOT_RUN (нет ключей)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.