Franky Bonanno

← News

pgvector with Django, part 2: your first vector in 15 minutes

· guides

From theory to code: PostgreSQL with pgvector in Docker, a Django project and your first vector field. In a few minutes you will have a real map inside a database.

What we build today

In the first part we imagined a map of meaning, where similar things sit close together. Today we build it for real: six points (two animals, two vehicles, two dishes) stored in a database, and a search that finds "the nearest ones".

We will use only three numbers per point instead of a thousand, so you can read them at a glance. The mechanism, though, is identical to the one in real systems.

You need three things installed: Docker, uv (the modern Python project manager) and Python 3.14. It is the same stack I use in my book.

Step 1: PostgreSQL with pgvector in a container

A freshly installed PostgreSQL does not know pgvector: it is an extension that has to be added. The quickest way is to use the project's official image, pgvector/pgvector, which is PostgreSQL with the extension already inside.

Create a folder and, inside it, a file called compose.yaml:

services:
  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_DB: archivio
      POSTGRES_USER: archivio
      POSTGRES_PASSWORD: cambiami
    ports:
      - "127.0.0.1:5433:5432"
    volumes:
      - dati_db:/var/lib/postgresql/data

volumes:
  dati_db:

Three details that make a difference:

  • pg17 means PostgreSQL 17. Other versions (pg16, pg18) exist with the same pattern.
  • 127.0.0.1:5433:5432 exposes the database only on your own computer, and on port 5433 instead of the usual 5432, so it does not clash with a PostgreSQL you may already have installed.
  • The dati_db volume keeps your data even if the container is stopped.

Start it and check that it is running:

docker compose up -d
docker compose ps

Step 2: the Django project with uv

uv init --python 3.14 --bare .
uv add "django>=6.1,<6.2" pgvector "psycopg[binary]"
uv run django-admin startproject config .
uv run python manage.py startapp archivio

Here pgvector is the Python library that teaches Django what vectors are, and psycopg is the driver that talks to PostgreSQL.

Step 3: connect Django to the database

Open config/settings.py. In INSTALLED_APPS add two lines at the bottom:

INSTALLED_APPS = [
    # ... the apps that are already there ...
    "django.contrib.postgres",
    "archivio",
]

django.contrib.postgres is not needed yet for this part, but it will be from part 4, when we create indexes: without it, Django refuses to create them. Better to add it now.

Then replace the DATABASES block with this:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "archivio",
        "USER": "archivio",
        "PASSWORD": "cambiami",
        "HOST": "127.0.0.1",
        "PORT": "5433",
    }
}

These are the same values as in compose.yaml. In a real project the password does not belong in the code but in an environment variable: for today, this is fine.

Step 4: switch the extension on from a migration

The extension has to be "switched on" once in the database. The cleanest way is to do it from a migration, so Django does it for you on any new database. Create an empty migration:

uv run python manage.py makemigrations archivio --empty -n estensione_vector

Open the file it created in archivio/migrations/ and make it look like this:

from django.db import migrations
from pgvector.django import VectorExtension


class Migration(migrations.Migration):

    dependencies = []

    operations = [
        VectorExtension(),
    ]

VectorExtension() runs the command that turns pgvector on, for you.

Step 5: the first model with a vector

In archivio/models.py:

from django.db import models
from pgvector.django import VectorField


class Punto(models.Model):
    nome = models.CharField(max_length=50)
    posizione = VectorField(dimensions=3)

    def __str__(self):
        return self.nome

The VectorField(dimensions=3) field creates a column of type vector(3) in the database: a list of exactly three numbers. Then:

uv run python manage.py makemigrations archivio -n punto
uv run python manage.py migrate

Step 6: fill the map and search

Open the Django shell:

uv run python manage.py shell

And paste this (the coordinates are made up: animals in one corner, vehicles in another, food in the third):

from archivio.models import Punto
from pgvector.django import CosineDistance

Punto.objects.bulk_create([
    Punto(nome="gatto", posizione=[0.9, 0.1, 0.0]),
    Punto(nome="cane",  posizione=[0.8, 0.2, 0.1]),
    Punto(nome="auto",  posizione=[0.1, 0.9, 0.1]),
    Punto(nome="moto",  posizione=[0.0, 0.8, 0.3]),
    Punto(nome="pizza", posizione=[0.1, 0.1, 0.9]),
    Punto(nome="pasta", posizione=[0.2, 0.0, 0.8]),
])

cerca = [0.85, 0.15, 0.05]   # a "puppy-like" point

vicini = Punto.objects.annotate(d=CosineDistance("posizione", cerca)).order_by("d")[:2]
for p in vicini:
    print(p.nome, round(p.d, 3))

(The names are Italian: *gatto* is cat, *cane* is dog, *auto* is car, *moto* is motorbike.) You should see:

gatto 0.004
cane 0.004

The point we searched for sits in the animals' corner, and the database hands you exactly the two animals. The number next to each is the distance: the smaller it is, the more alike the two things are; close to zero means almost identical.

What really happened

The Django code you wrote turns into an SQL query with a new operator, <=>, which means "cosine distance". In practice PostgreSQL computed the distance between your point and every point in the table, sorted them and gave you the first two. No words compared: only numbers.

If something goes wrong

  • Extension "vector" is not available. You are using a plain PostgreSQL, not the pgvector/pgvector image.
  • Port already in use. Change 5433 to another number, both in compose.yaml and in Django's settings.
  • Permission denied creating the extension. The database user lacks the rights to do it. It does not happen with the Docker image, but it can with a database managed by an outside service.

Next part

Made-up points are fine for understanding, but real documents have no coordinates: an artificial intelligence model has to compute them. And here there is a surprise: the choice of model changes the results enormously. In part 3 I try the same example with three different models, and one of them gets it wrong.