Databases

Vector Search in PostgreSQL with pgvector: Semantic Search Without External Services in 2026

Ruslan Ismailov Published 12 min read
V

Introduction: Why Semantic Search Matters and When You Don't Need a Separate Service

Classic full-text search works by exact word matching. A query like "buy laptop" won't find a document containing "purchase notebook," even if the meaning is identical. Semantic search solves this problem by comparing not words, but vector representations of meaning — embeddings.

The traditional answer to this challenge is to spin up Elasticsearch with a kNN plugin, Pinecone, Weaviate, or Qdrant. But for most products, that means new infrastructure, additional costs, data synchronization, and a new source of failures. If you already have PostgreSQL, the pgvector extension lets you add full-featured vector search directly to your existing database — no external services required.

In 2026, pgvector has reached maturity: HNSW index support, parallel queries, and compatibility with cloud-managed PostgreSQL platforms (Amazon RDS, Supabase, Neon, Google Cloud SQL). This article is for backend developers and architects who want a pragmatic solution without bloating their stack.

What Is pgvector: Installation and Supported Types

pgvector is an open-source extension for PostgreSQL that adds the vector data type, vector comparison operators, and indexes for approximate nearest neighbor (ANN) search.

Installing the Extension

On Ubuntu/Debian with PostgreSQL 16:

sudo apt install postgresql-16-pgvector

-- In psql:
CREATE EXTENSION IF NOT EXISTS vector;

Via Docker (recommended for development):

docker run -d \
  --name pgvector-dev \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 \
  pgvector/pgvector:pg16

After installation, verify the version:

SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 0.8.0 (current in 2026)

The vector(N) type accepts a fixed dimension N (maximum 16,000 dimensions for HNSW, 2,000 for IVFFLAT). Also supported are halfvec (16-bit floats, half the storage) and sparsevec for sparse vectors.

Generating Embeddings: Models and Integration in 2026

An embedding is a numeric vector that encodes the meaning of text. Popular models in 2026:

  • text-embedding-3-large (OpenAI) — 3072 dimensions, high quality, paid.
  • text-embedding-3-small (OpenAI) — 1536 dimensions, good price-to-quality ratio.
  • Mistral Embed — 1024 dimensions, competitive quality for European data.
  • nomic-embed-text-v2 (locally via Ollama) — 768 dimensions, fully offline.
  • multilingual-e5-large — works excellently with non-English languages.

Getting an Embedding via REST API in Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
)

type EmbeddingRequest struct {
    Input string `json:"input"`
    Model string `json:"model"`
}

type EmbeddingResponse struct {
    Data []struct {
        Embedding []float32 `json:"embedding"`
    } `json:"data"`
}

func GetEmbedding(text, apiKey string) ([]float32, error) {
    payload := EmbeddingRequest{
        Input: text,
        Model: "text-embedding-3-small",
    }
    body, _ := json.Marshal(payload)

    req, _ := http.NewRequest("POST",
        "https://api.openai.com/v1/embeddings",
        bytes.NewBuffer(body),
    )
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var result EmbeddingResponse
    json.NewDecoder(resp.Body).Decode(&result)
    return result.Data[0].Embedding, nil
}

Getting an Embedding in PHP (for Laravel)

<?php
use Illuminate\Support\Facades\Http;

function getEmbedding(string $text): array
{
    $response = Http::withToken(config('services.openai.key'))
        ->post('https://api.openai.com/v1/embeddings', [
            'input' => $text,
            'model' => 'text-embedding-3-small',
        ]);

    return $response->json('data.0.embedding');
}

Creating Tables with Vector Columns and Storing Embeddings

Let's create a documents table with a vector column for storing PostgreSQL embeddings:

CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    embedding   vector(1536),          -- dimension for text-embedding-3-small
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

Inserting a document with an embedding (Go example):

import "github.com/pgvector/pgvector-go"

embedding, _ := GetEmbedding("Laptops for development", apiKey)

_, err = db.Exec(
    `INSERT INTO documents (title, content, embedding)
     VALUES ($1, $2, $3)`,
    "Best Laptops 2026",
    "A review of top laptops for developers...",
    pgvector.NewVector(embedding),
)

Search Operators: Cosine Similarity, L2 Distance, Inner Product

