← Back to Technical Library

Embeddings and Vector Databases

The Semantic Layer That Powers Modern AI Search, RAG, and Retrieval -- From Word2Vec to HNSW Indexes in Production

Embeddings and Vector Databases

The Foundation of Semantic Search, Retrieval-Augmented Generation, and Modern AI Memory

📄 Technical Reference ⏱️ 25 min read 📂 Core AI Concepts

Embeddings are the bridge between human language and machine mathematics. They transform text, images, and audio into dense vectors of floating-point numbers where geometric proximity encodes semantic similarity. Vector databases are the infrastructure layer built to store, index, and search these vectors at scale. Together, they form the retrieval backbone of nearly every production RAG system, semantic search engine, recommendation platform, and AI agent memory store deployed today. This guide covers the full stack: how embeddings are generated, what dimensionality means in practice, how similarity metrics work, the major vector database engines and their tradeoffs, index algorithms like HNSW and IVF, the step-by-step RAG retrieval pipeline, document chunking strategies, and production-tested code examples you can run today.

EmbeddingsVector DatabaseRAGCosine SimilarityQdrantHNSW
🎯 Bottom Line: Embeddings convert meaning into mathematics. A well-chosen embedding model plus a properly configured vector database is the single highest-leverage infrastructure decision in a RAG system. The embedding model determines your retrieval quality ceiling; the vector database determines your latency, scalability, and operational cost floor. Most retrieval failures trace back to one of four causes: a weak embedding model for the domain, poor chunking strategy, missing metadata filters, or an index misconfigured for the dataset size. Get these four things right and the rest of the RAG stack becomes dramatically easier.

What Are Embeddings?

An embedding is a numerical representation of content -- typically a list of a few hundred to a few thousand floating-point numbers -- where the position of each number is learned so that semantically similar items end up close together in the resulting vector space. The word "embedding" comes from the idea that we are embedding high-dimensional, discrete things (words, sentences, images) into a continuous mathematical space where distance is meaningful.

The Core Intuition

Humans understand that "dog" and "puppy" are related, that "king" relates to "queen" as "man" relates to "woman," and that "I need to cancel my subscription" means the same thing as "How do I stop my recurring billing?" Traditional databases cannot do this -- they match exact strings or indexed tokens. Embeddings solve this by mapping each item to a point in a high-dimensional space where distance encodes meaning.

A 384-dimensional embedding is a list of 384 floats. Each dimension does not have a human-readable label -- the model learns what each axis "means" during training. But collectively, the vector captures semantic properties. Two sentences with similar meaning produce vectors that are close together (small distance, high cosine similarity) even if they share no words in common.

# A simplified 4-dimensional embedding for illustration: "dog" -> [ 0.21, -0.14, 0.83, 0.05] "puppy" -> [ 0.19, -0.11, 0.79, 0.08] "cat" -> [ 0.18, -0.22, 0.71, 0.04] "automobile"-> [-0.65, 0.44, -0.12, 0.91] # "dog" and "puppy" are nearly identical vectors. # "cat" is close to both (all are pets/animals). # "automobile" is far from all three (different semantic cluster). # Real embeddings use 384 - 3072 dimensions, not 4.

Why This Matters for AI Applications

Embeddings enable a class of operations that are impossible with keyword search:

  • Semantic search: Find documents that mean the same thing, even with zero word overlap. "How do I reset my password" retrieves a document titled "Credential recovery procedure."
  • Deduplication: Detect near-duplicate content by finding vectors with very high similarity (e.g., cosine > 0.95).
  • Clustering: Group thousands of support tickets into thematic clusters using k-means or HDBSCAN on the embedding vectors.
  • Recommendation: Find "more like this" by retrieving the nearest neighbors of an item's embedding.
  • RAG retrieval: Given a user question, embed it, find the closest document chunks in a vector database, and feed them as context to an LLM.
  • Classification with zero labeled data: Compare an item's embedding to prototype embeddings for each class and pick the nearest.
Key Distinction -- Embeddings vs. Token IDs: Token IDs are arbitrary integer lookups into a vocabulary. Token ID 50257 for "dog" and 50258 for "puppy" tell you nothing about their relationship -- they are just indices. Embeddings, by contrast, are learned continuous vectors where geometric distance reflects semantic similarity. An LLM internally converts token IDs into embeddings (via an embedding lookup table) as its very first operation. The embedding table is part of what makes the model "understand" language.

Dense vs. Sparse Representations

Embeddings are dense representations: every dimension has a non-zero value, and meaning is distributed across all dimensions. This contrasts with sparse representations like TF-IDF or BM25, where a document is a vector with mostly zeros and a few non-zero entries corresponding to the words it contains. Sparse representations excel at exact keyword matching and rare-term retrieval. Dense embeddings excel at semantic matching. Modern production search systems often combine both in a hybrid retrieval approach: BM25 for keyword precision plus embeddings for semantic recall, with results merged and re-ranked.

How Embeddings Are Generated

Embedding models have evolved through several major generations. Each generation improved semantic quality, context handling, and the range of inputs they can embed. Understanding this lineage matters because older models are still deployed in production, and the right model depends on your task, budget, and latency constraints.

Word2Vec (2013) -- The Pioneer

Developed at Google by Tomas Mikolov and colleagues, Word2Vec introduced the idea of learning word embeddings from large text corpora using a shallow neural network. Two architectures were proposed: CBOW (Continuous Bag of Words), which predicts a target word from its context, and Skip-gram, which predicts context words from a target word. Training produces a lookup table: each word in the vocabulary maps to a dense vector (typically 100-300 dimensions). Word2Vec is static: "bank" gets the same vector whether it means a river bank or a financial bank. This ambiguity is its fundamental limitation.

Word2Vec's famous demonstration of vector arithmetic -- king - man + woman ≈ queen -- showed that embeddings capture analogical relationships. This was a watershed moment: it proved that neural networks learn structured, interpretable semantic representations without explicit programming.

GloVe (2014) -- Global Co-occurrence

GloVe (Global Vectors for Word Representation), developed at Stanford by Pennington, Socher, and Manning, takes a different training objective. Instead of predicting context words like Word2Vec, GloVe factorizes the global word-word co-occurrence matrix: it learns vectors such that the dot product of two word vectors approximates the logarithm of how often those words co-occur in a corpus. GloVe embeddings are also static (one vector per word) and typically 100-300 dimensions. Pre-trained GloVe vectors trained on Wikipedia + Gigaword (6 billion tokens) are still widely used as baseline embeddings in NLP courses and lightweight applications.

fastText (2016) -- Subword Awareness

Facebook's fastText extended Word2Vec by learning embeddings for character n-grams in addition to whole words. A word's vector is the sum of its subword n-gram vectors. This means fastText can produce embeddings for words it has never seen (out-of-vocabulary words) by composing them from character sequences. It also handles morphology-rich languages better than Word2Vec or GloVe. The tradeoff is larger model size and slower lookup.

