RAG держится на одной операции: по вектору-вопросу найти ближайшие векторы-куски. На десятке документов это можно сделать перебором. На миллионах — уже нет: нужен специальный движок, который хранит эмбеддинги и быстро ищет среди них похожие. Это и есть векторная база данных.

20 векторов в базе, ромб — вектор-запрос точный перебор: сравнить с каждым приближённый поиск: пройти по графу сравнили все 20 векторовближайший найден: расстояние 28 сравнили 5 векторов из 20 вернули 31, а ближайший был 28 пропущен цена вопроса — сколько сравнений точный перебор 20 сравнений приближённый поиск 5 сравнений

Точный перебор сравнивает запрос с каждым вектором: ответ всегда верный, но счёт сравнений равен размеру базы. Приближённый поиск (его сокращают до ANN) идёт по рёбрам графа от вектора к более близкому, трогает малую часть базы — и останавливается там, где все соседи дальше. Здесь он вернул вектор на расстоянии 31, а настоящий ближайший (28) лежал в другой ветке графа. Это не ошибка в коде, а плата за скорость — и размер платы вы задаёте сами, настройкой того, сколько кандидатов держать при обходе.

Обязательно

Что она делает

Обычная база ищет по точному совпадению или диапазону: «где id = 42», «цена меньше 1000». Векторная база отвечает на другой вопрос: «какие записи ближе всего к этому вектору по смыслу». Она хранит эмбеддинги и умеет по запросу-вектору быстро вернуть k ближайших. Всё остальное (метаданные, тексты кусков) обычно лежит рядом, чтобы вместе с найденным вектором вернуть и сам текст, и ссылку на источник.

Приближённый поиск ближайших соседей

Точно найти ближайшие векторы среди миллионов — дорого: пришлось бы сравнить запрос с каждым. Поэтому векторные базы используют приближённый поиск ближайших соседей (ANN, approximate nearest neighbors): специальные индексы находят почти самых близких, обходясь десятками сравнений вместо миллионов. Самый популярный — HNSW, граф связей между похожими векторами, и слоёв в нём несколько: верхние редкие, по ним обход прыгает далеко и за несколько шагов попадает в нужную область, нижние плотные, по ним доходят до точных соседей. Отсюда и скорость: вместо миллиона сравнений — короткий спуск по слоям. Насколько это быстрее перебора — зависит от размера базы, длины векторов и от того, какой точности вы требуете: на обычных замерах по миллиону векторов выигрыш выходит в десятки и сотни раз при том, что до выдачи доходит 95-99% настоящих ближайших.

Вот эти проценты — главное, что надо унести из раздела. Промахи не «изредка случаются», они есть всегда: часть настоящих соседей поиск не увидит, потому что они лежат в другой ветке графа, — ровно как на схеме выше. Долю дошедших называют полнотой (recall), и это не свойство базы, которое приходится терпеть, а ручка. У HNSW она про то, сколько кандидатов держать при обходе графа (в pgvector это hnsw.ef_search, по умолчанию 40), у индексов другого устройства — про то, сколько частей базы просмотреть (probes у IVFFlat). Крутите вверх — точнее и медленнее, вниз — быстрее и грубее. Мерить полноту тоже надо, а не чувствовать: берут набор запросов, считают для них честный ответ полным перебором и смотрят, какая доля дошла до выдачи.

Ещё одна возможность, которую все ждут, — фильтрация по метаданным: «найди похожее по смыслу, но только среди документов этого пользователя и только за последний год». Звучит буднично, а на деле это самое больное место векторного поиска. Отфильтровать после поиска — значит из десяти найденных оставить два, потому что остальные оказались чужими. Отфильтровать до поиска — значит выбросить из графа большую часть вершин, а по обрывкам обход работает плохо. Базы решают это по-разному, и на своих данных результат надо проверять: в pgvector, например, HNSW с условием WHERE долго возвращал меньше строк, чем просили, и дотягивать выдачу повторными проходами по индексу он научился только в версии 0.8.0.

pgvector или выделенная база

Главная развилка при выборе — держать вектора рядом с остальными данными или заводить под них отдельную систему. Критерий простой: объём и требования к задержке.

