Как делать12 октября 2026 г.9 мин чтения
AI-интеграцииШаг 5 из 8

Эмбеддинги и векторный поиск на pgvector

Коротко

Эмбеддинг — это вектор из сотен чисел, который модель строит по тексту так, что близкие по смыслу тексты получают близкие векторы. Векторы можно хранить прямо в PostgreSQL с расширением pgvector и искать ближайшие по косинусному расстоянию оператором <=>, а для больших таблиц добавить приближённый индекс HNSW или IVFFlat. Эмбеддинги можно получать локальной моделью без ключей или через облачные API — главное, кодировать документы и запросы одной и той же моделью.

Пошаговый урокУровень: СреднийЭмбеддинги и векторные базыПрактика: ~1,5–3 ч

После материала вы сможете

  • получить эмбеддинги текстов локальной моделью через @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 г.

Содержание
  1. 01Эмбеддинг: текст как точка в пространстве смыслов
  2. 02Расстояния: косинус, скалярное произведение, L2
  3. 03Откуда брать эмбеддинги: локальная модель или API
  4. 04pgvector в Docker: таблица, загрузка и первый поиск
  5. 05Индексы HNSW и IVFFlat
  6. 06Гибридный поиск: смысл плюс ключевые слова
  7. 07Нарезка, частые ошибки и когда нужна отдельная векторная БД
  8. 08Упражнения

Эмбеддинг: текст как точка в пространстве смыслов

Обычный поиск по ключевым словам находит документ, только если в нём встречается слово из запроса. Клиент пишет «нужно приложение для телефона», а в описании услуги стоит «мобильные приложения для iOS и Android» — общих слов почти нет, и LIKE или полнотекстовый поиск промахнутся. Векторный поиск сравнивает не слова, а смысл.

Модель эмбеддингов (embedding model — нейросеть, которая превращает текст в вектор) читает текст и возвращает массив чисел фиксированной длины. В этом уроке это многоязычная модель multilingual-e5-small: 384 числа на любой текст до 512 токенов, длиннее — обрезается. По карточке модели она поддерживает около ста языков, включая русский и английский. Сами числа ничего не значат по отдельности; важно взаимное расположение векторов: тексты о похожем оказываются рядом.

Близость двух векторов измеряют косинусом угла между ними: скалярное произведение, делённое на произведение длин. Совпадающие направления дают 1, перпендикулярные — 0. Посчитаем косинус вручную на трёх фразах — без базы данных, только модель и арифметика:

src/cosine.ts
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)}`);
  }
}
Косинусная близость вручную; модуль embed.ts — в следующем разделе
terminal
$ 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
Реальный вывод. Фразы 1 и 2 (магазин и сайт с оплатой) ближе друг к другу, чем к фразе про резервное копирование

Расстояния: косинус, скалярное произведение, L2

pgvector поддерживает несколько функций расстояния, у каждой свой оператор и свой класс операторов для индекса. Расстояние — чем меньше, тем ближе, поэтому сортировка всегда ORDER BY … ASC.

<=>

Что считает
Косинусное расстояние = 1 − косинусная близость
Класс операторов для индекса
vector_cosine_ops

<#>

Что считает
Скалярное произведение со знаком минус (индексы умеют только ASC)
Класс операторов для индекса
vector_ip_ops

<->

Что считает
Евклидово расстояние (L2)
Класс операторов для индекса
vector_l2_ops

<+>

Что считает
Манхэттенское расстояние (L1)
Класс операторов для индекса
vector_l1_ops
Операторы расстояния pgvector для типа vector

Если векторы нормализованы до длины 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; на наших запросах обе версии ставили на первое место один и тот же документ.

src/embed.ts
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. Ниже — обёртки для обоих; живые запросы мы не отправляли, код проверен на локальном мок-сервере, который принимает те же запросы.

src/cloud.ts
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, интеграции, аудит и так далее.

bash
# 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 "нужно приложение для телефона"
Запуск стенда. В PowerShell вместо < используйте Get-Content sql/01-schema.sql | docker exec -i …, а переменную задайте как $env:DATABASE_URL="…". Node.js 24 выполняет .ts-файлы напрямую
data/services.json
[
  {"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."},
  …
]
Начало корпуса: slug, заголовок и текст
sql/01-schema.sql
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.

src/db.ts
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);
  },
});
Пул соединений с регистрацией типа vector
src/seed.ts
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();
Загрузка: эмбеддинги документов одним батчем и upsert по slug
src/search.ts
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();
Поиск трёх ближайших документов к вопросу
terminal
$ 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: какую долю из десяти точных соседей возвращает запрос через индекс.

sql/03-index.sql
-- Стенд: 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;
Стенд, план до и после индекса HNSW и recall@10 при разных ef_search
sql/04-ivfflat.sql
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;
Тот же замер для IVFFlat
terminal
$ 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
Вывод сокращён. Синтетические данные, один прогон на нашей машине: время и recall меняются от запуска к запуску, потому что граф HNSW и кластеры IVFFlat строятся со случайностью

План подтверждает главное: с индексом вместо 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
Параметры индексов по документации pgvector 0.8

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.

src/hybrid.ts
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();
Гибридный поиск: два ранжированных списка и слияние RRF в одном SQL-запросе
terminal
$ 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 на четвёртое место, полнотекстовый поиск — на первое, после слияния CRM наверху

В описании 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.