← Code Recipes
Embeddings & Search

pgvector Free code examples in C#, Python, Java and JavaScript

Vector search inside PostgreSQL — no second database to run or back up.

C# Python Java JavaScript

WHAT THIS PAGE COVERS

HNSW indexing, and joining vectors to your existing relational tables.

Model text-embedding-3-small
Package pgvector
Checked

Model ids change. If a sample returns a 404 on the model, that is the first thing to check against the official docs.

Install

Two packages: Npgsql for Postgres and Pgvector for the type mapping. UseVector() on the data source is what makes the mapping happen.

dotnet add package Npgsql
dotnet add package Pgvector

setx DATABASE_URL "Host=localhost;Username=postgres;Password=dev;Database=postgres"
Registering the type

UseVector() goes on the NpgsqlDataSourceBuilder. Without it a Vector parameter throws at execution time with a message about an unknown type, not about pgvector.

using Npgsql;
using Pgvector;
using Pgvector.Npgsql;

var connectionString = Environment.GetEnvironmentVariable("DATABASE_URL")
    ?? throw new InvalidOperationException("DATABASE_URL is not set.");

var builder = new NpgsqlDataSourceBuilder(connectionString);
builder.UseVector();                       // the line people miss

await using var dataSource = builder.Build();

await using (var create = dataSource.CreateCommand(
    "CREATE EXTENSION IF NOT EXISTS vector"))
{
    await create.ExecuteNonQueryAsync();
}
Inserting and searching

The parameter type is Vector, wrapping a float[]. 1536 floats, because the column was declared for text-embedding-3-small.

await using (var insert = dataSource.CreateCommand(
    "INSERT INTO documents (tenant_id, source, page, body, embedding) " +
    "VALUES ($1, $2, $3, $4, $5)"))
{
    insert.Parameters.AddWithValue(1L);
    insert.Parameters.AddWithValue("handbook.pdf");
    insert.Parameters.AddWithValue(4);
    insert.Parameters.AddWithValue("The pump stalled.");
    // float[1536] — the width of text-embedding-3-small.
    insert.Parameters.AddWithValue(new Vector(embedding));
    await insert.ExecuteNonQueryAsync();
}

await using var query = dataSource.CreateCommand(
    "SELECT body, embedding <=> $1 AS distance FROM documents " +
    "WHERE tenant_id = $2 AND deleted_at IS NULL " +
    "ORDER BY distance LIMIT 5");

query.Parameters.AddWithValue(new Vector(queryEmbedding));
query.Parameters.AddWithValue(1L);

await using var reader = await query.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
    Console.WriteLine($"{reader.GetDouble(1):F4}  {reader.GetString(0)}");
}
The join a vector database cannot do

Nearest neighbours restricted by a permission table, in one statement and one transaction. The vectors and the authorisation data are in the same place, which is the entire argument for pgvector.

await using var joined = dataSource.CreateCommand(
    """
    SELECT d.body,
           u.display_name AS author,
           d.embedding <=> $1 AS distance
      FROM documents d
      JOIN users u       ON u.id = d.author_id
      JOIN permissions p ON p.document_id = d.id
     WHERE p.user_id = $2
       AND d.deleted_at IS NULL
     ORDER BY distance
     LIMIT 5
    """);

joined.Parameters.AddWithValue(new Vector(queryEmbedding));
joined.Parameters.AddWithValue(viewerId);

await using var rows = await joined.ExecuteReaderAsync();
while (await rows.ReadAsync())
{
    Console.WriteLine($"{rows.GetString(1)}: {rows.GetString(0)}");
}
Install

The extension goes in the database; the package teaches your driver the type. Both are needed — without the adapter a vector comes back as a string.

# The extension, in Postgres:
#   docker run -p 5432:5432 -e POSTGRES_PASSWORD=dev pgvector/pgvector:pg17

pip install pgvector psycopg[binary]

export DATABASE_URL="postgresql://postgres:dev@localhost:5432/postgres"
The schema

The vector column sits beside the ordinary ones — that is the whole point. The width is fixed at 1536 for text-embedding-3-small and cannot be changed without rewriting the table.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id          bigserial PRIMARY KEY,
    tenant_id   bigint      NOT NULL REFERENCES tenants(id),
    source      text        NOT NULL,
    page        int         NOT NULL,
    body        text        NOT NULL,
    deleted_at  timestamptz,
    -- 1536 = text-embedding-3-small. Fixed for the life of the column.
    embedding   vector(1536)
);
Indexing it

HNSW builds slower and queries faster; IVFFlat is the opposite and MUST be built after the data is loaded, because it clusters what it can see. The ops class has to match the operator you will query with.

