Búsqueda vectorial con djanorm¶
dorm.contrib.pgvector cubre búsqueda por similitud vectorial
sobre cuatro backends — el mismo código de modelo + queryset
corre contra cualquiera porque el field elige el formato wire
según el vendor de la conexión activa:
| Backend | Tipo columna | Funciones de distancia |
|---|---|---|
| PostgreSQL (pgvector) | vector(N) |
operadores <-> / <=> / <#> |
| SQLite (sqlite-vec) | BLOB |
vec_distance_L2 / vec_distance_cosine |
| libsql / Turso (nativo) | F32_BLOB(N) |
vector_distance_l2 / vector_distance_cos |
| MariaDB 11.7+ / MySQL 9.0+ (3.0+) | VECTOR(N) |
VEC_DISTANCE_EUCLIDEAN / VEC_DISTANCE_COSINE |
El módulo expone:
VectorField(dimensions=N)— el tipo de columna.L2Distance/CosineDistance/MaxInnerProduct— expresiones de distancia que componen conannotate()yorder_by().HnswIndex/IvfflatIndex— helpers de índice (solo PostgreSQL — los demás backends usan otros modelos de índice que aún no envolvemos).VectorExtension— operación de migración que activa pgvector / sqlite-vec donde haga falta; no-op en libsql / MariaDB / MySQL porque traen funciones vectoriales nativas.
Nota sobre
MaxInnerProduct— pgvector la trae (operador<#>). sqlite-vec, libsql y MariaDB / MySQL no: usaCosineDistancesobre embeddings L2-normalizados (equivalente matemáticamente módulo una constante).Nota sobre el backend MySQL (3.0+) — el wrapper Python del motor MySQL / MariaDB es scaffold hoy (raisea
ImproperlyConfiguredhasta que v3.1 traiga la implementación completa).VectorFieldy las distancias emiten ya el SQL correcto, así que cuando el wrapper aterrice el código vectorial seguirá funcionando sin cambios. La filaVECTORen la tabla sella el contrato desde ahora.
Paso a paso (PostgreSQL)¶
1. Instalar pgvector en tu servidor PostgreSQL¶
pgvector se distribuye como extensión binaria. En Debian / Ubuntu con PostgreSQL 16:
Para otras distribuciones / servicios gestionados ver el README upstream. En AWS RDS / Aurora la extensión viene preinstalada — solo hace falta habilitarla (paso 3).
2. Instalar el extra de Python¶
El extra [pgvector] es solo PostgreSQL — instala el paquete
pgvector, que registra un adaptador psycopg para que
list[float] y numpy.ndarray se conviertan automáticamente.
Sin él el field sigue funcionando, solo pierdes la conveniencia
con numpy.
Si tu proyecto tiene como objetivo ambos PostgreSQL y SQLite
(CI corre SQLite, prod corre PG), instala el meta-extra de
conveniencia [vector] — incluye [pgvector] y [sqlite-vec]
en uno:
3. Generar la migración de la extensión¶
Eso escribe myapp/migrations/0001_enable_pgvector.py:
from dorm.contrib.pgvector import VectorExtension
dependencies = []
operations = [VectorExtension()]
VectorExtension ejecuta CREATE EXTENSION IF NOT EXISTS "vector"
al aplicar y DROP EXTENSION IF EXISTS "vector" al revertir.
En backends no-PostgreSQL la operación es un no-op, así que la
misma migración se aplica limpiamente bajo SQLite (tus tests
siguen pasando).
4. Añade un VectorField a tu modelo¶
import dorm
from dorm.contrib.pgvector import VectorField
class Document(dorm.Model):
title = dorm.CharField(max_length=200)
content = dorm.TextField()
embedding = VectorField(dimensions=1536) # OpenAI text-embedding-3-small
class Meta:
db_table = "documents"
dimensions= es obligatorio y tiene que coincidir con tu modelo
de embeddings. La columna se declara vector(1536) y pgvector
rechaza inserts cuya longitud difiera — el field replica la
comprobación en Python para que el ValidationError aparezca con
tu stack frame, no dentro de libpq.
5. Ejecuta makemigrations + migrate¶
El autodetector recoge la nueva columna y emite una operación
AddField contra la migración de la extensión existente.
6. Insertar y consultar¶
import openai
resp = openai.embeddings.create(
model="text-embedding-3-small",
input="hola mundo",
)
emb = resp.data[0].embedding # list[float] length 1536
doc = Document.objects.create(
title="hola",
content="hola mundo",
embedding=emb,
)
Para recuperar los k vecinos más cercanos, anota con una expresión de distancia y ordena por ella:
from dorm.contrib.pgvector import L2Distance
query_emb = openai.embeddings.create(
model="text-embedding-3-small",
input="saludos",
).data[0].embedding
nearest = list(
Document.objects
.annotate(score=L2Distance("embedding", query_emb))
.order_by("score")[:10]
)
for doc in nearest:
print(doc.title, doc.score) # type: ignore — atributo runtime
Las tres expresiones de distancia mapean uno a uno con los operadores de pgvector:
| Clase | Operador | Significado |
|---|---|---|
L2Distance |
<-> |
Euclídea (L2). Menor = más similar. |
CosineDistance |
<=> |
1 - cosine_similarity. Menor = más cerca. |
MaxInnerProduct |
<#> |
Producto interno negado (menor = más cerca). |
7. Añadir un índice — imprescindible para kNN en producción¶
Sin índice cada query kNN es un seq scan. A partir de unos pocos miles de filas eso son segundos por petición. Hay dos métodos:
from dorm.contrib.pgvector import HnswIndex, IvfflatIndex
class Document(dorm.Model):
embedding = VectorField(dimensions=1536)
class Meta:
db_table = "documents"
indexes = [
HnswIndex(
fields=["embedding"],
name="doc_emb_hnsw",
opclass="vector_l2_ops",
m=16,
ef_construction=64,
),
]
Después de añadirlo, dorm makemigrations + dorm migrate
emiten el CREATE INDEX … USING hnsw ….
Elegir el método de índice¶
| Método | Build | Recall | Memoria | Cuándo usarlo |
|---|---|---|---|---|
| HNSW | minutos | excelente | alta | Por defecto. Mejor recall, paga disco + RAM. |
| IVFFlat | segundos | bueno | baja | Memoria justa, tablas grandes, build crítico. |
El opclass importa¶
Elige la clase de operador que coincida con la distancia que consultas — si no, el planner no puede usar el índice y hace silenciosamente seq scan:
| Distancia | Opclass |
|---|---|
L2Distance |
vector_l2_ops |
CosineDistance |
vector_cosine_ops |
MaxInnerProduct |
vector_ip_ops |
Tuning en tiempo de query¶
Ambos métodos exponen knobs recall-vs-latencia que viven fuera de la definición del índice (son GUCs por sesión):
# HNSW: ef_search por defecto 40; sube para mejor recall.
get_connection().execute("SET hnsw.ef_search = 100")
# IVFFlat: probes por defecto 1; rango 1..lists.
get_connection().execute("SET ivfflat.probes = 10")
Configúralos en el entry-point de la request (dependency de FastAPI, middleware Django) para que toda la request use el mismo target.
Paso a paso (SQLite)¶
1. Instalar sqlite-vec¶
sqlite-vec es una extensión cargable client-side — no requiere instalación server-side. El paquete PyPI trae binarios compilados para Linux / macOS / Windows:
El extra [sqlite-vec] es solo SQLite — incluye únicamente
el paquete sqlite-vec sin tirar del adaptador pgvector de
psycopg. Usa [pgvector] para el lado PostgreSQL, o [vector]
para ambos a la vez si tu proyecto soporta los dos backends.
2. Verifica que tu Python soporta enable_load_extension¶
Casi todas las distros CPython traen sqlite3 compilado contra
una SQLite que permite cargar extensiones externas. Algunas no —
notablemente Python de sistema en Ubuntu / Debian antes de 3.11.
Comprobación rápida:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.enable_load_extension(True) # AttributeError → no soportado
Si lanza error, instala Python desde python.org / pyenv / uv.
3. Generar la migración de la extensión¶
Mismo comando que PostgreSQL:
La migración generada llama a VectorExtension(), que en SQLite:
- Carga sqlite-vec en la conexión de la migración.
- Marca el wrapper para que cada conexión futura (re-aperturas, hilos nuevos) auto-cargue la extensión.
La marca vive en la instancia del wrapper, no en la BD, así que
un restart de proceso necesita volver a tocar el código de la
migración — re-ejecuta la migración una vez al arranque o llama
a load_sqlite_vec_extension(raw_sqlite3_conn) desde el boot
de tu app.
4. Define el modelo igual¶
import dorm
from dorm.contrib.pgvector import VectorField
class Document(dorm.Model):
title = dorm.CharField(max_length=200)
embedding = VectorField(dimensions=384) # menor para SQLite
class Meta:
db_table = "documents"
En SQLite, db_type() devuelve BLOB. El field empaqueta los
valores como float32 little-endian — formato que sqlite-vec
almacena nativamente y que vec_distance_L2(col, ?) acepta
directamente.
5. Consulta igual¶
from dorm.contrib.pgvector import L2Distance
nearest = list(
Document.objects
.annotate(score=L2Distance("embedding", query_emb))
.order_by("score")[:10]
)
La expresión detecta el backend activo en tiempo de compilación
y emite embedding <-> %s::vector (PG) o
vec_distance_L2(embedding, %s) (SQLite).
Soporte de índices (SQLite)¶
El modelo de índices de sqlite-vec se monta sobre virtual tables
(vec0), que no encaja con el flujo regular-table que djanorm
expone hoy. Seq-scan con vec_distance_L2 es razonable hasta
unos cientos de miles de vectores en hardware estándar; si
necesitas ANN a escala SQLite, baja a RunSQL para crear una
virtual table vec0 paralela a la columna. Posiblemente lo
envolvamos en un release futuro cuando la API de sqlite-vec
estabilice.
Trampas comunes¶
- Las dimensiones tienen que coincidir con el modelo que
produjo el embedding. OpenAI
text-embedding-3-smalles 1536,…3-largees 3072,text-embedding-ada-002también 1536. Un desajuste lanzaValidationErrorcon el tamaño culpable. - pgvector limita
vectora 16000 dimensiones. Para vectores más grandes usahalfvec(floats de 16-bit, límite 32k) osparsevecen pgvector ≥ 0.7. Esos tipos aún no están envueltos por djanorm. - El primer build HNSW sobre tabla grande es lento. Construye el índice después del bulk-load, o acepta una ventana de migración larga. IVFFlat es más rápido pero techo de recall más bajo.
- No mezcles opclasses en la misma columna. Una opclass por índice y por columna.