BERT (2018) -- Contextual Embeddings

BERT (Bidirectional Encoder Representations from Transformers) was the breakthrough that made embeddings contextual. Instead of one fixed vector per word, BERT produces a different vector for the same word depending on its surrounding sentence. "Bank" in "I sat by the river bank" and "I deposited cash at the bank" get different embeddings. BERT is a Transformer encoder trained with masked language modeling (predicting randomly masked tokens) and next-sentence prediction.

For retrieval, the standard approach with BERT was to use the [CLS] token output (a 768-dimensional vector) as the sentence embedding. However, raw BERT [CLS] embeddings are surprisingly poor for semantic similarity -- the model was trained for token prediction, not sentence-level similarity. This gap motivated the next generation.

Sentence Transformers (2019) -- Purpose-Built for Similarity

Sentence Transformers (also called SBERT) fine-tune BERT and similar encoder models on sentence-pair similarity tasks using contrastive learning. The result is a model whose sentence-level embeddings are directly optimized for semantic similarity -- cosine similarity between two SBERT embeddings meaningfully reflects how similar the sentences are. Popular models include:

  • all-MiniLM-L6-v2: 384 dimensions, 80MB, very fast. Excellent default for English semantic search on CPU.
  • all-mpnet-base-v2: 768 dimensions, 420MB. Higher quality than MiniLM at moderate cost.
  • bge-base-en-v1.5: 768 dimensions from BAAI. Strong on MTEB benchmarks, supports instruction-tuned queries.
  • bge-large-en-v1.5: 1024 dimensions. Higher quality, slower, more memory.
  • e5-large-v2: 1024 dimensions from Microsoft. Requires "query:" and "passage:" prefixes for best results.
  • multilingual-e5-large: 1024 dimensions, supports 90+ languages. Critical for multi-language corpora.

OpenAI Embeddings (2022-Present) -- Proprietary API Models

OpenAI offers embedding models via API: text-embedding-ada-002 (1536 dimensions, now legacy), text-embedding-3-small (1536 dimensions, with support for shortening), and text-embedding-3-large (3072 dimensions, also shortable). These models are not open-weight -- you send text to the API and receive vectors back. They perform well on benchmarks and require no local infrastructure, but introduce a per-token cost, network latency, and a data-privacy dependency. For many enterprise deployments, self-hosted sentence-transformer or BGE models offer comparable quality at lower cost with full data control.

Model Family Year Dimensions Contextual? Typical Use
Word2Vec 2013 100-300 Legacy, teaching, simple NLP pipelines
GloVe 2014 100-300 Legacy, pre-trained baselines
fastText 2016 100-300 OOV words, morphologically rich languages
BERT [CLS] 2018 768 Yes Token-level tasks, not ideal for similarity
Sentence Transformers 2019+ 384-1024
BGE / E5 / GTE 2023+ 768-1024
OpenAI text-embedding-3-small 2024 1536 API-based, no infra, per-token cost
OpenAI text-embedding-3-large 2024 3072 API-based, highest OpenAI quality
Choosing a Model -- Practical Heuristic: For a self-hosted, CPU-friendly default, start with all-MiniLM-L6-v2 (384-dim). If you need better quality and have a GPU or can tolerate slightly higher latency, use bge-base-en-v1.5 or e5-base-v2 (768-dim). For multilingual corpora, use multilingual-e5-base or multilingual-e5-large. For API-based deployments where you do not want to manage model serving, use text-embedding-3-small. Only jump to 1536+ dimensions when you have evidence that 768 is insufficient for your retrieval quality targets.

Embedding Dimensions -- What They Mean

The dimensionality of an embedding is the length of the vector -- how many floating-point numbers represent each item. More dimensions can capture more nuanced semantic distinctions, but at a cost: larger storage, slower similarity computation, more memory, and potentially diminishing returns. The "right" dimensionality is a tradeoff between retrieval quality, latency, and cost.

Common Dimension Tiers

Dimensions Example Models Storage per Vector Strengths Weaknesses
384 all-MiniLM-L6-v2, MiniLM-L12 1.5 KB (float32) Lower ceiling on nuanced tasks
768 all-mpnet-base-v2, BGE-base, E5-base 3 KB 2x storage of 384-dim
1024 BGE-large, E5-large, GTE-large 4 KB High quality on benchmarks Slower, more GPU memory
1536 OpenAI ada-002, text-embedding-3-small 6 KB Strong API-based quality Per-token API cost, larger index
3072 OpenAI text-embedding-3-large 12 KB Highest OpenAI embedding quality 2x cost of 3-small, large index

Storage Math at Scale

Dimensionality directly drives storage cost. At float32 precision (4 bytes per float):

# Storage per vector at float32: 384-dim -> 384 * 4 bytes = 1,536 bytes (1.5 KB) 768-dim -> 768 * 4 bytes = 3,072 bytes (3.0 KB) 1024-dim -> 1024 * 4 bytes = 4,096 bytes (4.0 KB) 1536-dim -> 1536 * 4 bytes = 6,144 bytes (6.0 KB) 3072-dim -> 3072 * 4 bytes = 12,288 bytes (12.0 KB) # For 10 million vectors: 384-dim -> ~15 GB raw vectors 768-dim -> ~30 GB raw vectors 1536-dim -> ~60 GB raw vectors 3072-dim -> ~120 GB raw vectors # HNSW graph overhead adds ~1.5x - 2x on top of raw vector storage. # Quantization (int8, scalar, product) can reduce this 4x - 32x.

Dimensionality Reduction and Shortening

You can reduce dimensions after generation through techniques like PCA (Principal Component Analysis) or Matryoshka Representation Learning (MRL). OpenAI's text-embedding-3 models are trained with MRL, which means you can safely truncate the vector to a shorter length (e.g., take the first 256 or 512 dimensions of a 1536-dim vector) and retain most of the semantic quality. This is a genuine capability of the model, not a hack -- the training objective explicitly optimized for this. Truncation reduces storage and speeds up similarity computation at a modest quality cost.

Do Not Naively Truncate Non-MRL Models: Truncating the first N dimensions of a standard sentence-transformer embedding (not trained with Matryoshka representation learning) destroys semantic quality. The early dimensions are not more important than later ones -- information is distributed roughly uniformly. Only models explicitly trained for shortening (OpenAI text-embedding-3-* and a few MRL-trained open models) support safe truncation. For other models, use PCA fitted on your data if you must reduce dimensions.

Float Precision -- float32 vs float16 vs int8

Beyond dimensionality, the precision of each float matters. Most embedding models output float32. Storing as float16 halves storage with negligible quality loss for most similarity tasks. int8 scalar quantization (mapping each dimension to an integer in [-128, 127]) reduces storage 4x and speeds up distance computation significantly -- Qdrant, ChromaDB, and Weaviate all support this natively. Product quantization (PQ) compresses further (16x-32x) at a more noticeable quality cost, and is useful for billion-scale datasets where memory is the binding constraint.

