pgvector Playground

Real PostgreSQL with pgvector in your browser, with document chunks and embeddings ready to search. Nothing is uploaded.

About the pgvector Playground

Retrieval-augmented generation (RAG) answers questions from your own documents: the documents are split into chunks, each chunk is turned into an embedding, and at question time the closest chunks are found and handed to a language model. With pgvector, the search step is plain SQL in PostgreSQL, next to the rest of your data.

This playground runs that step for real, in your browser: PostgreSQL 16 with pgvector 0.8, and 371 chunks of tinypdf.in’s own help text with their embeddings. Pick a question above the editor, open Examples and run them: similarity search, filters, hybrid search, neighbouring chunks, diversity, multi-tenant search and indexes.

How RAG search works in PostgreSQL

  1. DocumentsPages, PDFs, tickets: the text you want answers from.
  2. ChunksEach document is split into passages of a few sentences.
  3. EmbeddingsA model turns each chunk into a list of numbers (here 384) that captures its meaning.
  4. TableChunks and embeddings go into PostgreSQL, in a vector(384) column.
  5. QuestionThe user’s question is embedded with the same model.
  6. Top-kORDER BY embedding <=> question LIMIT k returns the closest chunks.
  7. LLMThose chunks are the context the language model answers from.

The sample tables

documents
One row per help page: title, url, source (the site section), tenant_id (acme, globex or initech, for the multi-tenant example) and created_at.
chunks
The passages: document_id, chunk_index (position in the page), content, metadata (JSONB: section, source, words), tsv (for full-text search) and embedding vector(384).
questions
Ten example questions with their embeddings.
query
One row: the question the examples use. Picking a question above the editor, or typing your own, replaces it.

pgvector query examples you can run

Each of these runs against the sample tables. Open Examples above the editor to load one, or copy it.

Top 5 chunks for the question (cosine)
-- The question picked above the editor is in the table "query".
-- <=> is cosine distance: 0 = same meaning, 2 = opposite. Smaller distance = more similar.
-- similarity = 1 - distance: easier to read, higher is better. Sort and filter by distance.
SELECT c.id,
       d.title,
       round((c.embedding <=> q.embedding)::numeric, 4)       AS distance,
       round((1 - (c.embedding <=> q.embedding))::numeric, 4) AS similarity,
       left(c.content, 120) AS preview
