Skip to content
AI.info

Implementation Guides

Vector Search Implementation Guide

A hands-on guide to shipping vector similarity search: choosing an embedding model, HNSW vs IVF vs PQ, pgvector 0.8.6 iterative scans, hybrid BM25 fusion with RRF, Cohere Rerank 4, and recall tuning.

Vector Search Implementation Guide

Gabriele Masetti ·

Scope of this guide

What follows is a build guide, not a theory primer. It assumes you already know what an embedding is and why cosine similarity works, and walks through the decisions you actually have to make to ship vector search in production: which embedding model, how to store and index vectors, which index type, which database, how to filter by metadata, how to layer in keyword search, and how to tune recall without guessing.

Choosing an embedding model

Three variables matter more than benchmark leaderboard position: dimensionality, cost, and whether the model matches your domain (general text vs. code vs. multilingual).

Model Dimensions Price note
text-embedding-3-small 1,536 ~$0.02 per 1M input tokens
text-embedding-3-large 3,072 ~6.5x the price of -small
text-embedding-3-large (truncated) 1,024 Matryoshka truncation, less storage/latency

Practical rule: pick the smallest embedding dimension that hits your recall target on a held-out set of real queries. Every dimension you carry multiplies storage and index memory across every vector in the corpus, so a 3,072-dim model on 50M documents is a materially different infrastructure bill than 1,536.

Lock the model choice before you write a single row — embeddings from different models (or different dimensions truncations of the same model) are not comparable, and you cannot mix them in one index.

Generating and storing vectors

Chunking happens before embedding and determines retrieval quality as much as the model does. For prose, 200–500 token chunks with 10–20% overlap is a reasonable starting point; for code or structured docs, chunk on natural boundaries (functions, sections) rather than fixed token counts.

Batch-generate embeddings rather than calling the API per document:

import json
import openai
import psycopg

client = openai.OpenAI()

def embed_batch(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
    resp = client.embeddings.create(model=model, input=texts, dimensions=1536)
    return [d.embedding for d in resp.data]

BATCH_SIZE = 96  # keep batches well under the API's request size/token ceiling

with psycopg.connect(DATABASE_URL) as conn:
    with conn.cursor() as cur:
        for i in range(0, len(chunks), BATCH_SIZE):
            batch = chunks[i : i + BATCH_SIZE]
            vectors = embed_batch([c.text for c in batch])
            cur.executemany(
                "INSERT INTO documents (content, metadata, embedding) VALUES (%s, %s, %s)",
                [(c.text, json.dumps(c.meta), v) for c, v in zip(batch, vectors)],
            )
    conn.commit()

Store the raw text alongside the vector, not just the vector — you need it for reranking, display, and re-embedding when you upgrade models. Store a model and embedding_version column too; you will change embedding models eventually, and mixed-model rows silently corrupt similarity scores if you don't track provenance.

Index types: HNSW, IVF, and PQ

Three algorithms cover almost every production system:

Rule of thumb: start with HNSW. Only reach for IVF/PQ when the corpus is large enough (tens of millions+ of vectors) that HNSW's memory footprint becomes the bottleneck, or when you're using FAISS directly and need composite indexes like IVF4096,PQ64.

pgvector vs. FAISS vs. Pinecone/Weaviate/Qdrant

These aren't interchangeable — they solve different problems.

Decision shortcut:

Situation Pick
Already on Postgres, corpus < ~10–50M vectors pgvector
Need full control of index internals, offline pipeline FAISS
Need managed scaling, multi-tenant isolation, hybrid search out of the box Pinecone / Weaviate / Qdrant
Multi-tenant SaaS with strict per-customer isolation Pinecone namespaces or Qdrant/Weaviate collections — cheaper and simpler than metadata filtering by tenant ID at scale

pgvector in practice

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    metadata JSONB DEFAULT '{}',
    embedding_model TEXT NOT NULL,
    embedding vector(1536) NOT NULL
);

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

CREATE INDEX documents_metadata_gin
    ON documents USING gin (metadata jsonb_path_ops);

m (default 16, range 2–100) controls graph connectivity; ef_construction (default 64, must be ≥ 2×m) controls build-time search thoroughness. Both are build-time only — changing them means rebuilding the index.

Parameter Value Note
HNSW m 16 (default), range 2-100 Graph connectivity
HNSW ef_construction 64 (default) Must be ≥ 2×m
RRF k constant 60 Conventional default in rank fusion

Metadata filtering