Similarity Metrics -- Cosine, Dot Product, Euclidean

Once you have vectors, you need a way to measure how "close" two vectors are. The choice of distance metric affects retrieval results and must align with how the embedding model was trained. Using the wrong metric is a common and silent retrieval-quality bug.

Cosine Similarity

Cosine similarity measures the cosine of the angle between two vectors, ignoring their magnitude. It ranges from -1 (opposite) to 1 (identical direction), with 0 meaning orthogonal. For text embeddings, values typically fall in [0.5, 1.0] for related content and approach 1.0 for near-duplicates.

# Cosine similarity formula: cos(A, B) = (A . B) / (||A|| * ||B||) = dot(A, B) / (sqrt(sum(a_i^2)) * sqrt(sum(b_i^2))) # "A . B" is the dot product. ||A|| is the L2 norm (length) of A. # If both vectors are L2-normalized (unit length), then: # cosine(A, B) == dot(A, B) # This is why many vector DBs normalize on insert and use dot product.

Cosine similarity is the default and most common metric for text embeddings. Most sentence-transformer and OpenAI models are trained with cosine similarity as the objective. It is magnitude-invariant, which means a long document and a short query with the same "direction" (topic) will still be considered similar.

Dot Product (Inner Product)

The dot product is the sum of element-wise multiplications: dot(A, B) = sum(a_i * b_i). It does not normalize by vector length. If both vectors are L2-normalized (scaled to unit length), then dot product equals cosine similarity. Many vector databases support a "dot product" metric that, when combined with normalized vectors, is mathematically equivalent to cosine similarity but faster to compute (no division by norms).

Some models (notably certain OpenAI and Cohere models) are trained with dot product as the objective and their vectors are not normalized. For these, use dot product directly. Check the model documentation.

Euclidean Distance (L2)

Euclidean distance is the straight-line distance between two points in space: L2(A, B) = sqrt(sum((a_i - b_i)^2)). It is sensitive to both direction and magnitude. For L2-normalized vectors, Euclidean distance and cosine similarity are monotonically related: L2_dist = sqrt(2 - 2*cosine_sim). This means ranking by one is equivalent to ranking by the other when vectors are normalized.

Euclidean distance is common in computer vision embeddings (e.g., CLIP image embeddings, face recognition embeddings) and some older models. For text, cosine similarity or dot product is almost always preferred.

Metric Formula Range Magnitude Sensitive? Typical Use
Cosine Similarity (A . B) / (||A|| ||B||) [-1, 1]
Dot Product sum(a_i * b_i) (−∞, ∞) Yes Normalized vectors, certain API models
Euclidean (L2) sqrt(sum((a_i − b_i)^2)) [0, ∞) Yes Image embeddings, vision, clustering
Match the Metric to the Model: Using cosine similarity on a model trained with dot product (or vice versa) silently degrades retrieval quality. Always check the model card. Sentence-transformers models in the all-* family are trained with cosine similarity. OpenAI text-embedding-3 models work well with cosine or dot product (they are approximately normalized). Cohere embed-v3 specifies dot product. If unsure, L2-normalize all vectors on insertion and use cosine/dot product -- this is the safe default.

What Are Vector Databases and Why Do They Exist?

A vector database is a specialized data store optimized for inserting, indexing, and querying high-dimensional vectors along with their associated metadata. Traditional databases (PostgreSQL, MySQL, MongoDB) are designed for exact-match lookups, range queries, and relational joins. They use B-tree or hash indexes that are fundamentally unsuited for finding the "nearest" vectors in a 768-dimensional space. Vector databases exist because nearest-neighbor search in high dimensions is a different computational problem that requires different algorithms and data structures.

The Problem: Nearest Neighbor Search in High Dimensions

Given a query vector q and a dataset of N vectors, find the k vectors most similar to q. The brute-force solution is to compute the distance from q to every vector in the dataset, sort, and return the top k. This is O(N * d) per query, where d is dimensionality. For 10 million 768-dim vectors, a single query requires ~7.6 billion floating-point operations. At query time, this is too slow for interactive applications.

Vector databases solve this with Approximate Nearest Neighbor (ANN) algorithms. They pre-build index structures (HNSW, IVF, etc.) that trade a small amount of retrieval accuracy for massive speedups -- often 100x-1000x faster than brute force with 95%+ recall. They also handle the operational concerns: persistence, sharding, replication, metadata filtering, and horizontal scaling.

Vector Database vs. Traditional Database

Capability Traditional DB (PostgreSQL/MySQL) Vector DB (Qdrant/Pinecone/Milvus)
Primary query type Exact match, range, join Nearest-neighbor (similarity) search
Index structure B-tree, hash, GIN HNSW, IVF, PQ, ScaNN
Query complexity O(log N) for indexed lookups O(log N) approximate with HNSW
Handles high-dim vectors?
Metadata filtering Good (varies by engine)
Transactional consistency Eventual to strong (varies)
Best for User accounts, orders, relational data Semantic search, RAG, recommendations
The pgvector Exception: PostgreSQL with the pgvector extension is a hybrid: it adds HNSW and IVFFlat indexes to PostgreSQL, letting you do vector similarity search alongside full SQL queries with ACID guarantees. For datasets up to a few million vectors, pgvector is often the best choice because you get vector search and relational queries in one database with one operational surface. Beyond that scale, dedicated vector databases pull ahead on performance and features.

Core Operations a Vector Database Provides

  • Upsert: Insert or update a vector with an ID, the vector itself, and optional metadata (payload).
  • Search: Given a query vector, return the top-k nearest neighbors with their scores and metadata.
  • Filtered search: Combine vector similarity with metadata filters (e.g., "find similar documents tagged 'finance' from 2025").
  • Delete: Remove vectors by ID or by metadata filter.
  • Collection management: Create named collections with specific dimensionality, distance metric, and index configuration.
  • Quantization: Built-in scalar, product, or binary quantization to reduce memory and speed up search.
  • Persistence and replication: Disk-based storage, snapshots, and multi-node replication for durability.

Vector Database Comparison -- Qdrant, ChromaDB, Pinecone, Weaviate, Milvus, pgvector

The vector database landscape matured rapidly between 2022 and 2026. The six options below cover the mainstream choices for production RAG systems. Each has distinct strengths, deployment models, and operational profiles.

