In the last lesson you built a search that compares a question vector with every chunk vector. For six small documents that is instant. Now imagine your help center grows to 200,000 chunks. Comparing the question with every single chunk on every request would be slow and wasteful. A vector database exists to solve exactly that problem, along with a few others that every real product soon meets: saving data safely, updating one document without rebuilding everything, filtering by user or date, and deleting data when someone asks.
In this lesson you will learn what a vector database does, how the fast search works in simple words, how to choose between pgvector, Qdrant, Pinecone and Chroma, and then you will build a small vector store of your own with a clean interface. Building it yourself first makes every real database easier to understand.
The four jobs of a vector store
Strip away the marketing and a vector store has four jobs:
- Upsert. Save a chunk with its vector and metadata. “Upsert” means insert it if it is new, or update it if the same id already exists.
- Query. Given a vector, return the nearest vectors (the top k), optionally only those that match a filter.
- Delete. Remove chunks by id or by filter. This matters for privacy: when a user deletes a file, its vectors must go too.
- Persist. Keep everything safe when the server restarts.
If you can do these four things, you have a vector store. Everything else, such as speed at huge scale, replication and dashboards, is extra.
Exact search versus approximate search
The search in lesson 14 is exact: it checks every vector and returns the true best matches. This is perfect and simple, and for up to roughly tens of thousands of chunks it is often fast enough.
At millions of vectors, checking everything is too slow. Databases use approximate nearest neighbour (ANN) search. They build an index that lets them skip most vectors and still find nearly the best ones. The trade-off is small: you may miss a true top result once in a while, in exchange for a search that is hundreds of times faster. The share of true best results that you actually find is called recall.
The index you will hear about most is HNSW, which stands for Hierarchical Navigable Small World. A simple way to picture it: think of a country map with motorways, main roads and small streets. To reach a house, you first take the motorway to the right region, then main roads to the right town, then small streets to the right door. HNSW builds layers like that. The top layer has a few points with long links, and each lower layer has more points and shorter links. A search starts at the top, jumps close to the target, and refines on the lower layers. Another common index is IVF, which groups vectors into clusters and only searches the clusters closest to the question.
You do not build these yourself. You switch them on and tune a few settings. What you must remember is the trade-off: more accuracy costs more time and memory.
Metadata filters
A vector alone is not enough. In a real app you often need to say “only search this user's files” or “only documents from 2025”. That is a metadata filter. In your lab each chunk has a source field, and you can search only one source file.
Filters are also a security tool. If you run one shared store for many users, every query must carry the user id as a filter, applied on the server, never decided by the browser. Forgetting this is how one user's private data ends up in another user's answer.
The main options in plain words
- pgvector (PostgreSQL). An extension that adds vector columns and indexes to PostgreSQL. Best when you already use Postgres: your vectors live next to your normal data, you use normal SQL, backups and transactions, and you can filter with ordinary WHERE clauses. It is a very good default for small and medium projects.
- Qdrant. A database made only for vectors, available as open source and as a hosted service. It has strong filtering and is fast. A good choice when vector search is the core of your product.
- Pinecone. A fully managed cloud service. You do not run any servers. It is quick to start and scales easily, but it is a paid service and your data lives with the provider.
- Chroma. An open-source database that is very easy to start with and runs inside your Python or Node program. Excellent for learning and prototypes. For large production systems, check its current deployment options.
How to choose: if you already have Postgres and fewer than a few million chunks, start with pgvector. If you want no servers to manage, look at a managed service. If you need a dedicated, heavily filtered vector engine, look at Qdrant or similar. If you are only experimenting, use something small like Chroma or the in-memory store from this lesson. Features, limits and prices change, so read the current documentation before you decide.
Design for change: put the database behind an interface
Here is the most useful engineering lesson of this chapter. Today you might choose pgvector. In a year you might move to something else. If your route code calls the database directly in twenty places, moving is painful. So hide the database behind a tiny interface with a few methods. In your lab, the interface is a class with upsert, remove, query, count and list.
Here is the whole store. MemoryStore keeps vectors in a Map. FileStore extends it and saves to a JSON file after every change, so data survives a restart:
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadDocs } from './load.js';
import { chunkWords } from './chunk.js';
import { embed, cosine } from './embed.js';
// A vector store has four jobs: upsert, remove, query and count.
// Keep them behind one small interface and you can swap the engine later
// (pgvector, Qdrant, Pinecone, Chroma) without touching the routes.
export class MemoryStore {
constructor() {
this.items = new Map(); // id -> { id, source, title, text, vector }
}
upsert(item) {
this.items.set(item.id, { ...item, vector: item.vector || embed(`${item.title || ''}. ${item.text}`) });
return item.id;
}
remove(id) {
return this.items.delete(id);
}
count() {
return this.items.size;
}
list() {
return [...this.items.values()].map(({ vector, ...rest }) => rest);
}
// Nearest neighbours by cosine similarity. `source` is a metadata filter.
query(text, { k = 4, source } = {}) {
const q = embed(text);
return [...this.items.values()]
.filter((it) => !source || it.source === source)
.map(({ vector, ...rest }) => ({ ...rest, score: Number(cosine(q, vector).toFixed(4)) }))
.sort((a, b) => b.score - a.score)
.slice(0, k);
}
}
// Same store, but saved to a JSON file so data survives a restart.
export class FileStore extends MemoryStore {
constructor(file) {
super();
this.file = file;
if (fs.existsSync(file)) {
for (const it of JSON.parse(fs.readFileSync(file, 'utf8'))) this.items.set(it.id, it);
}
}
save() {
fs.mkdirSync(path.dirname(this.file), { recursive: true });
fs.writeFileSync(this.file, JSON.stringify([...this.items.values()]));
}
upsert(item) {
const id = super.upsert(item);
this.save();
return id;
}
remove(id) {
const done = super.remove(id);
if (done) this.save();
return done;
}
}
const here = path.dirname(fileURLToPath(import.meta.url));
let store;
// One shared store for the whole server.
// On every start the documents in server/rag/docs are re-chunked and re-embedded, so editing a
// document and restarting always works. Notes you added yourself (other sources) are kept.
export function getStore() {
if (store) return store;
store = new FileStore(process.env.VECTOR_FILE || path.join(here, '..', 'runtime', 'vectors.json'));
const docs = loadDocs();
const docSources = new Set(docs.map((d) => d.source));
for (const [id, item] of [...store.items]) if (docSources.has(item.source)) store.items.delete(id);
for (const doc of docs) {
chunkWords(doc.text, { size: 80, overlap: 20 }).forEach((text, i) => {
const id = `${doc.source}#${i + 1}`;
store.items.set(id, { id, source: doc.source, title: doc.title, text, vector: embed(`${doc.title}. ${text}`) });
});
}
store.save();
return store;
}
Read the last function, getStore. When the server starts, it reloads your saved items, then re-chunks and re-embeds the documents in the docs folder, replacing the old copies. This means that when you edit a document and restart the server, the index is updated, while notes that you added yourself are kept. A very common bug in RAG projects is an index that is out of date with the documents. Think about how your own project will keep them in sync.
The same four jobs in SQL
The lab also includes a file, pgvector.sql, that shows how the same four jobs look in PostgreSQL. You can read it as a map for your first migration. Notice three things. The vector column has a fixed size, and it must equal the size of your embedding. The <=> operator returns cosine distance, so similarity is 1 - distance. And the filter is a normal WHERE clause.
-- The same four jobs in PostgreSQL with the pgvector extension.
-- Read this file; the lab itself runs without a database.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id text PRIMARY KEY,
source text NOT NULL,
title text,
body text NOT NULL,
embedding vector(1024) NOT NULL -- must match the size of your embedding model
);
-- Approximate nearest-neighbour index (needed once you have many thousands of rows).
CREATE INDEX chunks_embedding_idx ON chunks USING hnsw (embedding vector_cosine_ops);
-- upsert
INSERT INTO chunks (id, source, title, body, embedding)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body, embedding = EXCLUDED.embedding;
-- query: <=> is cosine DISTANCE, so similarity = 1 - distance
SELECT id, source, body, 1 - (embedding <=> $1) AS score
FROM chunks
WHERE source = $2 -- the metadata filter
ORDER BY embedding <=> $1
LIMIT 4;
Because your routes only call store.upsert, store.query and so on, switching to pgvector later means writing one new class that runs these SQL statements. The routes, the pages and the lessons do not change.
Things that surprise people in production
- The embedding size is fixed. If you switch embedding model, the vector size may change and you must re-embed and rebuild the index.
- Deleting must really delete. If a user asks you to remove their data, remove the vectors and the original text, and check any backups and logs.
- Back it up. A vector index is data. It needs backups like any other database.
- Test with your own data. A benchmark on someone else's data does not tell you what happens on yours.
Your task: build a vector store with a clean interface
Build a store that you can add notes to, search with a filter, and delete from. Then add a page in the AI Lab to use it.
What you will build
- server/rag/store.js with a MemoryStore class and a FileStore class that saves to server/runtime/vectors.json.
- A getStore function that shares one store and syncs the documents folder when the server starts.
- Routes: GET /api/store, POST /api/store/query, POST /api/store/upsert and DELETE /api/store/:id.
- An Angular Store page where you add a note, search with an optional source filter, and delete a result.
Steps
- Write MemoryStore with a Map. Each item has id, source, title, text and vector. Write upsert, remove, count, list and query. In query, apply the source filter first, then score the rest with cosine similarity, sort and keep the top k.
- Write FileStore. It loads the file in the constructor if it exists, and calls save() after every upsert or remove.
- Write getStore. Create a single FileStore. Chunk and embed every document, delete old items that come from the same documents, add the fresh ones, and save.
- Write the four routes. Validate the input: the text must have at least 10 characters, limit its length, and return 404 when you delete an id that does not exist.
- Build the Store page. Show the number of items, a text area to add a note, a search box, a select with the sources and a Delete button next to each result.
- Test it. Add a note, search for it with the filter set to my-notes.md, delete it, restart the server and check that your notes are still there.
Hints
- Never send the vectors to the browser. Strip them out before you return items. They are large and useless to the page.
- Use a Map keyed by id, so an upsert with the same id replaces the old item.
- Add server/runtime/ to .gitignore. It holds generated data.
You know it works when your notes are found with the source filter, deleted notes never come back, and after a restart the notes you added are still there while the documents are refreshed from the folder.
Solution
You have already read store.js and the SQL file above. Here are the routes and the Angular page.
import { Router } from 'express';
import { getStore } from '../rag/store.js';
const router = Router();
// GET /api/store -> { count, sources, items }
router.get('/store', (req, res) => {
const items = getStore().list();
res.json({ count: items.length, sources: [...new Set(items.map((i) => i.source))], items });
});
// POST /api/store/query { query, source?, k? } -> { hits }
router.post('/store/query', (req, res) => {
const query = String(req.body?.query || '').trim().slice(0, 300);
if (!query) return res.status(400).json({ error: { message: 'Please type a query.' } });
const source = req.body?.source ? String(req.body.source).slice(0, 100) : undefined;
const k = Math.min(10, Math.max(1, Number(req.body?.k) || 4));
res.json({ hits: getStore().query(query, { k, source }) });
});
// POST /api/store/upsert { text, source?, id? } -> { id, count }
router.post('/store/upsert', (req, res) => {
const text = String(req.body?.text || '').trim().slice(0, 4000);
if (text.length < 10) return res.status(400).json({ error: { message: 'Write at least 10 characters.' } });
const source = String(req.body?.source || 'my-notes.md').slice(0, 100);
const store = getStore();
const id = String(req.body?.id || `${source}#${Date.now().toString(36)}`).slice(0, 120);
store.upsert({ id, source, title: source, text });
res.json({ id, count: store.count() });
});
// DELETE /api/store/:id -> { removed, count }
router.delete('/store/:id', (req, res) => {
const store = getStore();
const removed = store.remove(req.params.id);
if (!removed) return res.status(404).json({ error: { message: 'No such item.' } });
res.json({ removed: true, count: store.count() });
});
export default router;
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';
type Item = { id: string; source: string; text: string; score?: number };
@Component({
selector: 'app-store',
imports: [FormsModule],
template: `
<h1>Vector store</h1>
<p class="hint">Add your own notes, search them by meaning, filter by source and delete them. Data is saved in server/runtime/vectors.json.</p>
<div class="card">
<h2>Add a note</h2>
<textarea [(ngModel)]="newText" placeholder="Write something to remember..." style="min-height:90px"></textarea>
<button (click)="add()" [disabled]="busy() || newText.trim().length < 10">Add to store</button>
</div>
<div class="card">
<h2>Search ({{ count() }} items)</h2>
<input [(ngModel)]="query" (keyup.enter)="search()" placeholder="Search by meaning..." />
<label for="src">Only this source (optional)</label>
<select id="src" [(ngModel)]="source">
<option value="">All sources</option>
@for (s of sources(); track s) { <option [value]="s">{{ s }}</option> }
</select>
<button (click)="search()" [disabled]="busy() || !query.trim()">Search</button>
@if (error()) { <div class="err">{{ error() }}</div> }
@for (h of hits(); track h.id) {
<p><strong>{{ h.id }}</strong> <span class="muted">similarity {{ h.score }}</span>
<button class="danger" style="margin-left:8px" (click)="remove(h.id)">Delete</button></p>
<pre class="out">{{ h.text }}</pre>
}
</div>
`,
})
export class StorePage implements OnInit {
private http = inject(HttpClient);
newText = '';
query = 'how do I protect my personal details';
source = '';
busy = signal(false);
error = signal('');
count = signal(0);
sources = signal<string[]>([]);
hits = signal<Item[]>([]);
ngOnInit() {
this.refresh();
}
private async guard(fn: () => Promise<void>) {
this.busy.set(true);
this.error.set('');
try {
await fn();
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
refresh() {
return this.guard(async () => {
const r = await firstValueFrom(this.http.get<{ count: number; sources: string[] }>('/api/store'));
this.count.set(r.count);
this.sources.set(r.sources);
});
}
add() {
return this.guard(async () => {
await firstValueFrom(this.http.post('/api/store/upsert', { text: this.newText, source: 'my-notes.md' }));
this.newText = '';
const r = await firstValueFrom(this.http.get<{ count: number; sources: string[] }>('/api/store'));
this.count.set(r.count);
this.sources.set(r.sources);
});
}
search() {
return this.guard(async () => {
const r = await firstValueFrom(this.http.post<{ hits: Item[] }>('/api/store/query', { query: this.query, source: this.source || undefined }));
this.hits.set(r.hits);
});
}
remove(id: string) {
return this.guard(async () => {
await firstValueFrom(this.http.delete(`/api/store/${encodeURIComponent(id)}`));
this.hits.update((h) => h.filter((x) => x.id !== id));
const r = await firstValueFrom(this.http.get<{ count: number; sources: string[] }>('/api/store'));
this.count.set(r.count);
this.sources.set(r.sources);
});
}
}
What you learned
- A vector store has four jobs: upsert, query, delete and persist.
- Approximate search (such as HNSW) trades a tiny loss in recall for a very large gain in speed.
- Metadata filters are essential, and for multi-user apps they are also a security tool.
- pgvector, Qdrant, Pinecone and Chroma all do these jobs. Hide the choice behind an interface so you can change it later.
In the next lesson you will make retrieval much better by combining keyword and vector search, reranking the results and making the model cite its sources.