-- Cosine distance, so vector_cosine_ops. Using vector_l2_ops here and
-- <=> in the query means the index is never used, with no warning.
CREATE INDEX ON documents
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- A partial index is often the right answer: skip soft-deleted rows
-- entirely rather than filtering them out after the search.
CREATE INDEX ON documents
    USING hnsw (embedding vector_cosine_ops)
    WHERE deleted_at IS NULL;

-- IVFFlat instead, if build time matters more than query latency.
-- Build it AFTER loading: it clusters the rows that exist.
-- CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
--     WITH (lists = 1000);
Inserting and searching

register_vector teaches psycopg the type, so a numpy array or a list goes in and comes back as one. Without it you are formatting strings by hand.

import os
import psycopg
from pgvector.psycopg import register_vector

with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
    register_vector(connection)

    connection.execute(
        "INSERT INTO documents (tenant_id, source, page, body, embedding)"
        " VALUES (%s, %s, %s, %s, %s)",
        (1, "handbook.pdf", 4, "The pump stalled.", embedding),
    )

    rows = connection.execute(
        "SELECT body, embedding <=> %s AS distance"
        "  FROM documents"
        " WHERE tenant_id = %s AND deleted_at IS NULL"
        " ORDER BY distance"
        " LIMIT 5",
        (query_embedding, 1),
    ).fetchall()

    for body, distance in rows:
        print(round(distance, 4), body)
The join a vector database cannot do

THIS IS THE REASON TO BE HERE. Neighbours, filtered by a permission table, enriched with the author's name, in one query and one transaction. There is no second store to keep in step.

rows = connection.execute(
    """
    SELECT d.body,
           u.display_name AS author,
           d.embedding <=> %(query)s AS distance
      FROM documents d
      JOIN users u        ON u.id = d.author_id
      JOIN permissions p  ON p.document_id = d.id
     WHERE p.user_id = %(viewer)s        -- authorisation, not a filter
       AND d.deleted_at IS NULL
     ORDER BY distance
     LIMIT 5
    """,
    {"query": query_embedding, "viewer": viewer_id},
).fetchall()

# A dedicated vector database would need the permission check to
# happen somewhere else, against data it does not hold.
Install

pgvector-java adds the PGvector type on top of the ordinary JDBC driver. Nothing else changes about how you talk to Postgres.

# Gradle
implementation("org.postgresql:postgresql:42.7.4")
implementation("com.pgvector:pgvector:0.1.6")

export DATABASE_URL="jdbc:postgresql://localhost:5432/postgres?user=postgres&password=dev"
Registering the type

PGvector.addVectorType(connection) must run on each connection before a vector is bound. In a pool, that belongs in the connection-init hook.

import com.pgvector.PGvector;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;

public class Search {
    public static void main(String[] args) throws Exception {
        String url = System.getenv("DATABASE_URL");
        if (url == null) throw new IllegalStateException("DATABASE_URL is not set.");

        try (Connection connection = DriverManager.getConnection(url)) {
            try (Statement statement = connection.createStatement()) {
                statement.execute("CREATE EXTENSION IF NOT EXISTS vector");
            }

            // Per connection, before binding any vector parameter.
            // The column is vector(1536): text-embedding-3-small.
            PGvector.addVectorType(connection);
        }
    }
}
Inserting and searching

PGvector wraps a float[] — 1536 wide here, matching text-embedding-3-small. setObject binds it once the type is registered.

import java.sql.PreparedStatement;
import java.sql.ResultSet;

try (PreparedStatement insert = connection.prepareStatement(
        "INSERT INTO documents (tenant_id, source, page, body, embedding)"
        + " VALUES (?, ?, ?, ?, ?)")) {
    insert.setLong(1, 1L);
    insert.setString(2, "handbook.pdf");
    insert.setInt(3, 4);
    insert.setString(4, "The pump stalled.");
    insert.setObject(5, new PGvector(embedding));
    insert.executeUpdate();
}

try (PreparedStatement query = connection.prepareStatement(
        "SELECT body, embedding <=> ? AS distance FROM documents"
        + " WHERE tenant_id = ? AND deleted_at IS NULL"
        + " ORDER BY distance LIMIT 5")) {
    query.setObject(1, new PGvector(queryEmbedding));
    query.setLong(2, 1L);

    try (ResultSet rows = query.executeQuery()) {
        while (rows.next()) {
            System.out.printf("%.4f  %s%n",
                rows.getDouble("distance"), rows.getString("body"));
        }
    }
}
The join a vector database cannot do

One statement: nearest neighbours, restricted by a permission table, with the author's name attached. That is the trade pgvector makes — a little less raw speed for everything living in one transactional database.