Database Deployment License Index Types Strengths Best For
Qdrant Self-host / Cloud Apache 2.0 HNSW, scalar/int8 quantization, product quantization, binary quantization Fast, Rust-based, excellent filtered search, payload indexinging, quantization built-in
ChromaDB Self-host (embedded or server) Apache 2.0 HNSW (via hnswlib) Simplest setup, Python-native, great for prototyping and local dev Prototyping, local development, small-to-medium datasets
Pinecone Managed Cloud only Proprietary (SaaS) Proprietary (HNSW-based, serverless architecture) Zero ops, serverless scaling, good for teams without infra capacity Teams wanting fully managed, no infrastructure management
Weaviate Self-host / Cloud BSD-3 HNSW, scalar quantization, PQ, binary Built-in modules for embedding generation, GraphQL API, hybrid search All-in-one systems wanting embedded model inference + hybrid search
Milvus Self-host / Cloud (Zilliz) Apache 2.0 HNSW, IVF, IVF_PQ, DiskANN, SCANN, GPU indexes Billion-scale, distributed, GPU-accelerated, most index options
pgvector Self-host (PostgreSQL extension) PostgreSQL License HNSW, IVFFlat Vector + SQL in one DB, ACID, existing Postgres ecosystem

Qdrant -- Detailed Profile

Written in Rust, Qdrant is a performance-oriented vector search engine with first-class support for filtered search. Its standout feature is payload filtering with its own indexing structures, which lets you pre-filter by metadata (tags, dates, categories) before or during vector search without the performance cliff that some engines hit. Qdrant supports scalar quantization (int8), product quantization, and binary quantization natively. It runs as a single binary, a Docker container, or a managed cloud service. The API is REST-based (JSON over HTTP) with gRPC also available. For RAG systems where filtered search is important (e.g., "search only documents from department X dated after Y"), Qdrant is a top recommendation.

ChromaDB -- Detailed Profile

ChromaDB is the easiest vector database to start with. It runs embedded in a Python process (no server needed) or as a client-server setup. The Python API is clean and integrates tightly with LangChain and LlamaIndex. Chroma uses HNSW under the hood (via the hnswlib library) and stores metadata alongside vectors. Its weakness is scale and operational maturity: it is not designed for billion-vector datasets or high-throughput multi-tenant production deployments. For a local RAG prototype or a small internal tool, Chroma is excellent. For a production system serving thousands of concurrent users, evaluate Qdrant, Weaviate, or Milvus instead.

Pinecone -- Detailed Profile

Pinecone is a fully managed, proprietary SaaS vector database. You never touch a server, configure an index, or manage replication. Its serverless architecture (introduced 2024) separates storage and compute, so you pay for what you use rather than provisioning fixed-size pods. Pinecone supports metadata filtering, namespaces for multi-tenancy, and sparse-dense hybrid vectors. The tradeoffs are cost at high query volumes, vendor lock-in, no self-hosting option, and data leaving your infrastructure. Pinecone is ideal for teams that want to ship semantic search fast and have no capacity or desire to operate infrastructure.

Weaviate -- Detailed Profile

Weaviate is an open-source vector search engine (BSD-3 license) with a distinctive feature: vectorizer modules that can generate embeddings server-side. You can configure a Weaviate class to use, say, the text2vec-transformers module, and Weaviate will call a local or external model to embed your text on insertion -- you just send text, not vectors. Weaviate also has built-in hybrid search (BM25 + vector) and a GraphQL API. It supports HNSW, scalar quantization, product quantization, and binary quantization. Weaviate is a strong choice when you want the database to handle embedding generation and hybrid search in one system.

Milvus -- Detailed Profile

Milvus (created by Zilliz) is the choice for very large scale. It is a distributed, cloud-native vector database designed for datasets from hundreds of millions to billions of vectors. Milvus supports the most index types of any engine listed here: HNSW, IVF (flat, PQ, SQ8), DiskANN, SCANN, and GPU-accelerated indexes (GPU_CAGRA, GPU_IVF). Its architecture separates storage and compute, supports sharding and replication, and has a mature ecosystem (Attu for management UI, Zilliz Cloud for managed service). The operational complexity is higher than Qdrant or Chroma -- Milvus typically runs as a multi-component distributed system (etcd, MinIO, Pulsar/Kafka, and Milvus nodes). For datasets under 10M vectors, this complexity is usually not justified. For 100M+ vectors with high throughput, Milvus is the leading open-source option.

pgvector -- Detailed Profile

pgvector is a PostgreSQL extension that adds a vector type and two index types: HNSW (recommended, added in pgvector 0.5.0) and IVFFlat. You store vectors in a regular Postgres table alongside any other columns, and you query them with SQL: SELECT * FROM docs ORDER BY embedding <=> '[0.1, 0.2, ...]' LIMIT 10;. The <=> operator is Euclidean distance, <#> is negative inner product, and <~> is cosine distance. pgvector gives you vector search plus the full power of SQL (joins, aggregations, transactions, row-level security) in one database. For datasets up to roughly 1-5 million vectors, pgvector with HNSW is performant and operationally simple. Beyond that, dedicated vector databases offer better memory management, quantization, and distributed scaling.

Decision Heuristic: Prototyping / local dev: ChromaDB. Already on Postgres, < 5M vectors: pgvector. Production self-hosted, filtered search important: Qdrant. Want built-in embedding + hybrid search: Weaviate. Fully managed, no ops: Pinecone. Billion-scale, distributed: Milvus. This is a starting point, not a mandate -- benchmark on your own data and query patterns before committing.

Index Types -- HNSW, IVF, and Brute-Force Explained

The index is the data structure that makes similarity search fast. Without an index, every query scans the entire dataset (brute force / flat search). With an index, queries touch a small fraction of vectors and return approximate results with high recall. Understanding index types is essential because they control the speed-recall-memory tradeoff in your vector database.

Brute-Force (Flat) Index

The flat index is no index at all: the query vector is compared against every vector in the dataset, distances are computed for all, and the top-k are returned. This is exact -- 100% recall by definition. The cost is O(N * d) per query. For small datasets (up to ~10,000-100,000 vectors depending on dimensionality and latency budget), brute force is fast enough and guarantees perfect results. Many vector databases offer a "flat" or "exact" search mode for this reason. Beyond that size, you need an approximate index.

Brute force is also the ground truth for benchmarking: you compare ANN index recall against flat search results to measure how many true top-k neighbors the index finds.

HNSW (Hierarchical Navigable Small World)

HNSW is the dominant ANN index in production vector databases as of 2026. It is a graph-based index inspired by the "small world" network concept. Vectors are nodes in a multi-layer graph. The top layers contain few nodes with long-range connections; the bottom layer contains all nodes with short-range connections. A query starts at a top-layer entry point, greedily moves toward the query vector, and descends layer by layer, refining the search at each level. This hierarchical structure gives logarithmic-time search with excellent recall.

Key HNSW parameters:

  • M (default 16): The number of bi-directional links created for each new element during construction. Higher M = more memory, better recall, slower construction. Typical range: 12-48.
  • ef_construction (default 200): The size of the dynamic candidate list during index construction. Higher = better index quality, slower build. Typical range: 100-500.
  • ef_search (default varies): The size of the dynamic candidate list during search. Higher = better recall, slower query. This can be tuned per query in most engines. Typical range: 50-500.

