RAG держится на одной операции: по вектору-вопросу найти ближайшие векторы-куски. На десятке документов это можно сделать перебором. На миллионах — уже нет: нужен специальный движок, который хранит эмбеддинги и быстро ищет среди них похожие. Это и есть векторная база данных.
Точный перебор сравнивает запрос с каждым вектором: ответ всегда верный, но счёт сравнений равен размеру базы. Приближённый поиск (его сокращают до 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: оператор в запросе (
<=>,<->,<#>) обязан совпадать с тем, под который построен индекс, — иначе индекс просто не применится и запрос молча уползёт в полный перебор. - модель эмбеддингов. Вектора от одной модели нельзя искать среди векторов от другой — они несопоставимы. Сменили модель эмбеддингов — надо переиндексировать всё.
- стоимость индексации. Перевести миллионы кусков в эмбеддинги — это миллионы вызовов модели; это заметная разовая (и при обновлениях — регулярная) статья расходов.
- не только вектор. Часто лучший результат даёт гибрид: семантический поиск плюс обычный по ключевым словам — они дополняют друг друга.
Как собрать гибрид руками
«Объединяют два списка» — то место, где инженер застревает, потому что списки приходят с несопоставимыми шкалами: полнотекстовый поиск отдаёт свою оценку релевантности (у неё нет верхней границы и нет смысла в абсолютном значении), векторный — расстояние от нуля до двух. Сложить их напрямую нельзя.
Рабочий способ — слияние по рангам: забыть про оценки и использовать только места в списках. Каждому документу начисляют 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.
Что почитать дальше
- RAG и эмбеддинги — как поиск по векторам встраивается в ответ модели.
- Индексы PostgreSQL по типам — что общего у HNSW с остальными индексами базы.
- LangChain и те же блоки на других языках — Spring AI, LangChain4j и langchaingo поверх той же базы.