String sql = """
    SELECT d.body,
           u.display_name AS author,
           d.embedding <=> ? AS distance
      FROM documents d
      JOIN users u       ON u.id = d.author_id
      JOIN permissions p ON p.document_id = d.id
     WHERE p.user_id = ?
       AND d.deleted_at IS NULL
     ORDER BY distance
     LIMIT 5
    """;

try (PreparedStatement query = connection.prepareStatement(sql)) {
    query.setObject(1, new PGvector(queryEmbedding));
    query.setLong(2, viewerId);

    try (ResultSet rows = query.executeQuery()) {
        while (rows.next()) {
            System.out.println(rows.getString("author") + ": " + rows.getString("body"));
        }
    }
}
Install

pgvector/pg registers the type with node-postgres so vectors round-trip as arrays rather than strings.

npm install pg pgvector

export DATABASE_URL="postgresql://postgres:dev@localhost:5432/postgres"
Registering the type

Do this on the POOL, not on a connection — otherwise only the first checked-out client knows what a vector is and the rest return strings.

import pg from "pg";
import pgvector from "pgvector/pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// Once per pool, on every new connection.
pool.on("connect", async (client) => {
  await pgvector.registerTypes(client);
});

// The documents.embedding column is vector(1536) — the width of
// text-embedding-3-small, and fixed for the life of the column.
await pool.query("CREATE EXTENSION IF NOT EXISTS vector");
Inserting and searching

toSql() formats the array the way Postgres wants it. Passing a bare JavaScript array is a type error that mentions neither vectors nor the column.

await pool.query(
  `INSERT INTO documents (tenant_id, source, page, body, embedding)
   VALUES ($1, $2, $3, $4, $5)`,
  [1, "handbook.pdf", 4, "The pump stalled.", pgvector.toSql(embedding)],
);

const { rows } = await pool.query(
  `SELECT body, embedding <=> $1 AS distance
     FROM documents
    WHERE tenant_id = $2 AND deleted_at IS NULL
    ORDER BY distance
    LIMIT 5`,
  [pgvector.toSql(queryEmbedding), 1],
);

for (const row of rows) {
  console.log(Number(row.distance).toFixed(4), row.body);
}
Checking the index is actually used

A mismatched operator class is a silent sequential scan. EXPLAIN is the only way to find out, and it is worth doing once per query shape rather than trusting the CREATE INDEX succeeded.

const { rows: plan } = await pool.query(
  `EXPLAIN ANALYZE
   SELECT body FROM documents
    WHERE deleted_at IS NULL
    ORDER BY embedding <=> $1
    LIMIT 5`,
  [pgvector.toSql(queryEmbedding)],
);

const text = plan.map((row) => row["QUERY PLAN"]).join("\n");

// "Index Scan using documents_embedding_idx" — good.
// "Seq Scan on documents"                    — the ops class does not
//                                              match your operator.
if (text.includes("Seq Scan")) {
  console.warn("pgvector index not used:\n" + text);
}
The join a vector database cannot do

Neighbours, filtered by a permission table, in one query. That is what having the vectors inside the transactional database buys — and it is the only argument that matters.

const { rows } = await pool.query(
  `SELECT d.body,
          u.display_name AS author,
          d.embedding <=> $1 AS distance
     FROM documents d
     JOIN users u       ON u.id = d.author_id
     JOIN permissions p ON p.document_id = d.id
    WHERE p.user_id = $2
      AND d.deleted_at IS NULL
    ORDER BY distance
    LIMIT 5`,
  [pgvector.toSql(queryEmbedding), viewerId],
);

QUESTIONS

Which languages are the pgvector examples in?

C#, Python, Java and JavaScript. Every sample runs as pasted and reads its credentials from an environment variable — no key is ever written into the code.

Which package do I install for pgvector?

The samples use pgvector. The install step on each language tab gives the exact command.

Which pgvector model do these examples use?

text-embedding-3-small, checked against the official documentation on 26 September 2026. Model ids change often; if a call returns a model-not-found error, check the current list in the official docs.

Where is the official pgvector documentation?

https://github.com/pgvector/pgvector — it is linked at the bottom of this page, and it is the authority whenever it disagrees with a sample here.

ALSO IN EMBEDDINGS & SEARCH

The authority

Official pgvector documentation

Model names, pricing and rate limits change faster than any page here can. When this page and the official docs disagree, the official docs are right.

Read the docs ↗

https://github.com/pgvector/pgvector

New drops, every week

Free tools & Claude templates, landing regularly.

We keep shipping new free tools, code recipes and Claude Code templates. Follow @freeyantra on Instagram or join the newsletter so you never miss one.

Follow @freeyantra

or get them by email

Double opt-in. Unsubscribe any time. Privacy