~/ / Статьи об ИИ / Локальные LLM на VPS / Хранение и поиск эмбеддингов Ollama в PostgreSQL с помощью pgvector
Хранение и поиск эмбеддингов Ollama в PostgreSQL с помощью pgvector
Эмбеддинги из /api/embed в Ollama полезны для поиска только после того, как их где-то сохранили. pgvector — расширение с открытым исходным кодом, которое добавляет в PostgreSQL поиск векторов по сходству, так что векторы могут лежать в той же базе данных, на том же сервере, что и тексты с остальными вашими данными. В статье рассказано об установке, создании таблицы, выборе расстояния и индекса и о настройках, которые меняют результаты; за основу взято README pgvector для версии 0.8.7. Затем мы передаём результаты поиска в /api/chat в Ollama, чтобы ответ называл свои источники, и проверяем эти ссылки. Мы выполнили SQL на pgvector 0.8.7 с PostgreSQL 16, используя настоящие векторы из Ollama, а запросы к чату — на Ollama 0.40.11 с qwen2.5:7b. Четыре инструмента на этой странице пишут SQL и запрос и проверяют ответ прямо в вашем браузере.
Опубликовано: · 9 мин чтения · Факты сверены с README и исходным кодом pgvector версии 0.8.7 и с документацией и исходным кодом Ollama (0.40.11) 2026-11-05. Прежде чем на них полагаться, проверьте актуальную документацию.
Что pgvector добавляет в PostgreSQL
README pgvector описывает его как поиск векторов по сходству с открытым исходным кодом для Postgres. Он поддерживает точный и приближённый поиск ближайших соседей; векторы с одинарной точностью, половинной точностью, бинарные и разреженные; а также расстояние L2, скалярное произведение, косинусное расстояние, расстояние L1, расстояние Хэмминга и расстояние Жаккара. Поскольку векторы — обычные столбцы PostgreSQL, на них распространяются и соответствие ACID, восстановление на момент времени (point-in-time recovery) и JOIN.
Чтобы искать по документам, вы сохраняете по одной строке на фрагмент — с его текстом и вектором, создаёте эмбеддинг вопроса той же моделью и сортируете строки по расстоянию до вектора вопроса.
Установка
pgvector поддерживает PostgreSQL 13 и новее. README перечисляет несколько способов установки, в том числе такие:
| Способ | Что говорит README |
|---|---|
| Сборка из исходников | git clone --branch v0.8.7, затем make и make install (может понадобиться sudo) |
| Docker | Образ pgvector/pgvector добавляет pgvector к образу Postgres. Теги вроде pg18-trixie или 0.8.7-pg16 выбирают версию Postgres. |
| APT (Debian, Ubuntu) | sudo apt install postgresql-18-pgvector из репозитория PostgreSQL APT, где 18 заменяется вашей версией Postgres |
| Homebrew | brew install pgvector, что добавляет его только в формулы postgresql@18 и postgresql@17 |
Затем один раз включите расширение в каждой базе данных, где хотите его использовать, командой CREATE EXTENSION vector;. Для этой статьи мы использовали образ Docker pgvector/pgvector:0.8.7-pg16.
Создание таблицы
Столбец типа vector(n) хранит векторы ровно с n измерениями, и n должно совпадать с длиной векторов вашей модели эмбеддингов. Вектор другой длины отклоняется: когда мы вставили вектор с 2 измерениями в столбец vector(3), PostgreSQL вернул "expected 3 dimensions, not 2". all-minilm возвращает 384 измерения, поэтому его столбец — vector(384).
README указывает, что каждый вектор занимает 4 × измерения + 8 байт, причём каждый элемент — число с плавающей запятой одинарной точности, и допускает до 16,000 измерений в столбце. Индекс поддерживает меньше измерений, как показано ниже. Тип halfvec хранит значения половинной точности в 2 × измерения + 8 байт.
| Измерения | vector | halfvec |
|---|---|---|
| 384 | 1,544 байт | 776 байт |
| 768 | 3,080 байт | 1,544 байт |
| 1024 | 4,104 байт | 2,056 байт |
В нашей тестовой таблице pg_column_size дала 1,544 байт для vector(384) и 776 байт для halfvec(384), что совпадает с формулой. Это только сам вектор; текстовый столбец и индексы занимают дополнительное место.
Загрузка векторов из Ollama
pgvector читает вектор как текст в квадратных скобках, например '[1,2,3]', а README говорит, что все элементы должны быть конечными (без NaN, Infinity и -Infinity). Каждый массив в поле embeddings ответа /api/embed уже имеет такой вид, а ответ сохраняет порядок входных данных, поэтому первому тексту соответствует первый вектор.
README показывает INSERT для нескольких строк, INSERT … ON CONFLICT для upsert и COPY для массовой загрузки. Конвертер ниже сопоставляет ваши тексты с ответом и пишет операторы INSERT с экранированными кавычками. Мы выполнили его результат с текстами, в которых были кавычки, обратные косые черты, переводы строк и эмодзи, и они вернулись без изменений.
Попробовать: Embeddings to pgvector INSERT
В примере использованы выдуманные векторы длиной 1 с 4 измерениями, чтобы их можно было прочитать. У настоящих эмбеддингов сотни или тысячи измерений.
3 строк с 4 измерениями. Тексты прочитаны как тело запроса /api/embed, векторы — как ответ Ollama /api/embed.
Операторы INSERT
Выполните их в psql или другом клиенте. Строки вставляются в одной транзакции.
BEGIN;
INSERT INTO documents (content, embedding) VALUES
('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');
COMMIT;Формат вектора из README pgvector, проверен на pgvector 0.8.7 2026-11-04. pgvector README
Всё происходит в вашем браузере. Инструмент пишет SQL и ни к чему не подключается.
Выбор расстояния
Документация Ollama рекомендует косинусное сходство для большинства случаев семантического поиска и говорит, что /api/embed возвращает векторы длиной 1. Для таких векторов косинусное расстояние, расстояние L2 и скалярное произведение упорядочивают строки одинаково, потому что квадрат расстояния L2 равен 2 минус 2, умноженное на косинусное сходство. В нашем тесте с all-minilm первые три строки были одинаковыми для всех трёх мер, а расстояние L2 0.931 соответствовало косинусному сходству 0.566.
У каждого расстояния свой оператор. Поиск сортирует по оператору и берёт первые строки с помощью LIMIT, а индекс строится для одного класса операторов.
| Оператор | Возвращает | Класс операторов индекса |
|---|---|---|
| <=> | Косинусное расстояние. Косинусное сходство равно 1 - (a <=> b). | vector_cosine_ops |
| <-> | Расстояние L2 (евклидово) | vector_l2_ops |
| <#> | Отрицательное скалярное произведение. Умножьте на -1, чтобы получить скалярное произведение. | vector_ip_ops |
| <+> | Расстояние L1 | vector_l1_ops |
README объясняет, что <#> возвращает отрицательное скалярное произведение, потому что Postgres поддерживает для операторов только сканирование индекса в порядке ASC. Для halfvec классы операторов называются halfvec_cosine_ops, halfvec_l2_ops и так далее.
Точный поиск или индекс
Без индекса pgvector выполняет точный поиск ближайших соседей, который, по словам README, обеспечивает идеальную полноту (recall). Приближённый индекс обменивает часть полноты на скорость, и README предупреждает, что после добавления индекса запросы могут возвращать другие результаты.
Индекс используется только тогда, когда в запросе есть ORDER BY и LIMIT, а ORDER BY — это сам оператор расстояния по возрастанию. ORDER BY embedding <=> $1 LIMIT 5 может использовать индекс; ORDER BY 1 - (embedding <=> $1) DESC — не может. README также отмечает, что полное сканирование таблицы может быть быстрее, если таблица небольшая.
| Параметр | HNSW | IVFFlat |
|---|---|---|
| Как работает | Многослойный граф | Делит векторы на списки и просматривает списки, ближайшие к запросу |
| По сравнению с другим | Лучший компромисс между скоростью и полнотой, но строится медленнее и использует больше памяти | Строится быстрее и использует меньше памяти, но компромисс между скоростью и полнотой хуже |
| Когда создавать | Можно создать на пустой таблице (нет этапа обучения) | После того как в таблице появились данные |
| Параметры построения | m (по умолчанию 16) и ef_construction (по умолчанию 64) | lists: начните с rows / 1000 для таблиц до 1M строк и с sqrt(rows) для больших |
| Параметр поиска | hnsw.ef_search, по умолчанию 40 | ivfflat.probes, по умолчанию 1; начните с sqrt(lists) |
| Измерения | vector до 2,000, halfvec до 4,000 | vector до 2,000, halfvec до 4,000 |
На pgvector 0.8.7 мы также подтвердили, что у столбца vector(2001) не может быть индекса HNSW, что у IVFFlat нет класса операторов для расстояния L1 и что ef_construction должно быть не меньше 2 × m. Для большего числа измерений FAQ в README называет halfvec, бинарное квантование и другие варианты.
Попробовать: pgvector SQL Builder
Каждый вектор занимает 1,544 байт, поэтому 10,000 строк занимают около 15.4 MB только под векторы (индексы и другие столбцы не учтены).
1. Расширение и таблица
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE documents ( id bigserial PRIMARY KEY, content text NOT NULL, embedding vector(384) NOT NULL );
2. Индекс
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
3. Поиск
PREPARE принимает вектор вопроса из /api/embed как $1. Передавайте его как текст, например '[0.01,-0.02,…]'.
PREPARE search_documents(vector(384)) AS
SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
-- EXECUTE search_documents('[0.0123, -0.0456, ...]');Ограничения и значения по умолчанию из README и исходного кода pgvector 0.8.7, проверено 2026-11-04. pgvector README
Всё происходит в вашем браузере. Инструмент пишет SQL и ни к чему не подключается.
Почему поиск может вернуть меньше строк, чем задано в LIMIT
В HNSW результаты ограничены параметром hnsw.ef_search, по умолчанию 40. На тестовой таблице из 200 строк с индексом HNSW запрос с LIMIT 100 вернул 40 строк; после SET hnsw.ef_search = 100 он вернул 100. Задайте hnsw.ef_search не меньше числа нужных вам строк. SET LOCAL внутри транзакции меняет значение только для одного запроса.
Фильтрация тоже уменьшает результаты. При приближённых индексах, по словам README, условие WHERE применяется после сканирования индекса: если условию соответствует 10% строк, HNSW со значением hnsw.ef_search по умолчанию 40 в среднем вернёт только 4 строки. Начиная с версии 0.8.0 итеративное сканирование индекса (SET hnsw.iterative_scan = strict_order или relaxed_order) просматривает индекс дальше, пока не найдётся достаточно строк, но не больше hnsw.max_scan_tuples (по умолчанию 20,000).
README добавляет, что векторы NULL не индексируются, как и нулевые векторы при косинусном расстоянии.
Передайте результаты в /api/chat с пронумерованными источниками
Когда поиск вернул ближайшие строки, модели нужно передать их в промпте. /api/chat в Ollama принимает список сообщений, у каждого есть роль (system, user, assistant или tool) и содержимое. Простая схема — системное сообщение с правилами и сообщение пользователя, в котором перечислены строки, каждая со своим номером, например [1] и [2], а за ними вопрос. С номерами ответ может указать, какими строками он воспользовался, а каждый номер можно сопоставить обратно со строкой.
Чтобы ссылки на источники имели постоянную форму, передайте в поле format схему JSON Schema с ответом и списком ссылок, у каждой из которых есть номер источника и короткая цитата. Страница Ollama о структурированном выводе рекомендует также записать схему в промпте и снизить температуру (temperature), например до 0, чтобы вывод был более детерминированным. В psql функция json_agg собирает строки запроса в массив JSON, который инструмент ниже читает напрямую.
Попробовать: RAG Prompt Builder
Примеры источников написаны вручную в том виде, в каком их возвращает json_agg.
Источников: 3. Распознан формат: массив JSON из объектов.
Команда curl
Отправляет запрос в Ollama на этом компьютере (localhost:11434).
curl http://localhost:11434/api/chat -d '{
"model": "qwen2.5:7b",
"messages": [
{
"role": "system",
"content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
},
{
"role": "user",
"content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "integer"
},
"quote": {
"type": "string"
}
},
"required": [
"source",
"quote"
]
}
}
},
"required": [
"answer",
"citations"
]
},
"options": {
"temperature": 0
},
"truncate": false
}'Тело запроса (JSON)
{
"model": "qwen2.5:7b",
"messages": [
{
"role": "system",
"content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
},
{
"role": "user",
"content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "integer"
},
"quote": {
"type": "string"
}
},
"required": [
"source",
"quote"
]
}
}
},
"required": [
"answer",
"citations"
]
},
"options": {
"temperature": 0
},
"truncate": false
}В ответе сервера ответ модели находится в message.content. Если prompt_eval_count намного меньше ожидаемого, промпт, возможно, был обрезан.
Поля запроса — по справочнику API и исходному коду Ollama, проверено на Ollama 0.40.11 2026-11-05. Ollama API: /api/chat
Всё выполняется в вашем браузере. Инструмент пишет запрос и ни к чему не подключается.
Если источники не помещаются, Ollama обрезает начало промпта
Правила, строки и вопрос должны поместиться в длину контекста. Страница Ollama о длине контекста указывает по умолчанию 4k токенов при менее чем 24 GiB VRAM, а FAQ — 4096 токенов; ollama ps на нашей тестовой машине показала 4096. Изменить её можно через num_ctx в options или через переменную окружения OLLAMA_CONTEXT_LENGTH.
Если промпт длиннее, /api/chat по умолчанию не возвращает ошибку. В исходном коде Ollama (0.40.11) сервер сначала отбрасывает целиком более ранние сообщения, сохраняя системные сообщения и последнее сообщение. Если оставшийся промпт всё равно слишком длинный, он сохраняет несколько первых токенов (num_keep, по умолчанию 4) и конец промпта, а середину отбрасывает. В нашем тесте с qwen2.5:7b и num_ctx 1024 промпт из 2,068 токенов был обрезан до 514. Системное сообщение и первые источники были потеряны: когда ответ был в источнике [1], модель ответила кодовым словом, которого не было ни в одном источнике, а когда ответ был в последнем источнике, она ответила верно, но без номера источника, который требовало системное сообщение. В ответе не было ошибки; в журнале сервера было предупреждение "truncating input prompt".
В исходном коде Ollama у запросов к чату есть и поле truncate, которого нет на странице /api/chat. С "truncate": false тот же запрос вернул HTTP 400 с ошибкой "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it". Поле prompt_eval_count в ответе — это число токенов в промпте; в тесте с обрезкой оно равнялось 514, а не 2,068.
Поскольку конец промпта сохраняется, ставьте вопрос после источников. Чтобы узнать, что источники не помещаются, отправьте "truncate": false, а затем увеличьте num_ctx или отправьте меньше строк. Больший num_ctx требует больше памяти.
Проверьте ссылки на источники
Ссылку на источник можно проверить механически: номер должен указывать на отправленный источник, а цитата должна встречаться в этом источнике. В нашем тесте с четырьмя источниками и qwen2.5:7b цитаты в ответах JSON совпадали с источниками слово в слово. Это не доказывает, что ответ верен: на вопрос о размере вектора с 384 измерениями модель процитировала верное правило (4 умножить на число измерений плюс 8 байт) и всё равно ответила 1552 байта вместо 1,544.
Смотрите и на ответы без ссылок. Когда в источниках ответа не было, модель один раз ответила из собственных знаний без ссылок, а один раз сказала, что не знает, но всё равно сослалась на посторонний источник. Если done_reason в ответе равен "length", ответ был обрезан из-за num_predict, и в нём могут отсутствовать ссылки.
Проверка ниже сравнивает только текст. Действительно ли источник подтверждает предложение, по-прежнему должен решать человек.
Попробовать: RAG Citation Checker
Пример ответа написан вручную, чтобы показать цитату, которой нет в её источнике.
Источников: 4 (массив JSON из строк). Ответ распознан как: ответ /api/chat в Ollama; вид: JSON со ссылками.
Токенов в промпте (prompt_eval_count): 306
- Цитаты из ссылки #2 нет в источнике [4].
Ссылки
| Источник | Цитата | Результат |
|---|---|---|
| [2] | hnsw.ef_search is 40 by default | Найдена дословно |
| [4] | 4 bytes per dimension plus 8 | Нет в этом источнике |
Проверяется только то, что номера и цитаты совпадают с источниками. Инструмент не может определить, верен ли ответ и действительно ли источник подтверждает предложение.
Формат ответа — по справочнику API и исходному коду Ollama, проверено на Ollama 0.40.11 2026-11-05. Ollama API: /api/chat
Всё выполняется в вашем браузере. Инструмент ни к чему не подключается.
Память на небольшом сервере
README говорит, что индекс HNSW строится заметно быстрее, когда граф помещается в maintenance_work_mem, выводит уведомление, когда он перестаёт помещаться, и предупреждает, что не следует задавать maintenance_work_mem настолько большим, чтобы исчерпать память сервера. Как и в случае других индексов, быстрее создавать индекс после загрузки начальных данных. Индексы не обязаны помещаться в память, но, скорее всего, будут работать лучше, если помещаются; SELECT pg_size_pretty(pg_relation_size('index_name')) показывает размер индекса.
Для меньшего рабочего набора и меньших индексов README предлагает halfvec, индексирование с половинной точностью или бинарное квантование. Если Ollama работает на том же сервере, PostgreSQL и модели делят одну и ту же память; наша статья о наблюдении за памятью Ollama объясняет, от чего зависит, сколько её используют модели.
Источники
- pgvector README (v0.8.7)
- pgvector source: HNSW limits (src/hnsw.h)
- pgvector source: IVFFlat limits (src/ivfflat.h)
- Ollama: Embeddings
- Ollama API: Generate embeddings (/api/embed)
- PostgreSQL documentation: Lexical Structure (string constants)
- Ollama API: Generate a chat message (/api/chat)
- Ollama: Structured Outputs
- Ollama: Context length
- Ollama FAQ (context window size)
- Ollama source v0.40.11: server/prompt.go (dropping earlier messages)
- Ollama source v0.40.11: llm/llama_server.go (truncating the prompt)
- Ollama source v0.40.11: api/types.go (truncate, num_keep)
- PostgreSQL documentation: Aggregate Functions (json_agg)
Читайте дальше
- Эмбеддинги в Ollama: основы поиска и RAGСоздание эмбеддингов через /api/embed в Ollama: модели и их размеры, truncate и dimensions, векторы единичной длины и косинусное сходство, по официальной документации.
- Следим за памятью Ollama на небольшом VPSЧто Ollama держит в памяти, как долго и какие настройки это меняют: ollama ps, keep_alive и лимиты по официальному FAQ.
- Подключаем Dify к Ollama на собственном VPSЗапустите Dify на своём VPS и используйте Ollama как модель: установка, поля плагина, проблема адреса в Docker и эмбеддинги — по официальной документации.
- Локальная LLM без дорогого ПК: руководство для начинающих по работе через VPSНет мощного ПК? Арендуйте VPS и запустите небольшую локальную LLM из браузера: шесть шагов от оценки памяти до защищённого экрана чата, с честным описанием ограничений.