Vector stores
Once your chunks are embedded, you need somewhere to keep them and a fast way to find the nearest vectors to a query. That is the job of a vector store. This lesson builds one from scratch so you understand exactly what it does, explains how approximate indexes make search fast at scale, and then shows the same operations in popular tools — Chroma, PostgreSQL with pgvector, and FAISS.
Definitions
A system that stores vectors together with ids, original text, and metadata, and answers nearest-neighbour queries: “return the k stored vectors most similar to this query vector”, optionally filtered by metadata.
Finding the k items whose vectors are closest to the query vector. Exact kNN compares the query with every stored vector — accurate but slow at scale.
Index structures that find almost the nearest vectors while examining only a small fraction of them, trading a little accuracy (recall) for orders-of-magnitude speed. HNSW and IVF are the most common.
Build a vector store from scratch
Below is a complete in-memory vector store: add records, search with optional metadata filters, delete, and save to disk. Production systems add indexing and durability, but the interface is the same.
import hashlib
import re
import numpy as np
STOPWORDS = {"a", "an", "the", "is", "are", "be", "can", "do", "does", "i", "my", "to",
"of", "in", "on", "for", "and", "or", "what", "how", "it", "you", "your", "at"}
def words(text: str) -> list[str]:
"""Lowercase, drop stopwords, and strip a few suffixes (refunded -> refund)."""
out = []
for w in re.findall(r"[a-z0-9-]+", text.lower()):
if w in STOPWORDS:
continue
for suffix in ("ing", "ed", "s"):
if w.endswith(suffix) and len(w) > len(suffix) + 3:
w = w[: -len(suffix)]
break
out.append(w)
return out
def embed(text: str, dim: int = 256) -> np.ndarray:
"""Toy embedding: hashed bag of words, L2-normalised. Real models capture meaning."""
vec = np.zeros(dim, dtype=np.float32)
for word in words(text):
bucket = int(hashlib.md5(word.encode()).hexdigest(), 16) % dim
vec[bucket] += 1.0
norm = np.linalg.norm(vec)
return vec / norm if norm else vec
class VectorStore:
def __init__(self, dim: int = 256):
self.dim = dim
self.ids: list[str] = []
self.records: list[dict] = []
self.vectors = np.zeros((0, dim), dtype=np.float32)
def add(self, records: list[dict]) -> None:
new = np.stack([embed(r["text"], self.dim) for r in records])
self.vectors = np.vstack([self.vectors, new])
self.records.extend(records)
self.ids.extend(r["id"] for r in records)
def search(self, query: str, k: int = 3, where: dict | None = None) -> list[dict]:
scores = self.vectors @ embed(query, self.dim)
order = np.argsort(-scores)
hits = []
for i in order:
rec = self.records[i]
if where and any(rec.get(key) != value for key, value in where.items()):
continue # metadata filter
hits.append({"id": rec["id"], "score": round(float(scores[i]), 3), "text": rec["text"]})
if len(hits) == k:
break
return hits
def delete(self, record_id: str) -> None:
i = self.ids.index(record_id)
self.vectors = np.delete(self.vectors, i, axis=0)
del self.ids[i], self.records[i]
def __len__(self) -> int:
return len(self.ids)
store = VectorStore()
store.add([
{"id": "kb-1", "text": "Annual plans can be refunded within 30 days.", "product": "vault", "lang": "en"},
{"id": "kb-2", "text": "Backups run nightly and are kept for 90 days.", "product": "vault", "lang": "en"},
{"id": "kb-3", "text": "Sync plans can be refunded within 14 days.", "product": "sync", "lang": "en"},
{"id": "kb-4", "text": "Two-factor authentication is required for admins.", "product": "vault", "lang": "en"},
])
print(len(store), "records")
for hit in store.search("refund for an annual plan", k=2):
print(hit)
print("filtered:", store.search("refund for an annual plan", k=1, where={"product": "sync"}))
The metadata filter is what lets one index serve many products, tenants, or permission levels: the same question returns the Sync policy only when the user is asking about Sync.
store.delete("kb-3")
print(len(store), "records;", [h["id"] for h in store.search("refund", k=3)])
np.save("vectors.npy", store.vectors) # persist vectors
print(np.load("vectors.npy").shape)
Why exact search stops scaling
Our store compares the query with every vector: one million chunks × 1,536 dimensions is about 1.5 billion multiplications per query. That is fine for thousands of chunks and painful for millions, especially with many concurrent users.
The most popular ANN index. It builds a multi-layer graph where each vector links to its near neighbours. Search starts at a sparse top layer, greedily hops towards the query, and descends to denser layers — visiting only a tiny fraction of all vectors. Parameters such as M (links per node) and ef_search (search breadth) trade speed for recall.
Clusters vectors into many groups; at query time only the few clusters nearest to the query are searched. Often combined with quantisation (PQ) to compress vectors.
| Index | Speed | Memory | Recall | Use when |
|---|---|---|---|---|
| Flat (exact) | Slow at scale | Full vectors | 100% | Up to ~100k vectors, or as a quality baseline |
| HNSW | Very fast | Higher (graph links) | ~95–99%+ | The default for most RAG workloads |
| IVF / IVF-PQ | Fast | Low with PQ | Tunable | Very large corpora, memory-constrained |
Choosing a vector store
| Option | Type | Strengths | Consider when |
|---|---|---|---|
| Chroma | Embedded / server, open source | Easiest to start, persistent, metadata filters | Prototypes and small-to-medium apps |
| PostgreSQL + pgvector | Extension to your existing database | One database for app data and vectors; SQL filters, joins, transactions; HNSW indexes | You already run Postgres — very often the pragmatic choice |
| FAISS | Library (no server) | Extremely fast, many index types, GPU support | Research, batch jobs, custom services |
| Qdrant, Weaviate, Milvus | Dedicated vector databases | Scale, rich filtering, hybrid search, multi-tenancy | Large or high-traffic deployments |
| Pinecone, managed cloud offerings | Fully managed service | No operations | Teams that prefer not to run infrastructure |
| Elasticsearch / OpenSearch | Search engine with vectors | Mature keyword search + vectors in one place | You already use them for search |
Chroma
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="kb", metadata={"hnsw:space": "cosine"})
collection.upsert(
ids=["kb-1", "kb-2"],
documents=["Annual plans can be refunded within 30 days.", "Backups run nightly and are kept for 90 days."],
metadatas=[{"product": "vault"}, {"product": "vault"}],
) # Chroma embeds documents with its default local model unless you pass embeddings=
results = collection.query(query_texts=["how do refunds work"], n_results=1, where={"product": "vault"})
print(results["ids"][0], results["documents"][0])
PostgreSQL with pgvector
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id text PRIMARY KEY,
source text NOT NULL,
product text,
content text NOT NULL,
embedding vector(1536) NOT NULL,
updated_at timestamptz DEFAULT now()
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
-- top 5 chunks for a query vector, filtered by product
-- (<=> is cosine distance, so 1 - distance = similarity)
SELECT id, source, content, 1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE product = 'vault'
ORDER BY embedding <=> $1
LIMIT 5;
With pgvector, vectors sit next to your normal relational data, so permission checks, joins, and transactions work exactly as in the rest of your application.
FAISS
import faiss
import numpy as np
dim = 384
vectors = np.random.default_rng(0).random((10_000, dim), dtype=np.float32)
faiss.normalize_L2(vectors) # cosine via inner product
index = faiss.IndexHNSWFlat(dim, 32, faiss.METRIC_INNER_PRODUCT)
index.add(vectors)
query = vectors[:1].copy()
scores, ids = index.search(query, 3)
print("nearest id:", ids[0][0], "| similarity:", round(float(scores[0][0]), 3))
Querying with a stored vector returns that same vector as its nearest neighbour, with similarity 1.0 — a quick sanity check for any index. FAISS stores only vectors and integer ids; you keep text and metadata in a separate table keyed by those ids.
Keeping the index fresh
- Upsert by stable id (
source#chunk_index) so re-indexing a document replaces its old chunks. - Delete chunks of removed documents — stale chunks produce confidently wrong answers.
- Skip unchanged chunks using the content hash.
- Re-index on a schedule or on change events (webhooks from your CMS, Git pushes).
- Re-embed everything when you change embedding model, into a new collection, then switch over.
Apply permission filters inside the vector query (as a metadata filter or SQL WHERE), not after retrieving the top-k. Filtering afterwards can leave too few results — or, worse, a bug can leak restricted text into the prompt.
- Extend
VectorStorewith anupsertmethod that replaces records with an existing id. - Add a
min_scoreargument tosearch. - Index the same documents in Chroma and compare its top results with the toy store for five queries.
- A vector store keeps vectors, text, and metadata, and answers k-nearest-neighbour queries with filters.
- Exact search compares every vector; ANN indexes such as HNSW make search fast at scale with tiny recall loss.
- Start with Chroma or pgvector; reach for dedicated vector databases or FAISS when scale demands it.
- Upsert by stable id, delete stale chunks, and re-embed fully when the embedding model changes.
- Enforce permissions as filters inside the query, never after retrieval.