Topic 12 of 15 · Backend & deployment

PostgreSQL + pgvector.

Topic 6's TinySearchIndex rebuilds every embedding on every process start and lives only in memory. This lesson replaces it with a real, persistent store: the pgvector extension adds vector columns and similarity search directly to PostgreSQL — very likely a database you already run.

The mental model

One more column type, not a new database to operate.

A dedicated vector database is a real option, but for most products it's an extra system to run, monitor, and keep in sync with your primary data — when a normal Postgres extension does the job. pgvector adds a vector column type and a similarity operator to SQL you already know, so embeddings live right next to the rows they describe, in the same transactions, with the same backups.

Section 0

Why Postgres Over a Dedicated Vector DB

SituationReach for
Already running Postgres for the rest of the apppgvector — one fewer system to operate, backups and transactions already solved
Need to join vector search with relational data (user permissions, timestamps)pgvector — it's SQL, so JOIN and WHERE work normally
Truly massive scale (100M+ vectors) with vector search as the primary workloadA dedicated vector database (Pinecone, Weaviate, Qdrant) — purpose-built for this at that scale
Start with pgvector by default. Most products that think they need a dedicated vector database never actually reach the scale where Postgres becomes the bottleneck.
Section 1

Setting Up pgvector

docker run -d --name pg-vector \
  -e POSTGRES_PASSWORD=devpassword \
  -p 5432:5432 \
  ankane/pgvector:latest
-- run once, inside the database
CREATE EXTENSION IF NOT EXISTS vector;
Checkpoint: confirm the extension loaded with SELECT * FROM pg_extension WHERE extname = 'vector'; before writing any schema.
Section 2

Schema Design

A vector(n) column stores fixed-length embeddings — n must match your embedding model's output size exactly (1536 for OpenAI's text-embedding-3-small, 768 for Gemini's default). Store the embedding right alongside the text and metadata it came from, in one row.

CREATE TABLE document_chunks (
    id SERIAL PRIMARY KEY,
    document_id TEXT NOT NULL,
    content TEXT NOT NULL,
    embedding VECTOR(1536) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);
This is the same "schema as a contract" instinct as Topic 4's Pydantic models, now expressed in SQL — get the vector dimension wrong and every insert fails loudly at the database level, not silently at query time.
Section 3

Inserting Embeddings

The ingestion phase from Topic 7 Section 0, now writing to a real table instead of an in-memory list.

import psycopg
from openai import OpenAI

client = OpenAI()

def get_embedding(text: str) -> list[float]:
    return client.embeddings.create(model="text-embedding-3-small", input=text).data[0].embedding

def ingest_chunk(conn: psycopg.Connection, document_id: str, content: str) -> None:
    embedding = get_embedding(content)
    conn.execute(
        "INSERT INTO document_chunks (document_id, content, embedding) VALUES (%s, %s, %s)",
        (document_id, content, embedding),
    )
    conn.commit()
Section 5

Combining with Metadata Filters

Because this is still SQL, you get something an in-memory vector search doesn't hand you for free: filtering by ordinary columns before or alongside the vector search — "find similar chunks, but only from documents this user can access."

SELECT content, 1 - (embedding <=> %(query_embedding)s) AS similarity
FROM document_chunks
WHERE document_id = ANY(%(allowed_document_ids)s)   -- ordinary SQL filter
ORDER BY embedding <=> %(query_embedding)s
LIMIT 4;
This single query replaces a common and fragile pattern: retrieving top-k first and filtering by permission afterward, which can silently return fewer than k results — or leak the existence of documents a user can't see through similarity scores alone. Filter in the same query.
Section 6

Connecting from Python

pip install psycopg[binary] pgvector
import psycopg
from pgvector.psycopg import register_vector

conn = psycopg.connect("postgresql://postgres:devpassword@localhost:5432/postgres")
register_vector(conn)  # lets psycopg send/receive Python lists as VECTOR directly

def search(conn: psycopg.Connection, query: str, top_k: int = 4) -> list[tuple[str, float]]:
    query_embedding = get_embedding(query)
    rows = conn.execute(
        """SELECT content, 1 - (embedding <=> %s) AS similarity
           FROM document_chunks ORDER BY embedding <=> %s LIMIT %s""",
        (query_embedding, query_embedding, top_k),
    ).fetchall()
    return rows
Section 7 · Checkpoint

Capstone: Migrate Topic 7's RAG Store to pgvector

Replace Topic 7's TinySearchIndex with a Postgres-backed version — same public interface, real persistence underneath.

import psycopg
from pgvector.psycopg import register_vector
from openai import OpenAI

client = OpenAI()

def get_embedding(text: str) -> list[float]:
    return client.embeddings.create(model="text-embedding-3-small", input=text).data[0].embedding

class PgVectorIndex:
    def __init__(self, dsn: str):
        self.conn = psycopg.connect(dsn)
        register_vector(self.conn)
        self.conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS document_chunks (
                id SERIAL PRIMARY KEY,
                content TEXT NOT NULL,
                embedding VECTOR(1536) NOT NULL
            )""")
        self.conn.commit()

    def add_documents(self, documents: list[str]) -> None:
        for doc in documents:
            embedding = get_embedding(doc)
            self.conn.execute(
                "INSERT INTO document_chunks (content, embedding) VALUES (%s, %s)",
                (doc, embedding),
            )
        self.conn.commit()

    def search(self, query: str, top_k: int = 4) -> list[tuple[str, float]]:
        query_embedding = get_embedding(query)
        rows = self.conn.execute(
            """SELECT content, 1 - (embedding <=> %s) AS similarity
               FROM document_chunks ORDER BY embedding <=> %s LIMIT %s""",
            (query_embedding, query_embedding, top_k),
        ).fetchall()
        return [(content, float(score)) for content, score in rows]


if __name__ == "__main__":
    index = PgVectorIndex("postgresql://postgres:devpassword@localhost:5432/postgres")
    index.add_documents([
        "Reset your password from the account settings page.",
        "Our refund policy allows returns within 30 days of purchase.",
        "The mobile app supports offline mode since version 4.2.",
    ])
    for content, score in index.search("can I get my money back"):
        print(f"{score:.3f}  {content}")
Expected behavior: identical ranking behavior to Topic 6's in-memory version, but the data survives a process restart — run the script twice without re-adding documents and confirm search() still works on the second run using previously stored rows.
  • Ran pgvector locally via Docker and confirmed the extension is installed
  • Ingested the same documents from Topic 6/7's examples and confirmed matching search rankings
  • Restarted the Python process (not the database) and confirmed search still returns results without re-ingesting
  • Added the HNSW index from Section 4 and re-ran EXPLAIN ANALYZE on the search query to see the index actually being used instead of a sequential scan
  • Can explain out loud why PgVectorIndex exposes the same search() method signature as Topic 6's TinySearchIndex — what does that let Topic 7's answer_question() function do unchanged
  • Next up

    Topic 13: Docker, Cloud Run & Vertex AI

    Containerize the Topic 11 service and deploy it — including a look at Vertex AI as a managed alternative to calling providers directly.