FROM chunks c
JOIN documents d ON d.id = c.document_id
CROSS JOIN query q
ORDER BY c.embedding <=> q.embedding
LIMIT 5;
Cosine, L2 and inner product compared
-- <-> L2 (straight-line) distance, <=> cosine distance, <#> NEGATIVE inner product.
-- For all three, smaller = more similar. These embeddings have length 1,
-- so all three give the same order: only the numbers differ.
SELECT c.id,
       round((c.embedding <-> q.embedding)::numeric, 4)        AS l2,
       round((c.embedding <=> q.embedding)::numeric, 4)        AS cosine_distance,
       round((1 - (c.embedding <=> q.embedding))::numeric, 4)  AS cosine_similarity,
       round(((c.embedding <#> q.embedding) * -1)::numeric, 4) AS inner_product,
       round(vector_norm(c.embedding)::numeric, 4)             AS length
FROM chunks c CROSS JOIN query q
ORDER BY c.embedding <=> q.embedding
LIMIT 5;
Filter by metadata (FAQ answers only)
-- A normal WHERE clause on the JSONB metadata, then order by distance.
SELECT c.id, d.title,
       round((c.embedding <=> q.embedding)::numeric, 4) AS distance,
       left(c.content, 120) AS preview
FROM chunks c
JOIN documents d ON d.id = c.document_id
CROSS JOIN query q
WHERE c.metadata->>'section' = 'faq'
  AND d.source IN ('everyday-pdf', 'privacy')
ORDER BY c.embedding <=> q.embedding
LIMIT 5;
Hybrid search: full text + vector (RRF)
-- Two rankings merged with reciprocal rank fusion: score = 1/(60 + rank) from each.
-- Full text finds exact words (names, codes); vectors find meaning.
WITH vec AS (
  SELECT c.id, row_number() OVER (ORDER BY c.embedding <=> q.embedding) AS r
  FROM chunks c CROSS JOIN query q
  ORDER BY c.embedding <=> q.embedding
  LIMIT 20
),
fts AS (
  SELECT c.id,
         row_number() OVER (ORDER BY ts_rank_cd(c.tsv, websearch_to_tsquery('english', q.text)) DESC) AS r
  FROM chunks c CROSS JOIN query q
  WHERE c.tsv @@ websearch_to_tsquery('english', q.text)
  ORDER BY ts_rank_cd(c.tsv, websearch_to_tsquery('english', q.text)) DESC
  LIMIT 20
)
SELECT c.id,
       vec.r AS vector_rank,
       fts.r AS text_rank,
       round((coalesce(1.0 / (60 + vec.r), 0) + coalesce(1.0 / (60 + fts.r), 0))::numeric, 5) AS rrf,
       left(c.content, 100) AS preview
FROM vec
FULL JOIN fts USING (id)
JOIN chunks c USING (id)
ORDER BY rrf DESC
LIMIT 5;
Add the chunks around each hit
-- An LLM answers better with the text just before and after a hit.
WITH hits AS (
  SELECT c.document_id, c.chunk_index
  FROM chunks c CROSS JOIN query q
  ORDER BY c.embedding <=> q.embedding
  LIMIT 3
)
SELECT n.document_id,
       n.chunk_index,
       n.chunk_index = h.chunk_index AS is_hit,
       left(n.content, 120) AS preview
FROM hits h
JOIN chunks n ON n.document_id = h.document_id
             AND n.chunk_index BETWEEN h.chunk_index - 1 AND h.chunk_index + 1
ORDER BY n.document_id, n.chunk_index;
At most 2 chunks per page (diversity)
-- Without this, the top 6 can all come from one page.
WITH ranked AS (
  SELECT c.id, c.document_id, c.content,
         c.embedding <=> q.embedding AS distance,
         row_number() OVER (PARTITION BY c.document_id ORDER BY c.embedding <=> q.embedding) AS per_page
  FROM chunks c CROSS JOIN query q
)
SELECT r.id, d.title, round(r.distance::numeric, 4) AS distance, left(r.content, 100) AS preview
FROM ranked r JOIN documents d ON d.id = r.document_id
WHERE r.per_page <= 2
ORDER BY r.distance
LIMIT 6;
Multi-tenant: one customer’s documents only
-- Each document belongs to a tenant (acme, globex, initech).
-- Always filter by tenant before ranking, so no tenant sees another's text.
SELECT d.tenant_id, d.title,
       round((c.embedding <=> q.embedding)::numeric, 4) AS distance
FROM chunks c
JOIN documents d ON d.id = c.document_id
CROSS JOIN query q
WHERE d.tenant_id = 'acme'
ORDER BY c.embedding <=> q.embedding
LIMIT 5;

-- How the chunks are spread over tenants
SELECT d.tenant_id, count(*) AS chunks
FROM chunks c JOIN documents d ON d.id = c.document_id
GROUP BY d.tenant_id ORDER BY d.tenant_id;
Build the context for the LLM
-- The top chunks as one block of text, with their source, ready for a prompt.
WITH top AS (
  SELECT c.* FROM chunks c CROSS JOIN query q
  ORDER BY c.embedding <=> q.embedding
  LIMIT 4
)
SELECT (SELECT text FROM query) AS question,
       string_agg(format('[%s, part %s] %s', d.title, t.chunk_index + 1, t.content),
                  E'\n\n' ORDER BY d.id, t.chunk_index) AS context
FROM top t JOIN documents d ON d.id = t.document_id;
Similarity threshold instead of top-k
-- Keep everything closer than a cut-off, however many rows that is.
-- Good cut-offs depend on the embedding model: try a few on your own data.
SELECT c.id, d.title,
       round((1 - (c.embedding <=> q.embedding))::numeric, 4) AS similarity
FROM chunks c
JOIN documents d ON d.id = c.document_id
CROSS JOIN query q
WHERE c.embedding <=> q.embedding < 0.6
ORDER BY c.embedding <=> q.embedding
LIMIT 20;
Chunks similar to a chunk (no question)
-- "More like this": use a stored embedding as the query.
SELECT c.id, d.title,
       round((c.embedding <=> s.embedding)::numeric, 4) AS distance,
       left(c.content, 100) AS preview
FROM chunks c
JOIN documents d ON d.id = c.document_id
CROSS JOIN (SELECT embedding FROM chunks WHERE id = 10) s
WHERE c.id <> 10
ORDER BY c.embedding <=> s.embedding
LIMIT 5;
All example questions at once (LATERAL)
-- The best chunk for every question in one query.
SELECT qs.id, qs.text AS question, best.title, best.distance
FROM questions qs
CROSS JOIN LATERAL (
  SELECT d.title, round((c.embedding <=> qs.embedding)::numeric, 4) AS distance
  FROM chunks c JOIN documents d ON d.id = c.document_id
  ORDER BY c.embedding <=> qs.embedding
  LIMIT 1
) best
ORDER BY qs.id;
Add an HNSW index and read the plan
-- The operator class must match the operator: vector_cosine_ops for <=>.
CREATE INDEX IF NOT EXISTS chunks_embedding_hnsw
  ON chunks USING hnsw (embedding vector_cosine_ops);

-- 1. With only a few hundred rows, PostgreSQL still reads every row and sorts:
--    exact, and fast enough. It is right not to use the index here.
EXPLAIN ANALYZE
SELECT id FROM chunks
ORDER BY embedding <=> (SELECT embedding FROM query)
LIMIT 5;

-- 2. Turn off full scans to see the plan a large table would get: Index Scan … Order By.
SET enable_seqscan = off;
EXPLAIN ANALYZE
SELECT id FROM chunks
ORDER BY embedding <=> (SELECT embedding FROM query)
LIMIT 5;
RESET enable_seqscan;

-- Notes: the index needs ORDER BY <distance> LIMIT k, and the question vector must be a
-- single value, as (SELECT embedding FROM query) here. With "CROSS JOIN query q" it cannot be used.
-- For index tuning, see the PostgreSQL Query Performance Lab.
Filtered search with an index (iterative scan)
-- An approximate index hands over its nearest candidates (hnsw.ef_search, 40 by default)
-- and the WHERE clause is applied afterwards. A selective filter can leave fewer than k rows.
CREATE INDEX IF NOT EXISTS chunks_embedding_hnsw
  ON chunks USING hnsw (embedding vector_cosine_ops);
SET enable_seqscan = off;        -- act like a large table
SET hnsw.ef_search = 10;         -- a small candidate list makes the problem easy to see

-- 1. Asks for 5 rows, may get fewer (or none)
SELECT id, round((embedding <=> (SELECT embedding FROM query))::numeric, 4) AS distance
FROM chunks
WHERE metadata->>'source' = 'ai-and-text'
ORDER BY embedding <=> (SELECT embedding FROM query)
LIMIT 5;

-- 2. pgvector 0.8: keep scanning the index until 5 rows pass the filter
SET hnsw.iterative_scan = relaxed_order;
SELECT id, round((embedding <=> (SELECT embedding FROM query))::numeric, 4) AS distance
FROM chunks
WHERE metadata->>'source' = 'ai-and-text'
ORDER BY embedding <=> (SELECT embedding FROM query)
LIMIT 5;

RESET hnsw.iterative_scan; RESET hnsw.ef_search; RESET enable_seqscan;
Vector functions: length, average, dimensions
-- The "centre" of each page: the average of its chunk embeddings.
SELECT d.title,
       count(*) AS chunks,
       vector_dims(avg(c.embedding)) AS dims,
       round(vector_norm(avg(c.embedding))::numeric, 3) AS length_of_average
FROM chunks c JOIN documents d ON d.id = c.document_id
GROUP BY d.title
ORDER BY chunks DESC
LIMIT 8;

-- A vector, its parts and a smaller copy (halfvec uses 2 bytes per number)
SELECT '[0.6, 0.8, 0]'::vector              AS v,
       vector_norm('[0.6, 0.8, 0]'::vector)  AS length,
       '[0.6, 0.8, 0]'::vector::halfvec      AS half,
       pg_column_size('[0.6, 0.8, 0]'::vector)  AS bytes_vector,
       pg_column_size('[0.6, 0.8, 0]'::halfvec) AS bytes_halfvec;
Common mistake: wrong number of dimensions
-- An embedding from a different model has a different length.
-- PostgreSQL refuses to compare vectors of different sizes:
SELECT '[1, 2, 3]'::vector <=> '[1, 2]'::vector;
Store your own question
-- Pick a question above the editor, or copy one of the stored ones into "query":
UPDATE query
SET text = qs.text, embedding = qs.embedding
FROM questions qs
WHERE qs.id = 4;

SELECT text FROM query;

HNSW vs IVFFlat: which pgvector index?

HNSWIVFFlat
How it worksA graph of neighbours, searched from the top layer downVectors grouped into lists around centres; only the nearest lists are searched
BuildSlower, more memory; can be created on an empty tableFast; needs the data first (the lists come from it)
Query speed and recallUsually better recall for the same speedGood, with more probes for better recall
Tuningm, ef_construction; hnsw.ef_search at query timelists; ivfflat.probes at query time
Use it whenThe default choice for most tablesBuild time or memory matters more than the last bit of recall

Up to tens of thousands of rows, an exact scan (no index) is often fast enough and always returns the true nearest rows. Compare index choices with recall in the PostgreSQL Query Performance Lab.

pgvector glossary: embeddings, cosine distance, HNSW

Embedding
A fixed-length list of numbers that represents the meaning of a text. Texts with similar meaning get vectors that point in similar directions.
Chunk
A piece of a document small enough to embed and to fit, with others, into the model’s context: here one paragraph or one FAQ answer.
Dimensions
How many numbers each embedding has: 384 for all-MiniLM-L6-v2, 768 or 1,536 for larger models. A vector column has one fixed size.
Cosine distance (<=>)
1 minus the cosine of the angle between two vectors: 0 for the same direction, 2 for opposite. The usual choice for text embeddings.
L2 distance (<->)
The straight-line distance between two vectors. Gives the same order as cosine when vectors have length 1.
Inner product (<#>)
pgvector returns the negative inner product, so that smaller is more similar, like the other operators. Fastest, and equal to cosine on normalised vectors.
Normalisation
Scaling every vector to length 1. Then cosine, L2 and inner product rank results identically.
Top-k
The k closest chunks: ORDER BY distance LIMIT k.
Recall
The share of the true top-k that an approximate index returns. Exact search has recall 1.
HNSW and IVFFlat
Approximate (ANN) indexes. They trade a little recall for much faster search on large tables. HNSW is usually the better default; IVFFlat builds faster.
Hybrid search
Combining full-text search (exact words, names, codes) with vector search (meaning), for example with reciprocal rank fusion.
Reranking
A second, more careful model that reorders the top candidates before they go to the LLM.

Common pgvector mistakes

What differs on a real server

Questions

Is this real PostgreSQL with pgvector?

Yes. It is PostgreSQL 16 compiled to WebAssembly (PGlite) with the pgvector 0.8 extension, running inside your browser tab. The SQL, operators and index types are the same as on a server.

Which embedding model made the sample vectors?

all-MiniLM-L6-v2 (384 dimensions, Apache-2.0), run with transformers.js. When you type your own question, the page downloads the same model once (about 23 MB) and embeds the question in your browser, so the numbers are comparable.

What is the sample data?

tinypdf.in’s own help text: the About sections, feature tables and FAQ answers of about 30 tool pages, split into 371 chunks. Each document has a source, a tenant and a link to its page.

Can I use my own embeddings?

Yes. Import a JSONL, JSON or CSV file with a text column and an embedding column (a list of numbers). Every row must have the same number of dimensions; the page tells you which row differs. To compare them with the question above the editor, they must come from the same model.

Cosine, L2 or inner product?

For text embeddings, cosine distance (<=>) is the usual choice. If your vectors are normalised to length 1, as most embedding models produce, all three give the same order, and inner product (<#>) is the cheapest.

Do I need a vector index?

Not for small tables. An exact scan over a few thousand rows takes milliseconds and is always correct. Add HNSW or IVFFlat when the table is large enough that exact search is too slow, and check recall. The PostgreSQL Query Performance Lab compares index choices.

How do I do cosine similarity search in pgvector?

Order by the cosine distance operator and limit: SELECT id, content FROM chunks ORDER BY embedding <=> $1 LIMIT 5, where $1 is the question’s embedding. Cosine similarity is 1 minus that distance. The first example on this page runs exactly this.

What is the difference between HNSW and IVFFlat?

Both are approximate indexes. HNSW builds a graph: slower to build and larger, but fast and accurate at query time, and it can be created on an empty table. IVFFlat splits vectors into lists: quick to build and smaller, but it needs data first and usually gives lower recall for the same speed. Start with HNSW.

How do I do hybrid search in PostgreSQL?

Run a full-text search (tsvector, websearch_to_tsquery) and a vector search, then merge the two rankings, for example with reciprocal rank fusion: each result scores 1/(60 + rank) from each list. The hybrid search example on this page does this in one query with two CTEs.

Is my data uploaded?

No. The database, the sample, your queries, imported files and typed questions stay in this browser tab. The only downloads are PostgreSQL itself, the sample data and, if you type a question, the embedding model.

More playgrounds

Sample vectors: all-MiniLM-L6-v2 (Apache-2.0) via transformers.js. pgvector is under the PostgreSQL License. See open-source licences.