HNSW strengths: excellent recall-speed tradeoff, fast queries (sub-millisecond at moderate scale), no training step required (unlike IVF), incremental insertion is efficient. HNSW weaknesses: high memory usage (the graph adds 1.5x-2x on top of raw vector storage), index does not fit in memory well for billion-scale datasets, and filtered search can be tricky (the graph may not respect filters, leading to post-filtering recall drops).

HNSW Tuning Heuristic: Start with M=16, ef_construction=200, ef_search=100. Measure recall@10 against flat search on a validation set. If recall is below your target (e.g., 0.95), increase ef_search first (cheapest). If still insufficient, increase M and rebuild. For high-throughput production, M=32 with ef_search=64-128 is a common sweet spot. Memory-constrained deployments should lower M and rely more on ef_search.

IVF (Inverted File Index)

IVF works by clustering the dataset into nlist cells using k-means. At query time, the query vector is compared to the cluster centroids, and only vectors in the nearest nprobe clusters are searched (flat search within those clusters). This reduces the search space from N to roughly N * nprobe / nlist.

Key IVF parameters:

  • nlist: Number of clusters/centroids. Rule of thumb: sqrt(N) to 4*sqrt(N). For 1M vectors, nlist = 1000-4000.
  • nprobe: Number of clusters to search at query time. Higher = better recall, slower. Typical: nlist/100 to nlist/10.

IVF requires a training step (k-means clustering on a representative sample) before insertion. This is a key difference from HNSW, which builds incrementally. IVF variants include IVF-Flat (exact search within probed clusters) and IVF-PQ (vectors within clusters are product-quantized for further compression). IVF-PQ is the most memory-efficient option for very large datasets, at the cost of lower recall.

IVF strengths: lower memory than HNSW (no graph overhead), good for very large datasets, supports quantization variants. IVF weaknesses: requires training, slower queries than HNSW at equal recall on most workloads, insertion after training can degrade cluster quality over time (may require re-training).

Other Notable Index Types

  • DiskANN: A disk-based index for datasets too large to fit in RAM. Stores the graph on SSD and loads only the needed portions per query. Milvus and Weaviate support it. Trades query latency for the ability to search billion-scale datasets on machines with limited RAM.
  • ScaNN: Google's ANN library, notable for anisotropic quantization. Available in Milvus. Strong performance on certain benchmarks.
  • GPU-CAGRA / GPU-IVF: GPU-accelerated indexes in Milvus (via RAPIDS cuVS). For ultra-low-latency, high-throughput search on GPU-equipped infrastructure.
Index Recall Speed Memory Build Time Training Required? Best Scale
Flat (brute force) Low (vectors only) No < 100K vectors
HNSW Moderate No Up to ~100M (RAM-limited)
IVF-Flat Good (90-98%) Fast Low Moderate + training Yes (k-means) 1M - 100M
IVF-PQ Moderate (85-95%) Fast Moderate + training Yes 100M - 1B+
DiskANN High (95-98%) Medium (SSD-bound) Slow Yes 1B+ (disk-resident)

How Embeddings Power RAG -- The Retrieval Pipeline Step by Step

Retrieval-Augmented Generation (RAG) is the dominant architecture for grounding LLMs in external knowledge. The retrieval layer -- powered by embeddings and a vector database -- is what distinguishes RAG from pure prompt engineering. Here is the complete pipeline, broken into its ingestion and query phases.

Phase 1: Ingestion (Done Once, Updated Periodically)

Step 1 -- Document Loading: Read source documents from their original locations: PDFs, HTML pages, Word docs, Confluence pages, databases, Notion pages, GitHub repos. Each document becomes a Document object with text content and metadata (source URL, title, author, date, department, access level).

Step 2 -- Chunking: Split documents into smaller passages (chunks) that fit within the embedding model's context window and the LLM's context budget. Chunking strategy (covered in detail in the next section) heavily affects retrieval quality. Typical chunk sizes: 200-1000 tokens with 10-20% overlap.

Step 3 -- Embedding Generation: Pass each chunk through the embedding model to produce a vector. This is a batch operation -- chunks are sent in batches of 32-256 to the embedding model (local or API) to maximize throughput. Each chunk now has: an ID, the vector, the chunk text, and metadata inherited from the parent document.

Step 4 -- Vector Database Insertion: Upsert each (ID, vector, payload) tuple into the vector database. The payload includes the chunk text, source document metadata, and any precomputed filter fields. The vector database builds or updates its HNSW index as vectors arrive.

Step 5 -- Index Optimization: After a bulk load, optionally tune index parameters (HNSW M, ef_construction, quantization settings) based on the dataset size and target latency. For incremental updates, this is done periodically rather than per-insert.

Phase 2: Query (Done Per User Request)

Step 1 -- Query Embedding: The user's question is passed through the same embedding model used during ingestion. Using a different model for queries than for ingestion is a fatal retrieval bug -- the vectors live in different spaces and similarity is meaningless. The output is a single query vector.

Step 2 -- Metadata Filter Construction (Optional): If the user's query implies a scope (e.g., "What did our finance team say about Q4 earnings?"), construct a metadata filter: {"department": "finance", "date": {"$gte": "2025-10-01"}}. This narrows the search space before vector similarity is computed.

Step 3 -- Vector Search: Send the query vector to the vector database with parameters: top-k (e.g., 10-20), the similarity metric, optional filter, and search-time index parameters (ef_search for HNSW, nprobe for IVF). The database returns the k most similar chunks with their scores and payloads.

Step 4 -- Re-ranking (Optional but Recommended): Retrieve a larger top-k (e.g., 50-100) from the vector database, then re-rank them with a cross-encoder model (e.g., bge-reranker-large, Cohere Rerank). Cross-encoders score query-document pairs jointly (not via embeddings) and are more accurate than pure vector similarity. Re-ranking the top 50-100 down to the final 5-10 significantly improves end-to-end RAG quality.

Step 5 -- Context Assembly: Take the final top-k chunks (after re-ranking), concatenate their text with source citations, and insert them into the LLM prompt under a "Context" or "Retrieved Documents" section. The prompt also includes the user's question and instructions to answer based on the context.

Step 6 -- LLM Generation: The LLM reads the context and question, generates an answer grounded in the retrieved chunks, and (if instructed) cites the sources. The answer is returned to the user along with the source citations for verification.