pgvector supports three distance operators. The choice depends on your use case:

  • Cosine distance (<=>) — measures the angle between vectors, independent of magnitude. Best choice for text embeddings. Value 0 = identical, 2 = opposite.
  • L2 (Euclidean) distance (<->) — Euclidean distance. Suitable when absolute proximity in space matters, such as for images.
  • Inner product (<#>) — dot product (with a negative sign for sorting). Optimal for normalized vectors — mathematically equivalent to cosine similarity but faster.

Example query using cosine similarity SQL:

-- Find the 10 documents closest in meaning to the query
SELECT
    id,
    title,
    1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;

Where $1 is the embedding of the user's search query.

Indexes for Vector Search: IVFFLAT vs HNSW

Without an index, PostgreSQL performs a full table scan (exact kNN search). With millions of records, this is unacceptably slow. pgvector offers two types of approximate search indexes.

IVFFLAT — Inverted File with Flat Clusters

CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- Before searching, set the number of clusters to probe:
SET ivfflat.probes = 10;

The lists parameter: recommended value is rows / 1000 for tables up to 1M rows. probes: higher values mean more accuracy but slower queries. IVFFLAT builds quickly but requires data to exist before the index is created.

HNSW — Hierarchical Navigable Small World

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

-- During search:
SET hnsw.ef_search = 40;

The pgvector HNSW index outperforms IVFFLAT in search speed at comparable accuracy. Parameters:

  • m (8–64) — number of connections per layer. Higher = more accurate, more memory.
  • ef_construction (4–400) — index build quality. 64 is a sensible default.
  • ef_search — search-time accuracy, adjustable on the fly.

HNSW requires more memory during construction and takes longer to build, but delivers better recall at lower latency. In 2026, HNSW is the default choice for most production systems.

Hybrid Search: Combining Full-Text and Vector Search

Pure semantic search can sometimes miss exact terms (SKUs, proper names). PostgreSQL hybrid search combines tsvector search with vector search using RRF (Reciprocal Rank Fusion):

WITH
-- Full-text search
fulltext AS (
    SELECT id, ts_rank(to_tsvector('english', content),
                       plainto_tsquery('english', $2)) AS ft_score
    FROM documents
    WHERE to_tsvector('english', content) @@ plainto_tsquery('english', $2)
    LIMIT 60
),
-- Vector search
vector_search AS (
    SELECT id, 1 - (embedding <=> $1::vector) AS vs_score
    FROM documents
    ORDER BY embedding <=> $1::vector
    LIMIT 60
),
-- Merge using RRF
ranked AS (
    SELECT
        COALESCE(f.id, v.id) AS id,
        COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY f.ft_score DESC)), 0) +
        COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY v.vs_score DESC)), 0) AS rrf_score
    FROM fulltext f
    FULL OUTER JOIN vector_search v ON f.id = v.id
)
SELECT d.id, d.title, r.rrf_score
FROM ranked r
JOIN documents d ON d.id = r.id
ORDER BY r.rrf_score DESC
LIMIT 10;

Performance at Scale: Benchmarks and Limitations

Approximate pgvector performance with HNSW on a server with 32 GB RAM, PostgreSQL 16, 1536-dimension vectors:

  • 100K records: ~2 ms per query, recall 95%+
  • 1M records: ~15–30 ms per query, recall 90%+
  • 10M records: ~80–150 ms, partitioning recommended

For tables exceeding 5M rows, use partitioning by date range or category:

CREATE TABLE documents (
    id         BIGSERIAL,
    category   TEXT NOT NULL,
    embedding  vector(1536),
    created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY LIST (category);

CREATE TABLE documents_tech
    PARTITION OF documents FOR VALUES IN ('tech');

CREATE TABLE documents_finance
    PARTITION OF documents FOR VALUES IN ('finance');

-- Indexes are created per partition
CREATE INDEX ON documents_tech
USING hnsw (embedding vector_cosine_ops);

Important limitation: the HNSW index is loaded into memory during search. Configure maintenance_work_mem for building and shared_buffers for caching.

Caching Search Results with Redis

Semantic search is expensive: you need to fetch the query embedding via API and execute a vector query. Redis allows you to cache results by hashing the query embedding.

Example in Go using the go-redis library:

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "time"
    "github.com/redis/go-redis/v9"
)

func SearchWithCache(ctx context.Context, rdb *redis.Client, query string, embedding []float32) ([]Document, error) {
    // Cache key based on embedding hash
    h := sha256.Sum256([]byte(fmt.Sprintf("%v", embedding)))
    cacheKey := "search:" + hex.EncodeToString(h[:])

    // Check cache
    cached, err := rdb.Get(ctx, cacheKey).Result()
    if err == nil {
        var docs []Document
        json.Unmarshal([]byte(cached), &docs)
        return docs, nil
    }

    // Execute search in PostgreSQL
    docs, err := SearchDocuments(ctx, embedding)
    if err != nil {
        return nil, err
    }

    // Store in Redis for 10 minutes
    data, _ := json.Marshal(docs)
    rdb.Set(ctx, cacheKey, data, 10*time.Minute)

    return docs, nil
}

