~/ / AI articles / Local LLMs on a VPS / Store and search Ollama embeddings in PostgreSQL with pgvector

Store and search Ollama embeddings in PostgreSQL with pgvector

Embeddings from Ollama's /api/embed are only useful for search once they are stored somewhere. pgvector is an open-source extension that adds vector similarity search to PostgreSQL, so the vectors can live in the same database as the texts and the rest of your data, on the same server. This article covers installing it, creating the table, choosing the distance and the index, and the settings that change the results, using the pgvector README for version 0.8.7. It then passes the search results to Ollama's /api/chat so that the answer names its sources, and checks those citations. We ran the SQL on pgvector 0.8.7 with PostgreSQL 16 using real vectors from Ollama, and the chat requests on Ollama 0.40.11 with qwen2.5:7b. Four tools on this page write the SQL and the request and check the answer in your browser.

Published: · 10 min read · Facts checked against the pgvector README and source code for version 0.8.7 and against Ollama's documentation and source code (0.40.11) on 2026-11-05. Check the current documentation before you rely on them.

What pgvector adds to PostgreSQL

The pgvector README describes it as open-source vector similarity search for Postgres. It supports exact and approximate nearest neighbor search; single-precision, half-precision, binary and sparse vectors; and L2 distance, inner product, cosine distance, L1 distance, Hamming distance and Jaccard distance. Because the vectors are ordinary PostgreSQL columns, they also get ACID compliance, point-in-time recovery and JOINs.

For a search over your documents, you store one row per chunk with its text and its vector, embed the question with the same model, and sort the rows by their distance to the question's vector.

Install it

pgvector supports PostgreSQL 13 and later. The README lists several ways to install it, including these:

MethodWhat the README says
Build from sourcegit clone --branch v0.8.7, then make and make install (may need sudo)
DockerThe pgvector/pgvector image adds pgvector to the Postgres image. Tags such as pg18-trixie or 0.8.7-pg16 choose the Postgres version.
APT (Debian, Ubuntu)sudo apt install postgresql-18-pgvector from the PostgreSQL APT Repository, with 18 replaced by your Postgres version
Homebrewbrew install pgvector, which only adds it to the postgresql@18 and postgresql@17 formulas

Then enable it once in each database where you want to use it, with CREATE EXTENSION vector;. For this article we used the Docker image pgvector/pgvector:0.8.7-pg16.

Create the table

A column of type vector(n) holds vectors with exactly n dimensions, and n must be the length of the vectors from your embedding model. A vector with a different length is rejected: when we inserted a vector with 2 dimensions into a vector(3) column, PostgreSQL returned "expected 3 dimensions, not 2". all-minilm returns 384 dimensions, so its column is vector(384).

The README gives the storage of each vector as 4 × dimensions + 8 bytes, with every element a single-precision floating-point number, and allows up to 16,000 dimensions in a column. An index supports fewer dimensions, as shown below. The halfvec type stores half-precision values in 2 × dimensions + 8 bytes.

Dimensionsvectorhalfvec
3841,544 bytes776 bytes
7683,080 bytes1,544 bytes
10244,104 bytes2,056 bytes

On our test table, pg_column_size gave 1,544 bytes for vector(384) and 776 bytes for halfvec(384), matching the formula. This is the vector only; the text column and the indexes take additional space.

Load the vectors from Ollama

pgvector reads a vector as text in square brackets, such as '[1,2,3]', and the README says all elements must be finite (no NaN, Infinity or -Infinity). Each array in the embeddings field of an /api/embed response already has this form, and the response keeps the order of the input, so the first text goes with the first vector.

The README shows INSERT for a few rows, INSERT … ON CONFLICT for upserts, and COPY for bulk loading. The converter below pairs your texts with the response and writes INSERT statements with the quotes escaped. We ran its output with texts containing quotes, backslashes, line breaks and emoji, and they came back unchanged.

Try it: Embeddings to pgvector INSERT

The example uses made-up, unit-length vectors with 4 dimensions so that you can read them. Real embeddings have hundreds or thousands of dimensions.

3 rows with 4 dimensions. Texts read as the /api/embed request body, vectors as an Ollama /api/embed response.

INSERT statements

Run it in psql or another client. The rows are inserted in one transaction.

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;

Vector format from the pgvector README, checked against pgvector 0.8.7 on 2026-11-04. pgvector README

Everything is done in your browser. The tool writes SQL and connects to nothing.

Choose the distance

Ollama's documentation recommends cosine similarity for most semantic search use cases and says /api/embed returns vectors with a length of 1. For such vectors, cosine distance, L2 distance and inner product put the rows in the same order, because the squared L2 distance is 2 minus 2 times the cosine similarity. In our test with all-minilm, the top three rows were the same with all three, and the L2 distance of 0.931 matched the cosine similarity of 0.566.

Each distance has its own operator. A search sorts by the operator and takes the first rows with LIMIT, and an index is built for one operator class.

OperatorReturnsIndex operator class
<=>Cosine distance. Cosine similarity is 1 - (a <=> b).vector_cosine_ops
<->L2 (Euclidean) distancevector_l2_ops
<#>The negative inner product. Multiply by -1 for the inner product.vector_ip_ops
<+>L1 distancevector_l1_ops

The README explains that <#> returns the negative inner product because Postgres only supports ASC order index scans on operators. For halfvec, the operator classes are halfvec_cosine_ops, halfvec_l2_ops and so on.

Exact search or an index

Without an index, pgvector does an exact nearest neighbor search, which the README says provides perfect recall. An approximate index trades some recall for speed, and the README warns that queries can return different results after you add one.

An index is only used when the query has ORDER BY and LIMIT and the ORDER BY is the distance operator itself in ascending order. ORDER BY embedding <=> $1 LIMIT 5 can use the index; ORDER BY 1 - (embedding <=> $1) DESC cannot. The README also notes that a table scan may be faster when the table is small.

PointHNSWIVFFlat
How it worksA multilayer graphDivides the vectors into lists and searches the lists closest to the query
Compared with the otherBetter speed-recall tradeoff, but slower to build and uses more memoryFaster to build and uses less memory, but a lower speed-recall tradeoff
When to create itCan be created on an empty table (no training step)After the table has some data
Build optionsm (16 by default) and ef_construction (64 by default)lists: start with rows / 1000 for up to 1M rows, and sqrt(rows) above that
Search optionhnsw.ef_search, 40 by defaultivfflat.probes, 1 by default; start with sqrt(lists)
Dimensionsvector up to 2,000, halfvec up to 4,000vector up to 2,000, halfvec up to 4,000

On pgvector 0.8.7 we also confirmed that a vector(2001) column cannot have an HNSW index, that IVFFlat has no operator class for L1 distance, and that ef_construction must be at least 2 × m. For more dimensions, the README's FAQ lists halfvec, binary quantization and other options.

Try it: pgvector SQL Builder

Each vector takes 1,544 bytes, so 10,000 rows take about 15.4 MB for the vectors alone (indexes and other columns not included).

1. Extension and table

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(384) NOT NULL
);

2. Index

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

3. Search

PREPARE takes the vector of the question from /api/embed as $1. Pass it as text, such as '[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, ...]');

Limits and defaults from the pgvector 0.8.7 README and source code, checked on 2026-11-04. pgvector README

Everything is done in your browser. The tool writes SQL and connects to nothing.

Why a search can return fewer rows than LIMIT

With HNSW, the results are limited by hnsw.ef_search, which is 40 by default. On a test table of 200 rows with an HNSW index, LIMIT 100 returned 40 rows; after SET hnsw.ef_search = 100, it returned 100. Set hnsw.ef_search to at least the number of rows you need. SET LOCAL inside a transaction changes it for a single query.

Filtering also reduces the results. With approximate indexes, the README says the WHERE condition is applied after the index is scanned: if a condition matches 10% of rows, HNSW with the default hnsw.ef_search of 40 returns only 4 rows on average. Since version 0.8.0, iterative index scans (SET hnsw.iterative_scan = strict_order, or relaxed_order) scan more of the index until enough rows are found, up to hnsw.max_scan_tuples (20,000 by default).

The README adds that NULL vectors are not indexed, and neither are zero vectors for cosine distance.

Pass the results to /api/chat with numbered sources

Once the search returns the closest rows, the model needs them in its prompt. Ollama's /api/chat takes a list of messages, each with a role (system, user, assistant or tool) and its content. A simple layout is a system message with the rules and a user message that lists the rows with a number each, such as [1] and [2], followed by the question. With numbers, the answer can say which rows it used, and you can map each number back to its row.

To get the citations in a fixed shape, give the format field a JSON Schema with the answer and a list of citations, each with the source number and a short quote. Ollama's structured outputs page recommends also writing the schema in the prompt, and lowering the temperature, for example to 0, for more deterministic output. In psql, the json_agg function collects the rows of a query into a JSON array, which the tool below reads directly.

Try it: RAG Prompt Builder

The example sources are written by hand in the shape that json_agg returns.

3 sources, read as a JSON array of objects.

curl command

Sends the request to Ollama on this machine (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
}'

Request body (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
}

In the response, the answer is in message.content. If prompt_eval_count is much lower than you expect, the prompt may have been cut.

Request fields from Ollama's API reference and source code, checked on Ollama 0.40.11 on 2026-11-05. Ollama API: /api/chat

Everything is done in your browser. The tool writes the request and connects to nothing.

When the sources do not fit, Ollama cuts the start of the prompt

The rules, the rows and the question all have to fit into the context length. Ollama's context length page gives a default of 4k tokens with less than 24 GiB of VRAM, and its FAQ gives 4096 tokens; ollama ps on our test machine showed 4096. num_ctx in options or the OLLAMA_CONTEXT_LENGTH environment variable changes it.

When the prompt is longer, /api/chat does not return an error by default. In Ollama's source code (0.40.11), the server first drops whole earlier messages while keeping the system messages and the last message. If the remaining prompt is still too long, it keeps the first few tokens (num_keep, 4 by default) and the end of the prompt and discards the part in between. In our test with qwen2.5:7b and num_ctx 1024, a prompt of 2,068 tokens was cut to 514. The system message and the first sources were lost: with the answer in source [1], the model replied with a code word that was not in any source, and with the answer in the last source, it answered correctly but without the source number that the system message asked for. The response contained no error; the server log had the warning "truncating input prompt".

Ollama's source code also has a truncate field for chat requests, which is not listed on the /api/chat page. With "truncate": false, the same request returned HTTP 400 with the error "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it". The prompt_eval_count field of a response is the number of tokens in the prompt; in the truncated test it was 514, not 2,068.

Because the end of the prompt is kept, put the question after the sources. To find out when the sources do not fit, send "truncate": false and then raise num_ctx or send fewer rows. A larger num_ctx needs more memory.

Check the citations

A citation can be checked mechanically: the number must refer to a source that was sent, and the quote should appear in that source. In our test with four sources and qwen2.5:7b, the quotes in the JSON answers matched the sources word for word. That does not prove the answer: asked about the size of a 384-dimension vector, the model quoted the correct rule (4 times the dimensions plus 8 bytes) and still answered 1552 bytes instead of 1,544.

Look at answers without citations too. When the sources did not contain the answer, the model once answered from its own knowledge with no citations, and once said that it did not know but still cited an unrelated source. If done_reason in the response is "length", the answer was cut off by num_predict and may be missing its citations.

The checker below compares text only. Whether a source really supports a sentence still has to be judged by a person.

Try it: RAG Citation Checker

The example answer is written by hand to show a quote that is not in its source.

4 sources (a JSON array of strings). Answer read as an Ollama /api/chat response, in the form of JSON with citations.

Tokens in the prompt (prompt_eval_count): 306

  • The quote of citation #2 is not in source [4].

Citations

SourceQuoteResult
[2]hnsw.ef_search is 40 by defaultFound as written
[4]4 bytes per dimension plus 8Not in this source

This only checks that the numbers and quotes match the sources. It cannot tell whether the answer is correct or whether a source really supports a sentence.

Response format from Ollama's API reference and source code, checked on Ollama 0.40.11 on 2026-11-05. Ollama API: /api/chat

Everything is done in your browser. The tool connects to nothing.

Memory on a small server

The README says an HNSW index builds significantly faster when the graph fits into maintenance_work_mem, shows a notice when it no longer fits, and warns not to set maintenance_work_mem so high that it exhausts the memory on the server. As with other indexes, it is faster to create the index after loading the initial data. Indexes do not have to fit into memory, but you will likely see better performance if they do; SELECT pg_size_pretty(pg_relation_size('index_name')) shows the size of an index.

For a smaller working set and smaller indexes, the README suggests halfvec, half-precision indexing or binary quantization. If Ollama runs on the same server, PostgreSQL and the models share the same memory; our article on watching Ollama's memory explains what decides how much the models use.

Sources

Read next