# RAG retrieval pipeline (pseudo-code): # --- INGESTION (one-time or periodic) --- documents = load_documents("knowledge_base/") chunks = chunk_documents(documents, strategy="recursive", size=512, overlap=64) vectors = embedding_model.encode([c.text for c in chunks], batch_size=64) vector_db.upsert(collection="docs", points=[ {"id": c.id, "vector": v, "payload": {"text": c.text, "source": c.source, ...}} for c, v in zip(chunks, vectors) ]) # --- QUERY (per user request) --- query = "How do I reset my two-factor authentication?" query_vector = embedding_model.encode(query) # SAME model! filter = {"category": {"$eq": "security"}} # optional metadata filter results = vector_db.search( collection="docs", query_vector=query_vector, limit=50, # retrieve more for re-ranking query_filter=filter ) # Re-rank with cross-encoder reranked = reranker.rank(query, [r.payload["text"] for r in results], top_k=5) # Assemble context and call LLM context = "\n\n".join([f"[{i+1}] {r.text}" for i, r in enumerate(reranked)]) prompt = f"Answer the question using only the context below.\n\nContext:\n{context}\n\nQuestion: {query}" answer = llm.generate(prompt)
🎯 The Retrieval Quality Ceiling: The embedding model sets the ceiling for what your RAG system can retrieve. No amount of re-ranking, prompt engineering, or LLM sophistication can recover a relevant chunk that the embedding model failed to surface from the vector database. If your RAG system is "hallucinating" or "missing obvious answers," the first place to look is the embedding model and chunking strategy -- not the LLM. Benchmark retrieval (recall@k) independently of generation quality.

Chunking Strategies for Documents

Chunking is how you split documents into the passages that get embedded and stored. It is one of the most impactful and most neglected levers in RAG system quality. Bad chunking destroys retrieval even with a perfect embedding model: a chunk that spans two unrelated topics will not match either query well, and a chunk that cuts a sentence in half loses meaning. Good chunking preserves semantic coherence within each chunk.

Fixed-Size Chunking (Token or Character Based)

The simplest strategy: split text into chunks of N tokens (or N characters) with an optional overlap. Common settings: 512 tokens with 64-token overlap. Fixed-size chunking is easy to implement and predictable. Its weakness is that it ignores semantic boundaries -- a chunk may start or end mid-sentence, fragmenting the meaning. Overlap (10-20% of chunk size) mitigates but does not solve this. Fixed-size chunking is a reasonable default for homogeneous text (articles, manuals) and is the baseline most frameworks use.

Sentence-Based Chunking

Split text at sentence boundaries, then group sentences into chunks up to a target size. This preserves sentence-level semantics -- no chunk cuts a sentence in half. Use a sentence tokenizer (NLTK, spaCy, or the simpler regex-based splitter in LangChain) to identify boundaries first. Sentence chunking is better than fixed-size for prose but can still group unrelated sentences if a paragraph shifts topics mid-way.

Paragraph-Based Chunking

Split at paragraph boundaries. Paragraphs are natural semantic units -- an author typically groups related sentences into a paragraph. For well-structured documents (reports, articles, documentation), paragraph chunking often produces the most coherent chunks. The challenge is variable chunk size: some paragraphs are one sentence, others are 500 words. You can merge small adjacent paragraphs or split large ones with sentence-level fallback to stay within a target size range.

Recursive / Structural Chunking

LangChain's RecursiveCharacterTextSplitter is the most widely used approach in practice. It splits on a hierarchy of separators (e.g., ["\n\n", "\n", ". ", " ", ""]) -- first by paragraphs, then by lines, then by sentences, then by characters -- until chunks fit the target size. This respects document structure as much as possible while guaranteeing a size bound. It is the recommended default for most RAG ingestion pipelines.

Semantic Chunking

A more advanced strategy: embed each sentence, then split at points where the cosine similarity between adjacent sentences drops below a threshold (often the 95th percentile of similarity drops). This produces chunks that are semantically coherent -- each chunk covers one "topic" as detected by embedding similarity drift. Semantic chunking requires an extra embedding pass during ingestion (embed every sentence first, then chunk), which adds cost. Research and practitioner reports suggest it can outperform fixed-size chunking on complex documents, but the gain is modest on simple text and the cost is non-trivial.

Document-Specific Chunking

  • Markdown / HTML: Split on headers (H1, H2, H3) to preserve structural sections. A chunk under an "Installation" heading is semantically about installation.
  • Code: Split on function/class boundaries. Each function is a natural chunk. Use language-aware splitters (tree-sitter based).
  • PDFs: Use layout-aware extractors (unstructured, PyMuPDF) that respect columns, tables, and figure captions. Naive text extraction from PDFs often interleaves columns and produces garbage chunks.
  • Tables: Embed the table as text (CSV or markdown) or use a table-specific embedding approach. Do not split a table across chunks.
Strategy Coherence Complexity Cost Best For
Fixed-Size Low (may cut sentences) Homogeneous text, baseline
Sentence-Based Medium Simple Prose, articles
Paragraph-Based Simple Well-structured documents
Recursive Simple
Semantic Complex High (extra embedding pass) Complex multi-topic docs
Document-Specific Medium-High Medium Code, Markdown, PDFs, tables
Practical Chunking Advice: Start with recursive chunking at 512 tokens, 64-token overlap. Benchmark retrieval quality (do the right chunks come back for a set of test queries?). If retrieval misses are due to chunks containing multiple topics, try smaller chunks (256 tokens) or semantic chunking. If misses are due to chunks being too small to contain a full answer, increase chunk size or use parent-document retrieval (retrieve small chunks, return their parent document or larger enclosing section). Always include source document metadata in each chunk's payload so you can trace and debug retrieval results.
The Overlap Trap: Overlap reduces the chance of cutting a key sentence at a boundary, but it also means the same text appears in multiple chunks. This inflates storage, can cause duplicate retrieval results, and skews toward returning overlapping chunks. Use 10-20% overlap, not more. If you find yourself needing large overlaps, your chunk size is probably too small for the content -- increase chunk size instead.

CLI & Code Examples

1. Generating Embeddings with curl (OpenAI API)

The OpenAI embeddings API is the fastest way to get a working embedding pipeline. Send text, receive a vector.

# Generate an embedding with OpenAI text-embedding-3-small via curl curl https://api.openai.com/v1/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "model": "text-embedding-3-small", "input": "How do I reset my two-factor authentication?" }' # Response (truncated -- the real vector has 1536 dimensions): # { # "object": "list", # "data": [{ # "object": "embedding", # "index": 0, # "embedding": [0.0023, -0.0094, 0.0215, ..., 0.0041] # }], # "model": "text-embedding-3-small", # "usage": {"prompt_tokens": 9, "total_tokens": 9} # } # Shorten to 512 dimensions (Matryoshka / MRL): curl https://api.openai.com/v1/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "model": "text-embedding-3-small", "input": "How do I reset my two-factor authentication?", "dimensions": 512 }'

2. Generating Embeddings with Python (Self-Hosted sentence-transformers)

# Install: pip install sentence-transformers from sentence_transformers import SentenceTransformer # Load a 384-dim model (fast, CPU-friendly) model = SentenceTransformer("all-MiniLM-L6-v2") # Embed a single query query = "How do I reset my two-factor authentication?" query_embedding = model.encode(query, normalize_embeddings=True) print(f"Shape: {query_embedding.shape}") # (384,) print(f"First 5 dims: {query_embedding[:5]}") # Embed a batch of document chunks (much more efficient than one-by-one) chunks = [ "To reset two-factor authentication, navigate to Settings > Security.", "Two-factor authentication can be disabled from the account security page.", "Password reset links expire after 24 hours.", "Contact support if you lose access to your authenticator device." ] # batch_size controls memory usage; 32-128 is typical chunk_embeddings = model.encode( chunks, batch_size=32, normalize_embeddings=True, show_progress_bar=True ) print(f"Batch shape: {chunk_embeddings.shape}") # (4, 384) # Compute cosine similarity between query and all chunks import numpy as np similarities = chunk_embeddings @ query_embedding # dot product = cosine (normalized) for chunk, sim in sorted(zip(chunks, similarities), key=lambda x: -x[1]): print(f"{sim:.4f} {chunk}")

3. Storing Vectors in Qdrant (Python)

# Install: pip install qdrant-client # Start Qdrant locally: docker run -p 6333:6333 qdrant/qdrant from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct from sentence_transformers import SentenceTransformer client = QdrantClient(url="http://localhost:6333") model = SentenceTransformer("all-MiniLM-L6-v2") # Create a collection with 384-dim cosine distance client.recreate_collection( collection_name="knowledge_base", vectors_config=VectorParams(size=384, distance=Distance.COSINE) ) # Prepare chunks with metadata chunks = [ {"id": 1, "text": "To reset 2FA, go to Settings > Security > Reset 2FA.", "source": "security-guide.pdf", "category": "security", "page": 12}, {"id": 2, "text": "Password reset links expire after 24 hours.", "source": "security-guide.pdf", "category": "security", "page": 15}, {"id": 3, "text": "Contact IT support for hardware token replacement.", "source": "it-handbook.docx", "category": "it", "page": 8}, ] # Generate embeddings in a single batch vectors = model.encode([c["text"] for c in chunks], normalize_embeddings=True) # Upsert points with payload (metadata + chunk text) points = [ PointStruct( id=c["id"], vector=v.tolist(), payload={"text": c["text"], "source": c["source"], "category": c["category"], "page": c["page"]} ) for c, v in zip(chunks, vectors) ] client.upsert(collection_name="knowledge_base", points=points) print(f"Inserted {len(points)} vectors into Qdrant.")

4. Similarity Search in Qdrant (with Metadata Filtering)

from qdrant_client import QdrantClient from qdrant_client.models import Filter, FieldCondition, MatchValue from sentence_transformers import SentenceTransformer client = QdrantClient(url="http://localhost:6333") model = SentenceTransformer("all-MiniLM-L6-v2") query = "How do I reset my two-factor authentication?" query_vector = model.encode(query, normalize_embeddings=True).tolist() # Search WITHOUT a filter (pure semantic search) results = client.search( collection_name="knowledge_base", query_vector=query_vector, limit=5, with_payload=True ) for r in results: print(f"Score: {r.score:.4f} | {r.payload['text']} | source: {r.payload['source']}") # Search WITH a metadata filter (only security category) # This is Qdrant's key strength -- filtered vector search security_filter = Filter( must=[FieldCondition(key="category", match=MatchValue(value="security"))] ) filtered_results = client.search( collection_name="knowledge_base", query_vector=query_vector, query_filter=security_filter, limit=5, with_payload=True ) print("\n--- Filtered to security category ---") for r in filtered_results: print(f"Score: {r.score:.4f} | {r.payload['text']}") # Tune HNSW ef_search at query time for higher recall from qdrant_client.models import SearchParams high_recall_results = client.search( collection_name="knowledge_base", query_vector=query_vector, limit=5, search_params=SearchParams(hnsw_ef=256, exact=False) # higher ef = better recall )

5. Full RAG Pipeline in Python (Qdrant + OpenAI LLM)

# pip install qdrant-client sentence-transformers openai from qdrant_client import QdrantClient from sentence_transformers import SentenceTransformer from openai import OpenAI import textwrap # --- Setup --- qdrant = QdrantClient(url="http://localhost:6333") embedder = SentenceTransformer("all-MiniLM-L6-v2") # 384-dim, self-hosted llm = OpenAI() # for generation COLLECTION = "knowledge_base" def rag_query(question: str, top_k: int = 5) -> str: # 1. Embed the question with the SAME model used for ingestion q_vec = embedder.encode(question, normalize_embeddings=True).tolist() # 2. Search the vector database for top-k similar chunks hits = qdrant.search( collection_name=COLLECTION, query_vector=q_vec, limit=top_k, with_payload=True ) # 3. Assemble context from retrieved chunks context_parts = [] for i, hit in enumerate(hits, 1): text = hit.payload["text"] source = hit.payload.get("source", "unknown") context_parts.append(f"[{i}] (source: {source}) {text}") context = "\n\n".join(context_parts) # 4. Build the prompt and call the LLM prompt = f"""You are a helpful assistant. Answer the user's question using ONLY the context provided below. If the context does not contain the answer, say you don't know. Cite sources as [1], [2], etc. Context: {context} Question: {question} Answer:""" response = llm.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.0 ) return response.choices[0].message.content # --- Run it --- answer = rag_query("How do I reset my two-factor authentication?") print(textwrap.fill(answer, width=80))

6. pgvector Example (PostgreSQL + SQL)

# In PostgreSQL (after installing pgvector extension): # Enable the extension CREATE EXTENSION IF NOT EXISTS vector; # Create a table with a vector column (384 dimensions) CREATE TABLE documents ( id SERIAL PRIMARY KEY, content TEXT, source TEXT, category TEXT, embedding vector(384) ); # Create an HNSW index for cosine distance CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 200); # Insert a vector (embed the text in your application, then insert) INSERT INTO documents (content, source, category, embedding) VALUES ('To reset 2FA, go to Settings > Security.', 'security-guide.pdf', 'security', '[0.021, -0.014, 0.083, ...]'); # 384 floats # Similarity search (cosine distance, top 5) # Provide the query embedding from your application SELECT id, content, source, 1 - (embedding <=> '[0.015, -0.009, 0.077, ...]') AS cosine_similarity FROM documents WHERE category = 'security' # metadata filter via standard SQL ORDER BY embedding <=> '[0.015, -0.009, 0.077, ...]' # cosine distance LIMIT 5; # Tune ef_search at query time (per session): SET hnsw.ef_search = 200;

Practical Performance Considerations

Batch Size for Embedding Generation