Every real deployment needs to combine "semantically similar to X" with "and belongs to tenant Y" or "and created after date Z." Two approaches:

  1. Pre-filter with a regular index, then vector search — works well when the filter is highly selective (e.g., tenant isolation) and your database can intersect a B-tree/GIN scan with the vector index efficiently.
  2. Post-filter after the ANN search — retrieve more candidates than you need (e.g., top 50 instead of top 10) and filter afterward. That is the default behaviour of a naive HNSW query, and it can silently under-return results when a filter is selective, because the graph traversal may exhaust its candidate list before finding enough matches.

pgvector 0.8.0 (October 2024) added iterative index scan, which lets a filtered HNSW/IVFFlat query keep expanding the graph traversal in batches instead of giving up early, meaningfully improving recall on selective WHERE clauses. The knobs are hnsw.iterative_scan and ivfflat.iterative_scan, and they are unchanged in the current 0.8.x series — 0.8.6, released 29 July 2026. In managed databases, Pinecone indexes metadata automatically on upsert with no separate schema step, and recommends namespaces over metadata filters for tenant-scale isolation because a namespace-scoped query is cheaper than scanning-and-filtering a shared index.

Hybrid search: BM25 + dense vectors

Dense embeddings miss exact matches — product SKUs, error codes, proper nouns the embedding model undertrained on. Keyword search (BM25, or Postgres full-text search) catches those but misses paraphrase and synonymy. Combine both and fuse with Reciprocal Rank Fusion (RRF):

score(d) = Σ 1 / (k + rank(d))

summed across each retrieval method's rank for document d, with k = 60 as the conventional default. RRF operates on ranks rather than raw scores, which sidesteps the problem that BM25 scores and cosine similarities live on incompatible scales — it needs no tuning and is the default fusion method in Elasticsearch and Qdrant.

A hand-rolled RRF in SQL, combining pgvector similarity with Postgres full-text search:

WITH vector_hits AS (
    SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS rnk
    FROM documents
    ORDER BY embedding <=> $1
    LIMIT 50
),
text_hits AS (
    SELECT id, row_number() OVER (ORDER BY ts_rank_cd(content_tsv, q) DESC) AS rnk
    FROM documents, plainto_tsquery('english', $2) q
    WHERE content_tsv @@ q
    LIMIT 50
)
SELECT COALESCE(v.id, t.id) AS id,
       COALESCE(1.0 / (60 + v.rnk), 0) + COALESCE(1.0 / (60 + t.rnk), 0) AS rrf_score
FROM vector_hits v
FULL OUTER JOIN text_hits t ON v.id = t.id
ORDER BY rrf_score DESC
LIMIT 10;

Weaviate implements the same idea natively via an alpha parameter (0 = pure BM25, 1 = pure vector, default 0.5) with two selectable fusion algorithms, rankedFusion and the newer relativeScoreFusion. Qdrant exposes hybrid queries through its Query API combining dense and sparse (BM25-style) vectors natively with RRF. If your database supports hybrid search natively, use that instead of hand-rolling it — it's less code to maintain and usually faster.

Add a reranking stage for anything user-facing: take the top 20–50 hybrid candidates and rerank with a cross-encoder. Cohere's Rerank 4, released in December 2025, is the current hosted option, in two sizes — rerank-v4.0-pro for quality and rerank-v4.0-fast for latency and throughput — both with a 32,768-token context window against the 4,096 of the older rerank-v3.5, and both multilingual across 100+ languages. The wider window matters when the candidates are long: contracts, manuals, transcripts. A self-hosted model such as bge-reranker-v2-m3 is the alternative when the documents cannot leave your network. Budget roughly 100–300ms of added latency per query either way, and measure it on your own document lengths rather than trusting the range. Reranking consistently improves NDCG over first-stage retrieval alone because cross-encoders score the query and document jointly instead of comparing precomputed vectors.

Recall tuning

Recall is a query-time knob, separate from the index you built:

Measure recall against a ground-truth set, not vibes: run a brute-force exact search (flat index) on a sample of queries, compare to your ANN index's top-k, and compute recall@k directly. Re-check after any reindex, model change, or quantization change — recall regressions are silent until a user notices bad results.

If storage is the bottleneck rather than latency, pgvector's halfvec type (16-bit floats instead of 32-bit) cuts storage in half with negligible recall impact, and binary quantization via the bit type cuts storage roughly 32x at a real recall cost that a rerank stage can partially recover.

Batch vs. realtime ingestion

Two ingestion patterns, often needed together:

For change-heavy sources (a database feeding your vector store), a CDC pipeline (e.g., Debezium reading Postgres WAL) into a queue that triggers re-embedding is more reliable than polling for "updated_at > last_run," especially once you need to also handle deletes.

Production checklist

Explore

More articles