pgvector — расширение PostgreSQL. Оно добавляет обычному Postgres тип «вектор» и поиск ближайших, причём индексы там те же, что у выделенных баз: тот самый HNSW и более простой IVFFlat. Огромный плюс: вектора лежат рядом с вашими обычными данными, в той же базе, под теми же транзакциями и бэкапами. Не нужно поднимать и синхронизировать отдельную систему. Для большинства проектов, где эмбеддингов от тысяч до нескольких миллионов, этого достаточно — и это разумный выбор по умолчанию. О самом PostgreSQL — в разделе про него.

Об один потолок легко удариться в первый же день, поэтому скажем заранее: индекс pgvector строит по векторам не длиннее 2000 чисел. А популярные модели эмбеддингов выдают больше — у крупной модели OpenAI это 3072. Такой вектор в таблице полежит, а вот обычный индекс по нему не построится, и поиск уйдёт в полный перебор. Выходы есть: тип halfvec, где каждое число хранится вдвое короче и индексируются вектора до 4000, уменьшение длины вектора на стороне самой модели или просто другая модель. Про эти пределы лучше узнать до того, как зальёте миллион кусков.

Посчитайте объём — решение принимается по нему

«От тысяч до нескольких миллионов» — расплывчато, а решение упирается в конкретную арифметику, и она простая. Вектор из 1536 чисел по четыре байта — это 6 килобайт. Отсюда:

ВекторовТолько векторыС индексом и накладными
100 тысяч0,6 ГБоколо 1 ГБ
1 миллион6 ГБ8–10 ГБ
10 миллионов60 ГБ80–100 ГБ

Главное в этой таблице — не сами цифры, а то, что граф индекса хочет лежать в памяти. Поиск по HNSW — это обход графа с прыжками по случайным местам; если данные не в памяти, каждый прыжок превращается в чтение с диска, и «десятки миллисекунд» становятся «секундами». Поэтому практический порог звучит так: если векторы с индексом не влезают в память сервера базы (точнее, в её буферный кеш) — поиск станет на порядок медленнее, и тут либо больше памяти, либо сжатие, либо выделенная база.

Отсюда и прикидка выбора. Сто тысяч кусков (это, например, вся документация среднего продукта) — гигабайт, любой сервер, pgvector без раздумий. Миллион — десяток гигабайт, всё ещё pgvector, но память сервера теперь имеет значение, и её считают. Десять миллионов — сотня гигабайт, и вот здесь начинается настоящий разговор: либо сжатие векторов, либо отдельная система, спроектированная под то, чтобы держать это на нескольких машинах.

Два множителя, которые меняют картину сильнее всего: размерность вектора (модель с 768 измерениями вместо 1536 сразу делит объём вдвое, часто почти без потери качества на своих данных) и число кусков (нарезка по 500 токенов вместо 200 уменьшает число векторов в 2,5 раза). То есть объём — это не данность, а следствие двух ваших решений, принятых на этапе нарезки и выбора модели.

Сжатие векторов: когда память кончилась

Главный рычаг, когда таблица перестаёт влезать, — хранить векторы короче. Тут три ступени, и они есть и в pgvector, и в выделенных базах.

Половинная точность. Каждое число вместо четырёх байт занимает два: объём делится вдвое, качество поиска почти не меняется (потеря обычно в пределах процента полноты). В pgvector это тип halfvec, и у него есть второй бонус — индексируются векторы до 4000 измерений вместо 2000. Это первое, что стоит попробовать, и часто этого достаточно.

Скалярное квантование. Число сжимают до одного байта: объём в четыре раза меньше, потеря полноты обычно единицы процентов. Применяется либо самой базой прозрачно, либо вами при записи.

Бинарное квантование. Каждое число превращается в один бит: в тридцать два раза меньше, и поиск становится очень быстрым (сравнение битовых строк дешевле арифметики). Потеря качества заметная, поэтому работает это в паре с переоценкой: по сжатым векторам находят несколько сотен кандидатов, а затем пересчитывают их близость по настоящим векторам, которые лежат рядом на диске и читаются точечно. В pgvector это bit и binary_quantize, в выделенных базах — встроенный режим. Схема «грубо и много, потом точно и мало» — стандартный ответ на «база не влезает в память», и она же объясняет, почему при этом скорость не падает.

Порядок применения ровно такой: сначала посмотреть, не уменьшить ли размерность у самой модели; потом половинная точность; потом квантование с переоценкой; и только потом отдельная система. И проверять каждый шаг замером полноты на своих запросах — цифра «потеря в пределах процента» относится к чужим данным, а не к вашим.

Выделенные векторные базы (Qdrant, Weaviate, Milvus, Pinecone и другие) — специализированные системы, заточенные только под векторный поиск. Их берут, когда векторов десятки-сотни миллионов и больше, нужен экстремальный масштаб, тонкая настройка индексов или векторный поиск — центральная нагрузка сервиса. За это платят отдельной системой в эксплуатации.

Третий вариант, который встречается чаще обоих. Развилка «Postgres или векторная база» пропускает случай, который в реальных стеках самый частый: поисковый движок уже стоит. Elasticsearch и OpenSearch давно умеют векторный поиск (тот же HNSW) наряду с полнотекстовым, и это важно по двум причинам.

Первая: если движок уже есть в стеке под обычный поиск, добавление векторного поля — это изменение схемы, а не новая система в эксплуатации. Вторая, и более весомая: статья сама рекомендует гибрид, а в движке гибрид собирается одним запросом — есть встроенное объединение результатов полнотекстового и векторного поиска. В Postgres то же самое приходится собирать руками: два запроса, два списка, слияние по рангам в коде или в SQL.

Как выбирать между тремя вариантами, по главному признаку:

  • Векторы — дополнение к вашим данным, гибрид не нужен или простой. Берите pgvector: одна база, одни транзакции, одни копии, соединение с обычными таблицами в том же запросе.
  • Полнотекстовый поиск уже живёт в движке, и нужен настоящий гибрид. Берите движок: вы получаете и словарные возможности (морфология, синонимы, опечатки), и векторы, и объединение из коробки.
  • Векторный поиск — основная нагрузка, объёмы десятки миллионов и больше, нужна тонкая настройка. Берите выделенную базу.

Правило то же, что и с любым специализированным хранилищем: начинают с pgvector (данные и так в Postgres), а на выделенную базу переходят, когда масштаб или требования это оправдывают — а не «на будущее». С поправкой на третий вариант формулировка звучит так: начинают с того, что уже стоит в стеке, а новую систему заводят под названную причину.

где держать векторы pgvector дополнение к данным поисковый движок нужен гибрид выделенная база основная нагрузка

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

Грабли

  • метрика близости. «Ближе» считают по-разному: косинус, евклидово расстояние, скалярное произведение — и порядок выдачи у них выходит разный. Брать надо ту метрику, под которую модель эмбеддингов обучали. Отдельная тонкость pgvector: оператор в запросе (<=>, <->, <#>) обязан совпадать с тем, под который построен индекс, — иначе индекс просто не применится и запрос молча уползёт в полный перебор.
  • модель эмбеддингов. Вектора от одной модели нельзя искать среди векторов от другой — они несопоставимы. Сменили модель эмбеддингов — надо переиндексировать всё.
  • стоимость индексации. Перевести миллионы кусков в эмбеддинги — это миллионы вызовов модели; это заметная разовая (и при обновлениях — регулярная) статья расходов.
  • не только вектор. Часто лучший результат даёт гибрид: семантический поиск плюс обычный по ключевым словам — они дополняют друг друга.

Как собрать гибрид руками

«Объединяют два списка» — то место, где инженер застревает, потому что списки приходят с несопоставимыми шкалами: полнотекстовый поиск отдаёт свою оценку релевантности (у неё нет верхней границы и нет смысла в абсолютном значении), векторный — расстояние от нуля до двух. Сложить их напрямую нельзя.

Рабочий способ — слияние по рангам: забыть про оценки и использовать только места в списках. Каждому документу начисляют 1 / (k + место) по каждому списку, где k — сглаживающая константа (берут около 60), и суммируют. Документ, оказавшийся высоко в обоих списках, поднимается наверх; попавший только в один — остаётся, но ниже.

WITH semantic AS (
    SELECT id, row_number() OVER (ORDER BY embedding <=> :query_vec) AS rank
    FROM chunks
    ORDER BY embedding <=> :query_vec
    LIMIT 50
),
lexical AS (
    SELECT id, row_number() OVER (
               ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('russian', :q)) DESC) AS rank
    FROM chunks
    WHERE tsv @@ websearch_to_tsquery('russian', :q)
    LIMIT 50
)
SELECT c.id, c.text,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + l.rank), 0) AS score
FROM chunks c
LEFT JOIN semantic s ON s.id = c.id
LEFT JOIN lexical  l ON l.id = c.id
WHERE s.id IS NOT NULL OR l.id IS NOT NULL
ORDER BY score DESC
LIMIT 10;

Что здесь важно по частям. Оба списка ограничены пятьюдесятью — брать больше смысла мало, а стоимость растёт. Внешние соединения обязательны: документ, найденный только одним способом, не должен выпадать (именно ради этого гибрид и нужен). Константа 60 сглаживает разницу между первым и вторым местом — без неё первое место получает несоразмерный вес. И порядок сортировки у расстояния — по возрастанию (<=> — это расстояние, меньше значит ближе), а у полнотекстовой оценки — по убыванию; перепутать их легко и обнаружить трудно.

Альтернатива — взвешенная сумма нормированных оценок: каждую оценку приводят к диапазону от нуля до единицы внутри выдачи, затем складывают с весами (например, 0,7 на векторный и 0,3 на словарный). Точнее при хорошо подобранных весах, но веса надо подбирать на наборе с разметкой и пересматривать при смене модели; слияние по рангам работает без настройки, поэтому начинают с него.

Чего в гибриде не хватает без третьего шага. Объединение даёт хороший набор кандидатов, но порядок внутри него всё равно приблизительный. Верхние двадцать-пятьдесят переранжируют кросс-энкодером — разбор в статье про RAG. Замеряют все четыре варианта (словарный, векторный, гибрид, гибрид с переранжированием) на одном наборе вопросов: обычно каждый шаг даёт заметный прирост, и видно, где остановиться.

Жизнь векторов: загрузка, обновление, удаление

Статья описывает, как база ищет, и молчит о том, как векторы в неё попадают и что бывает при изменениях. А это и есть основная эксплуатационная работа.

Первая загрузка — пакетом, а не по одному. Миллион кусков вставлять построчно бессмысленно: это миллион обращений к базе. Вставляют пачками по несколько сотен, в одной транзакции на пачку. И главное правило порядка: индекс строят после загрузки, а не до. Вставка в существующий граф HNSW дороже вставки в таблицу без индекса в несколько раз, и на большом объёме разница превращается из минут в часы.

Построение индекса на большой таблице блокирует. Обычный CREATE INDEX держит блокировку, при которой нельзя писать в таблицу. На миллионе векторов построение — это минуты, на десяти — десятки минут, и всё это время фича не может индексировать новые документы. Поэтому на живой базе строят так же, как любой другой индекс в PostgreSQL:

CREATE INDEX CONCURRENTLY idx_chunks_embedding
    ON chunks USING hnsw (embedding vector_cosine_ops);

Это дольше и требует второго проход по таблице, зато не запирает запись. Полезно знать и две настройки, которые ускоряют построение в разы: память, доступная на построение индекса, и число параллельных рабочих процессов — их поднимают на время операции, а не навсегда.

Обновление куска — удалить и вставить. Вектор нельзя «поправить»: при правке текста меняется и он сам, и, скорее всего, нарезка. Порядок такой же, как для индекса базы знаний: удалить все куски документа по его идентификатору, нарезать заново, вставить пачкой. Обновлять вектор на месте (UPDATE колонки) технически можно, но только когда текст того же куска перегенерирован той же нарезкой — случай редкий.

Массовые удаления портят граф. Вот деталь, о которой узнают поздно: в HNSW удаление не убирает вершину из графа сразу — она помечается и продолжает участвовать в обходе как «мёртвая». После удаления заметной доли данных обход начинает тратить время на мёртвые вершины, полнота падает, а скорость проседает, хотя данных стало меньше. Признаки: поиск замедлился без роста корпуса, полнота упала, размер индекса не уменьшился. Лечится перестроением индекса (REINDEX INDEX CONCURRENTLY), и это повод не делать «удалить всё и залить заново» регулярной операцией. Если полная переиндексация корпуса нужна по расписанию, правильнее строить новую таблицу и переключать на неё, а не вычищать старую.

Уборка мусора — обычная забота PostgreSQL. Векторы — это большие строки, и обновления оставляют мёртвые версии, как в любой таблице. На таблице с частыми переиндексациями стоит проверить, успевает ли автоматическая уборка, и при необходимости настроить её агрессивнее для этой таблицы — разбор в статье про уборку и распухание.

Сколько стоит первая загрузка. Не забудьте про вторую половину счёта: миллион кусков — это миллион вызовов модели эмбеддингов. У модели по сети это отдельные деньги (порядок — десятки долларов за миллион кусков у дешёвых моделей) и время, ограниченное потолком запросов в минуту. У модели, поднятой у себя, — часы работы машины. Это разовая статья при первой загрузке и регулярная при каждой смене модели эмбеддингов, из-за которой такая смена и стоит дорого.

Где это применяется

Векторная база — обязательный компонент любого RAG-приложения: база знаний, поиск по документам, рекомендации похожего, дедупликация и кластеризация по смыслу. Везде, где вопрос звучит как «найди похожее по смыслу, а не по точному совпадению».

Дополнительно: при первом чтении можно пропустить

Глубже: pgvector из приложения: колонка, индекс и запросрасширенное

«Начинают с pgvector» выше не показывает, как это выглядит в сервисе на Java, где доступ к базе идёт через jOOQ. Три шага.

Схема. Миграция включает расширение и заводит колонку с размерностью модели эмбеддингов и индекс под ту же метрику, что и запрос:

CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE chunk ADD COLUMN embedding vector(1536);
CREATE INDEX chunk_embedding_hnsw ON chunk USING hnsw (embedding vector_cosine_ops);

Размерность в колонке фиксирована, и смена модели эмбеддингов это новая колонка и переиндексация, о чём раздел про грабли. Индекс HNSW строится минуты на миллионах строк и занимает память; его создают после массовой загрузки, а не до.

Тип в jOOQ. Кодогенератор не знает тип vector и отдаёт его как строку; ему объявляют принудительный тип с привязкой (forcedTypes с Binding), которая преобразует float[] в текстовое представление [0.1,0.2,...] при записи и обратно при чтении. После этого колонка в сгенерированных классах типизирована, а вставка эмбеддинга это обычный insertInto с массивом.

Запрос. Оператор близости у jOOQ не встроен, и его пишут полем-выражением; всё остальное, фильтры по метаданным, арендатору и дате, лимит, это обычный построитель:

Field<Double> distance = DSL.field("{0} <=> {1}", Double.class,
        CHUNK.EMBEDDING, DSL.val(queryEmbedding, CHUNK.EMBEDDING.getDataType()));
dsl.select(CHUNK.ID, CHUNK.TEXT, distance.as("distance"))
   .from(CHUNK)
   .where(CHUNK.TENANT_ID.eq(tenantId)).and(CHUNK.DELETED.isFalse())
   .orderBy(distance)
   .limit(20)
   .fetch();

Оператор в запросе (<=> для косинуса) обязан совпадать с классом операторов индекса, иначе индекс не используется, и EXPLAIN покажет полный перебор. Фильтр по арендатору в WHERE работает, но у HNSW есть особенность: индекс сначала находит ближайшие, потом применяет фильтр, и при жёстком фильтре может вернуть меньше строк, чем просили; для этого поднимают hnsw.ef_search в сессии или используют итеративное сканирование в новых версиях pgvector. Гибридный поиск по словам добавляется вторым запросом к тому же chunk через tsvector, а объединение списков делают в коде, о чём статья про RAG.

Spring AI умеет всё это своим PgVectorStore без jOOQ, и это нормальный выбор для прототипа; в сервисе, где jOOQ единственный слой доступа, вектор проходит через него же, чтобы права, транзакции и тесты были одни на всю базу.

«Начинают с pgvector» выше не показывает, как это выглядит в сервисе на Go, где доступ к базе идёт через pgx. Три шага.

Схема. Миграция включает расширение и заводит колонку с размерностью модели эмбеддингов и индекс под ту же метрику, что и запрос:

CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE chunk ADD COLUMN embedding vector(1536);
CREATE INDEX chunk_embedding_hnsw ON chunk USING hnsw (embedding vector_cosine_ops);

Размерность в колонке фиксирована, и смена модели эмбеддингов это новая колонка и переиндексация, о чём раздел про грабли. Индекс HNSW строится минуты на миллионах строк и занимает память; его создают после массовой загрузки, а не до.

Тип в pgx. Драйвер не знает тип vector, и без регистрации колонка приезжает строкой. Пакет github.com/pgvector/pgvector-go даёт тип pgvector.Vector и кодек для pgx в подпакете pgx: pgxvec.RegisterTypes(ctx, conn) вызывают в AfterConnect конфигурации пула, после чего []float32 пишется и читается как вектор, а вставка эмбеддинга это обычный Exec с параметром pgvector.NewVector(vec).

Запрос. Оператор близости пишут прямо в SQL, вектор уходит параметром; фильтры по метаданным, арендатору и дате, лимит это обычный запрос:

rows, err := pool.Query(ctx, `
    SELECT id, text, embedding <=> $1 AS distance
    FROM chunk
    WHERE tenant_id = $2 AND NOT deleted
    ORDER BY distance
    LIMIT 20`, pgvector.NewVector(queryEmbedding), tenantID)

Оператор в запросе (<=> для косинуса) обязан совпадать с классом операторов индекса, иначе индекс не используется, и EXPLAIN покажет полный перебор. Фильтр по арендатору в WHERE работает, но у HNSW есть особенность: индекс сначала находит ближайшие, потом применяет фильтр, и при жёстком фильтре может вернуть меньше строк, чем просили; для этого поднимают hnsw.ef_search в сессии или используют итеративное сканирование в новых версиях pgvector. Гибридный поиск по словам добавляется вторым запросом к тому же chunk через tsvector, а объединение списков делают в коде, о чём статья про RAG.

langchaingo умеет то же своим хранилищем vectorstores/pgvector поверх pgx, и это нормальный выбор для прототипа; в сервисе, где запросы к базе идут через свои репозитории, вектор проходит через них же, чтобы права, транзакции и тесты были одни на всю базу.

«Начинают с pgvector» выше не показывает, как это выглядит в сервисе на Node, где доступ к базе идёт через node-postgres (pg). Три шага.

Схема. Миграция включает расширение и заводит колонку с размерностью модели эмбеддингов и индекс под ту же метрику, что и запрос:

CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE chunk ADD COLUMN embedding vector(1536);
CREATE INDEX chunk_embedding_hnsw ON chunk USING hnsw (embedding vector_cosine_ops);

Размерность в колонке фиксирована, и смена модели эмбеддингов это новая колонка и переиндексация, о чём раздел про грабли. Индекс HNSW строится минуты на миллионах строк и занимает память; его создают после массовой загрузки, а не до.

Тип в драйвере. pg не знает тип vector и отдаёт колонку строкой. Пакет pgvector регистрирует разбор на соединении: new pg.Pool({ onConnect: (client) => pgvector.registerTypes(client) }) с import pgvector from 'pgvector/pg', а параметр-вектор оборачивают в pgvector.toSql(embedding); вставка эмбеддинга после этого обычный query с параметром.

Запрос. Оператор близости пишут прямо в SQL, вектор уходит параметром; фильтры, арендатор, лимит это обычный запрос:

const { rows } = await pool.query(
  `SELECT id, text, embedding <=> $1 AS distance
     FROM chunk
    WHERE tenant_id = $2 AND NOT deleted
    ORDER BY distance
    LIMIT 20`,
  [pgvector.toSql(queryEmbedding), tenantId],
);

Оператор в запросе (<=> для косинуса) обязан совпадать с классом операторов индекса, иначе индекс не используется, и EXPLAIN покажет полный перебор. Фильтр по арендатору в WHERE работает, но у HNSW есть особенность: индекс сначала находит ближайшие, потом применяет фильтр, и при жёстком фильтре может вернуть меньше строк, чем просили; для этого поднимают hnsw.ef_search в сессии или используют итеративное сканирование в новых версиях pgvector. Гибридный поиск по словам добавляется вторым запросом к тому же chunk через tsvector, а объединение списков делают в коде, о чём статья про RAG.

LangChain.js умеет то же своим PGVectorStore из @langchain/community, и это нормальный выбор для прототипа; в сервисе, где запросы к базе идут через свои репозитории, вектор проходит через них же, чтобы права, транзакции и тесты были одни на всю базу.

«Начинают с pgvector» выше не показывает, как это выглядит в сервисе на Python, где доступ к базе идёт через psycopg 3 или SQLAlchemy. Три шага.

Схема. Миграция включает расширение и заводит колонку с размерностью модели эмбеддингов и индекс под ту же метрику, что и запрос:

CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE chunk ADD COLUMN embedding vector(1536);
CREATE INDEX chunk_embedding_hnsw ON chunk USING hnsw (embedding vector_cosine_ops);

Размерность в колонке фиксирована, и смена модели эмбеддингов это новая колонка и переиндексация, о чём раздел про грабли. Индекс HNSW строится минуты на миллионах строк и занимает память; его создают после массовой загрузки, а не до.

Тип в драйвере. psycopg не знает тип vector и отдаёт колонку строкой. Пакет pgvector регистрирует адаптер на соединении (from pgvector.psycopg import register_vector; register_vector(conn), для пула через configure), после чего массив NumPy или Vector(list) пишется и читается как вектор; для SQLAlchemy тот же пакет даёт тип колонки Vector(1536) и методы cosine_distance у столбца.

Запрос. Оператор близости пишут прямо в SQL, вектор уходит параметром; фильтры, арендатор, лимит это обычный запрос:

rows = conn.execute(
    """
    SELECT id, text, embedding <=> %s AS distance
      FROM chunk
     WHERE tenant_id = %s AND NOT deleted
     ORDER BY distance
     LIMIT 20
    """,
    (Vector(query_embedding), tenant_id),
).fetchall()

Оператор в запросе (<=> для косинуса) обязан совпадать с классом операторов индекса, иначе индекс не используется, и EXPLAIN покажет полный перебор. Фильтр по арендатору в WHERE работает, но у HNSW есть особенность: индекс сначала находит ближайшие, потом применяет фильтр, и при жёстком фильтре может вернуть меньше строк, чем просили; для этого поднимают hnsw.ef_search в сессии или используют итеративное сканирование в новых версиях pgvector. Гибридный поиск по словам добавляется вторым запросом к тому же chunk через tsvector, а объединение списков делают в коде, о чём статья про RAG.

LangChain умеет то же своим PGVector из langchain_postgres, и это нормальный выбор для прототипа; в сервисе, где запросы к базе идут через свои репозитории, вектор проходит через них же, чтобы права, транзакции и тесты были одни на всю базу.

Коротко

  • Векторная база хранит эмбеддинги и быстро находит ближайшие по смыслу — фундамент RAG.
  • Масштаб держит приближённый поиск ближайших соседей (ANN, индекс HNSW): он трогает малую часть базы и выигрывает у перебора десятки-сотни раз. Часть настоящих ближайших при этом теряется всегда — долю дошедших (полноту) измеряют и настраивают ручкой обхода, платя за точность временем.
  • pgvector (расширение Postgres) — выбор по умолчанию: тот же HNSW, вектора рядом с данными, без отдельной системы; потолок — индекс по векторам до 2000 чисел, дальше halfvec или модель покороче. Выделенные базы — под очень большой масштаб.
  • Грабли: метрика близости должна совпадать у модели, запроса и индекса; вектора от разных моделей несовместимы (при смене — переиндексация); фильтры по метаданным даются векторному поиску тяжело; стоимость индексации; польза гибрида с поиском по словам.
  • Из приложения: колонка vector(N) и индекс HNSW под метрику запроса, тип вектора зарегистрирован у драйвера (привязка в jOOQ, pgvector-go для pgx, pgvector/pg для node-postgres, pgvector.psycopg), оператор близости в самом запросе, фильтры и лимит обычные; оператор совпадает с классом индекса.
  • Объём считается просто: вектор из 1536 чисел — 6 КБ, миллион — около десяти гигабайт с индексом, и граф должен лежать в памяти, иначе поиск замедляется на порядок.
  • Когда память кончилась: половинная точность вдвое, скалярное квантование вчетверо, бинарное в тридцать два раза с переоценкой по настоящим векторам; каждый шаг проверяют замером полноты.
  • Третий вариант помимо pgvector и выделенной базы — поисковый движок, который уже стоит в стеке и умеет гибрид одним запросом.
  • Гибрид собирают слиянием по рангам (1 / (60 + место) по каждому списку с внешними соединениями), а не сложением несопоставимых оценок; верх переранжируют кросс-энкодером.
  • Эксплуатация: грузить пачками и строить индекс после загрузки, на живой базе — CONCURRENTLY; правка куска это удалить и вставить; массовые удаления портят граф HNSW и требуют перестроения.

Дальше — агентные приложения: когда модель не просто отвечает, а сама решает, какие инструменты вызвать, чтобы выполнить задачу.

Что пощупать

Самый маленький вариант векторного хранилища — список в памяти и перебор всех векторов на каждый вопрос — лежит в практикуме курса, remodov/marketplace-system, на четырёх языках. На нём видно, где проходит граница, за которой нужна настоящая база: перебор миллиона векторов по 256 чисел это сотни миллионов операций на КАЖДЫЙ вопрос.

Код: Java — assistant; Python — knowledge.py и embeddings.py; Go — knowledge.go и embeddings.go; Node — knowledge.mjs и embeddings.mjs.

Что почитать дальше