Always batch your embedding requests. Embedding one text at a time is 10x-50x slower than batching, because the model's forward pass amortizes overhead across the batch. For self-hosted sentence-transformers on GPU, batch sizes of 64-256 are typical. For CPU-only inference, 32-64 is more appropriate (larger batches do not help much on CPU and consume more memory). For OpenAI's API, batch up to 2048 texts per request (the API limit) -- this also reduces per-request overhead and network round-trips.

# Self-hosted (GPU): batch_size=128 is a good default embeddings = model.encode(texts, batch_size=128, show_progress_bar=True) # OpenAI API: send up to 2048 inputs per request response = client.embeddings.create( model="text-embedding-3-small", input=texts[:2048] # batch of up to 2048 )

Dimension Reduction Strategies

If storage or memory is a constraint, reduce dimensions in this order of preference:

  1. Use a smaller model: Switch from a 1536-dim OpenAI model to a 384-dim MiniLM or 768-dim BGE model. This is the cleanest reduction -- you embed at lower dimensions from the start.
  2. Matryoshka truncation (MRL models only): For OpenAI text-embedding-3-* or MRL-trained open models, set the dimensions parameter to 512 or 256. Safe, model-supported, minimal quality loss.
  3. Scalar quantization (int8): Keep all dimensions but store each float as an 8-bit integer. 4x storage reduction, negligible recall loss. Supported natively by Qdrant, Weaviate, Milvus.
  4. Product quantization (PQ): Compress vectors into subvectors encoded by codebooks. 16x-32x reduction, more noticeable recall loss. Use for billion-scale where memory is the binding constraint.
  5. PCA (post-hoc): Fit PCA on your dataset and reduce 768-dim to 256-dim. Works on any model but requires fitting on your data and adds a transform step. Less clean than model-native shortening.

Metadata Filtering -- The Hidden Performance Lever

Metadata filtering is one of the most impactful yet underused techniques in production vector search. Instead of searching across all vectors, you pre-filter by metadata (date range, category, user ID, access level) and search only the filtered subset. This improves both relevance (the user only sees results from their scope) and performance (fewer vectors to search).

The challenge is that HNSW graphs are built without knowledge of filters. A naive approach (retrieve top-k by similarity, then drop results that fail the filter) can return zero results if none of the top-k match the filter. This is called the post-filtering recall problem. Vector databases handle this differently:

  • Qdrant: Uses payload indexing with filterable HNSW. Can do pre-filtering (search only within filtered subset) efficiently. This is Qdrant's signature strength.
  • Weaviate: Supports inverted index on properties combined with HNSW, enabling efficient filtered search.
  • Pinecone: Supports metadata filtering with its own optimized approach; very fast for selective filters.
  • pgvector: Filtering is just a SQL WHERE clause. PostgreSQL's planner handles it naturally. For highly selective filters, Postgres may choose to skip the HNSW index and do a filtered scan instead.
Test Your Filters: Always test filtered search with highly selective filters (matching < 1% of the dataset). If your vector database returns empty results or very low recall with selective filters, you are hitting the post-filtering problem. Qdrant and Weaviate handle this well; other engines may need configuration changes or a different approach (e.g., separate collections per tenant for multi-tenant systems).

Latency Budgets and Sizing

Dataset Size Recommended Index Expected Query Latency RAM Needed (768-dim, HNSW) Engine Suggestion
1K - 100K Flat (exact) or HNSW < 1ms < 1 GB pgvector, Chroma, Qdrant
100K - 1M HNSW 1 - 5ms 1 - 10 GB Qdrant, Weaviate, pgvector
1M - 10M HNSW + int8 quantization 2 - 10ms 5 - 50 GB
10M - 100M HNSW + PQ, or IVF-PQ 5 - 20ms 20 - 200 GB Qdrant, Milvus
100M - 1B+ DiskANN or IVF-PQ (distributed) 10 - 50ms Disk-based + sharded

Monitoring and Benchmarking

In production, track these metrics continuously:

  • Query latency (p50, p95, p99): Set alerts on p99 latency. If it drifts upward, your index may need tuning or your dataset has outgrown your index parameters.
  • Recall@k: Periodically run a set of known queries with known-good results and measure what fraction the vector search returns. A recall drop signals index degradation or a model change.
  • Index build time: Track how long bulk loads take. If it grows non-linearly with dataset size, you may be hitting a configuration issue.
  • Memory usage: HNSW is memory-resident. Monitor RAM and swap. If you start swapping, latency will spike dramatically.
  • Filter selectivity: Log what fraction of your queries use filters and how selective they are. This informs whether you need payload indexing or collection partitioning.
🎯 The Performance Checklist: 1. Batch your embedding requests (64-256 per batch). 2. Normalize vectors and use cosine/dot product. 3. Start with HNSW (M=16, ef_construction=200, ef_search=100). 4. Enable int8 quantization when memory matters. 5. Use metadata filters to narrow search scope. 6. Add a cross-encoder re-ranker for the final 5-10 results. 7. Benchmark recall@k independently of generation quality. 8. Monitor p99 latency and index memory. 9. Re-tune index parameters when dataset size grows 10x. 10. Never change the embedding model without re-embedding the entire corpus.

References & Further Reading

Source Attribution

Source: Avondale.AI Technical Documentation Library
Category: Core AI Concepts
Type: Technical Reference Guide
License: CC BY 4.0

Foundational Papers

  • Mikolov et al. (2013). "Efficient Estimation of Word Representations in Vector Space." arXiv:1301.3781 -- The Word2Vec paper.
  • Pennington, Socher, Manning (2014). "GloVe: Global Vectors for Word Representation." EMNLP 2014 -- The GloVe paper.
  • Devlin et al. (2019). "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." arXiv:1810.04805 -- The BERT paper.
  • Reimers & Gurevych (2019). "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks." arXiv:1908.10084 -- The Sentence Transformers paper.
  • Malkov & Yashunin (2018). "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs." arXiv:1603.09320 -- The HNSW paper.
  • Jegou, Douze, Schmid (2011). "Product Quantization for Nearest Neighbor Search." IEEE TPAMI -- The PQ paper.
  • Lewis et al. (2020). "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks." arXiv:2005.11401 -- The original RAG paper.
  • Karpukhin et al. (2020). "Dense Passage Retrieval for Open-Domain Question Answering." arXiv:2004.04906 -- DPR, dual-encoder retrieval.
  • Kusupati et al. (2022). "Matryoshka Representation Learning." arXiv:2205.13147 -- MRL, dimension shortening.
  • Jayaram Subramanya et al. (2019). "DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node." NeurIPS -- Disk-based ANN.

Vector Databases & Tools

Further Reading

Suggested Citation:
Embeddings and Vector Databases Explained: The Semantic Layer That Powers Modern AI Search and RAG. Avondale.AI Technical Documentation Library. Accessed June 2026. https://avondale.ai/technical/embeddings-vector-databases.html