For frequently repeated queries (searches on popular topics), Redis hit rates can reach 60–80%, significantly reducing database load.

Comparing pgvector with Elasticsearch kNN and Dedicated Vector Databases

pgvector vs Elasticsearch kNN search:

  • Infrastructure: pgvector — zero new components. Elasticsearch — separate cluster, Kibana, monitoring.
  • Consistency: pgvector — ACID transactions. ES — eventual consistency.
  • Hybrid search: both support it, but in PostgreSQL it's native SQL without additional DSL.
  • Performance at 10M+: ES wins through horizontal scaling with shards.

pgvector vs Pinecone/Weaviate/Qdrant:

  • Dedicated vector databases win on peak performance with hundreds of millions of vectors.
  • pgvector wins on simplicity, cost, and integration with relational data (JOINs, transactions, familiar SQL).
  • For most B2B and SaaS products with volumes up to 10M documents, pgvector is the pragmatic choice.

Practitioner's rule: start with pgvector. Migrate to a dedicated vector database only when PostgreSQL can no longer handle measured — not imagined — load.

Real-World Case: Semantic Document Search in a Laravel Application

Let's walk through a pgvector Laravel implementation for searching articles in a knowledge base.

Migration

<?php
// database/migrations/2026_01_01_add_embeddings_to_articles.php
public function up(): void
{
    DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

    Schema::table('articles', function (Blueprint $table) {
        // Add custom type via raw statement
    });

    DB::statement('ALTER TABLE articles ADD COLUMN embedding vector(1536)');
    DB::statement(
        'CREATE INDEX articles_embedding_hnsw
         ON articles USING hnsw (embedding vector_cosine_ops)
         WITH (m = 16, ef_construction = 64)'
    );
}

Search Service

<?php
namespace App\Services;

use App\Models\Article;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

class SemanticSearchService
{
    public function search(string $query, int $limit = 10): array
    {
        $embedding = $this->getEmbedding($query);
        $vectorStr = '[' . implode(',', $embedding) . ']';

        return Article::selectRaw(
            'id, title, excerpt,
             1 - (embedding <=> ?::vector) AS similarity',
            [$vectorStr]
        )
        ->whereNotNull('embedding')
        ->orderByRaw('embedding <=> ?::vector', [$vectorStr])
        ->limit($limit)
        ->get()
        ->toArray();
    }

    private function getEmbedding(string $text): array
    {
        return Cache::remember(
            'embedding:' . md5($text),
            now()->addDay(),
            fn () => Http::withToken(config('services.openai.key'))
                ->post('https://api.openai.com/v1/embeddings', [
                    'input' => $text,
                    'model' => 'text-embedding-3-small',
                ])
                ->json('data.0.embedding')
        );
    }
}

Indexing Articles with an Artisan Command

<?php
namespace App\Console\Commands;

use App\Models\Article;
use App\Services\SemanticSearchService;
use Illuminate\Console\Command;

class IndexArticleEmbeddings extends Command
{
    protected $signature = 'articles:index-embeddings {--chunk=100}';

    public function handle(SemanticSearchService $service): void
    {
        Article::whereNull('embedding')
            ->chunkById((int) $this->option('chunk'), function ($articles) use ($service) {
                foreach ($articles as $article) {
                    $embedding = $service->getEmbeddingPublic(
                        $article->title . ' ' . $article->content
                    );
                    $vectorStr = '[' . implode(',', $embedding) . ']';

                    $article->updateQuietly(['embedding' => $vectorStr]);
                    $this->info("Indexed: {$article->id}");

                    // Respect API rate limits
                    usleep(100_000); // 100ms
                }
            });
    }
}

Conclusion

pgvector in 2026 is a mature, production-ready solution for semantic search directly inside PostgreSQL. You get ACID transactions, JOINs with relational data, familiar SQL, and zero additional infrastructure.

Key recommendations:

  • Use the HNSW index as your default — it's faster than IVFFLAT at comparable accuracy.
  • For text, use cosine distance (<=>).
  • Combine vector search with tsvector via RRF — PostgreSQL hybrid search significantly outperforms either method alone.
  • Cache query embeddings in Redis — this reduces API call costs by 2–5x.
  • Partition tables when document count exceeds 5M.
  • Migrate to dedicated vector databases only when there is a real, measured need.

pgvector + PostgreSQL is pragmatic engineering: you solve a real semantic search problem with minimal architectural complexity.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →