AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

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.

Last updated: 01 Nov, 2026

Definitions

Definition · Vector store (vector database)

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.

Definition · k-nearest neighbours (kNN)

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.

Definition · Approximate nearest neighbours (ANN)

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.

Python
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"}))
4 records {'id': 'kb-1', 'score': 0.707, 'text': 'Annual plans can be refunded within 30 days.'} {'id': 'kb-3', 'score': 0.471, 'text': 'Sync plans can be refunded within 14 days.'} filtered: [{'id': 'kb-3', 'score': 0.471, 'text': 'Sync plans can be refunded within 14 days.'}]

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.

Python
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)
3 records; ['kb-1', 'kb-2', 'kb-4'] (3, 256)

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.

Definition · HNSW (Hierarchical Navigable Small World)

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.

Definition · IVF (inverted file index)

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.

IndexSpeedMemoryRecallUse when
Flat (exact)Slow at scaleFull vectors100%Up to ~100k vectors, or as a quality baseline
HNSWVery fastHigher (graph links)~95–99%+The default for most RAG workloads
IVF / IVF-PQFastLow with PQTunableVery large corpora, memory-constrained

Choosing a vector store

OptionTypeStrengthsConsider when
ChromaEmbedded / server, open sourceEasiest to start, persistent, metadata filtersPrototypes and small-to-medium apps
PostgreSQL + pgvectorExtension to your existing databaseOne database for app data and vectors; SQL filters, joins, transactions; HNSW indexesYou already run Postgres — very often the pragmatic choice
FAISSLibrary (no server)Extremely fast, many index types, GPU supportResearch, batch jobs, custom services
Qdrant, Weaviate, MilvusDedicated vector databasesScale, rich filtering, hybrid search, multi-tenancyLarge or high-traffic deployments
Pinecone, managed cloud offeringsFully managed serviceNo operationsTeams that prefer not to run infrastructure
Elasticsearch / OpenSearchSearch engine with vectorsMature keyword search + vectors in one placeYou already use them for search

Chroma

chroma_demo.py
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])
['kb-1'] ['Annual plans can be refunded within 30 days.']

PostgreSQL with pgvector

SQL
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

faiss_demo.py
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))
nearest id: 0 | similarity: 1.0

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.
Watch out

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.

Try it
  1. Extend VectorStore with an upsert method that replaces records with an existing id.
  2. Add a min_score argument to search.
  3. Index the same documents in Chroma and compare its top results with the toy store for five queries.
Key takeaways
  • 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.