Probabilmente hai visto questa ricetta per costruire un sistema RAG decine di volte:
- Scegli un modello di embedding
- Scegli un vector database
- Costruisci sopra una pipeline di retrieval
Il punto 2 viene trattato quasi come una legge di natura. Ovviamente ti serve un vector database.
Tranne che, molto spesso, non è vero.
Se sei già su PostgreSQL, puoi salvare i tuoi chunk, gestire gli embedding e far girare vari tipi di ricerca per similarità nello stesso posto dove vive il resto dei tuoi dati. Nessun servizio extra, nessuna nuova strategia di backup, nessuna complicazione operativa in più.
In questo articolo ti mostro come impostare PostgreSQL + pgvector in un progetto Elixir, e come usare diverse strategie di ricerca per il RAG:
- Ricerca full-text con ranking BM25
- Ricerca su vettori densi in diverse varianti: cosine, L2, L1, dot product
- Ricerca di similarità binaria: Hamming e Jaccard su bitstring
Userò la repo postgres_rag_bench come riferimento, ma il focus è su setup e concetti, non sulla struttura della repo.
Preparare PostgreSQL alla ricerca vettoriale
Suppongo che tu stia usando PostgreSQL 16 o superiore. Ti serve l’estensione vector installata.
Se usi un Postgres gestito dove vector è disponibile, di solito ti basta:
CREATE EXTENSION IF NOT EXISTS vector;
Lo esegui una volta per database. Nelle migration Ecto di solito lo avvolgi in execute così è idempotente:
def change do
execute(
"CREATE EXTENSION IF NOT EXISTS vector",
"DROP EXTENSION IF EXISTS vector"
)
end
Se ti autogestisci Postgres e CREATE EXTENSION vector fallisce, significa semplicemente che pgvector non è installato a livello di server. A quel punto devi:
- installare
pgvectortramite i pacchetti della tua distro oppure - ricompilare Postgres con l’estensione, oppure
- passare a un’immagine che la include già
Non c’è un trucco per evitarlo. Se l’estensione non è installata sul server, il resto di questo articolo non funzionerà. Nessuna magia in Elixir può sostituire un’estensione C mancante.
Quando CREATE EXTENSION vector funziona in psql, puoi andare avanti.
Collegare pgvector a Elixir ed Ecto
Dal lato Elixir ti servono tre cose:
- La dipendenza in
mix.exs - Una definizione di tipo personalizzata per Postgrex
- La configurazione del Repo che dica a Ecto di usare quei tipi
In mix.exs:
defp deps do
[
{:ecto_sql, "~> 3.11"},
{:postgrex, ">= 0.0.0"},
{:pgvector, "~> 0.3"}
]
end
Poi definisci un modulo che dice a Postgrex come gestire il tipo vector:
# lib/my_app/postgres_types.ex Postgrex.Types.define( MyApp.PostgresTypes, [Pgvector.Extensions.Vector], [] )
Infine, indichi al tuo Repo di usare questo modulo di tipi:
# config/config.exs or config/runtime.exs config :my_app, MyApp.Repo, database: "my_app_dev", username: "postgres", password: "postgres", hostname: "localhost", types: MyApp.PostgresTypes
Se ti dimentichi l’opzione types:, Ecto non sa cosa fare con la colonna vector e ti ritroverai con errori di decode poco chiari quando proverai a leggerla.
A questo punto Pgvector.Ecto.Vector è disponibile come tipo nei tuoi schema e puoi iniziare a trattare gli embedding come cittadini di prima classe.
Creare una tabella RAG “seria” in una migration
Ti serve una tabella che contenga:
- da dove arriva il chunk
- il titolo originale del documento
- un indice per chunk all’interno del documento
- il testo del chunk
- un embedding denso come vettore
- una rappresentazione binaria opzionale di quell’embedding
Ecco una migration minimale ma concreta:
defmodule MyApp.Repo.Migrations.CreateDatasetChunks do
use Ecto.Migration
def change do
execute(
"CREATE EXTENSION IF NOT EXISTS vector",
"DROP EXTENSION IF EXISTS vector"
)
create table(:dataset_chunks) do
add :source_path, :string, null: false
add :document_title, :string, null: false
add :chunk_index, :integer, null: false
add :content, :text, null: false
add :embedding, :vector, size: 384
add :embedding_binary, :text
timestamps(type: :naive_datetime_usec)
end
create unique_index(
:dataset_chunks,
[:source_path, :chunk_index],
name: :dataset_chunks_source_chunk_index
)
create index(:dataset_chunks, [:document_title])
create index(
:dataset_chunks,
["to_tsvector('english', content)"],
using: :gin,
name: :dataset_chunks_content_tsv_idx
)
# indice ivfflat per distanza cosine
execute(
"""
CREATE INDEX dataset_chunks_embedding_cosine_idx
ON dataset_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100)
""",
"DROP INDEX IF EXISTS dataset_chunks_embedding_cosine_idx"
)
# indice ivfflat per distanza L2
execute(
"""
CREATE INDEX dataset_chunks_embedding_l2_idx
ON dataset_chunks
USING ivfflat (embedding vector_l2_ops)
WITH (lists = 100)
""",
"DROP INDEX IF EXISTS dataset_chunks_embedding_l2_idx"
)
# indice ivfflat per inner product
execute(
"""
CREATE INDEX dataset_chunks_embedding_ip_idx
ON dataset_chunks
USING ivfflat (embedding vector_ip_ops)
WITH (lists = 100)
""",
"DROP INDEX IF EXISTS dataset_chunks_embedding_ip_idx"
)
end
end
Se la tua build di pgvector include anche vector_l1_ops e le operator class per i bitstring, puoi aggiungere indici anche per quelli come nelle migration originali della repo. Se non li hai, creare quegli indici fallirà, quindi o li avvolgi in blocchi anonimi DO con gestione delle eccezioni, oppure li salti direttamente.
Esegui tutto con:
mix ecto.migrate
Ora Postgres è pronto per la full-text search, la ricerca vettoriale cosine, L2 e inner product, tutto sulla stessa tabella.
Definire lo schema in Elixir
Lo schema è diretto. Stai solo mappando quella migration in Ecto:
defmodule MyApp.DatasetChunk do
use Ecto.Schema
schema "dataset_chunks" do
field :source_path, :string
field :document_title, :string
field :chunk_index, :integer
field :content, :string
field :embedding, Pgvector.Ecto.Vector
field :embedding_binary, :string
timestamps(type: :naive_datetime_usec)
end
end
Usando Pgvector.Ecto.Vector dici a Ecto come codificare e decodificare tra liste Elixir e la colonna vector in Postgres.
embedding in Elixir sarà una lista di float tipo [0.034, -0.27, ...] e nel database un vector(384) a tutti gli effetti.
Portare gli embedding dentro la tabella
Non puoi chiedere a Postgres di generare embedding dal nulla. L’operazione avviene nel codice della tua applicazione.
Hai due strade realistiche:
- usare un modello locale via Bumblebee, come nella repo
- chiamare un’API esterna tipo OpenAI e salvare il vettore restituito
Per il database non cambia niente. Quello che conta è che per ogni chunk di testo tu abbia una lista di float di lunghezza fissa.
Un flusso di ingestione semplificato può essere questo:
defmodule MyApp.Rag.Loader do
alias MyApp.{Repo, DatasetChunk}
@embedding_dim 384
def ingest_markdown(path) do
content = File.read!(path)
chunks = split_into_paragraphs(content)
Enum.with_index(chunks)
|> Enum.each(fn {chunk_text, idx} ->
embedding = embed(chunk_text)
Repo.insert!(
%DatasetChunk{
source_path: path,
document_title: guess_title(content, path),
chunk_index: idx,
content: chunk_text,
embedding: embedding,
embedding_binary: to_binary_bits(embedding)
},
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:source_path, :chunk_index]
)
end)
end
defp split_into_paragraphs(content) do
content
|> String.split(~r/\n{2,}/, trim: true)
|> Enum.map(&String.trim/1)
end
defp guess_title(content, path) do
case Regex.run(~r/^#\s+(.+)$/m, content) do
[_, title] -> title
_ -> Path.basename(path)
end
end
defp embed(text) do
# Chiama qui il tuo modello di embedding
# Restituisci una lista di float di lunghezza 384
end
defp to_binary_bits(embedding) when is_list(embedding) do
embedding
|> Enum.map(fn x -> if x > 0.0, do: "1", else: "0" end)
|> Enum.join()
end
end
A livello concettuale è quello che fa già la tua repo, solo reso più esplicito.
Quando hai caricato qualche documento, puoi iniziare a giocare con le varie strategie di ricerca.
BM25 full-text search in PostgreSQL ed Elixir
Partiamo dal vecchio cavallo di battaglia: la full-text search.
Postgres può calcolare un ranking in stile BM25 usando ts_rank_cd. La tua migration ha già creato un indice GIN su o_tsvector('english', content).
La query SQL base è questa:
SELECT
id,
document_title,
chunk_index,
ts_rank_cd(
to_tsvector('english', content),
plainto_tsquery('english', $1)
) AS score,
ts_headline(
'english',
content,
plainto_tsquery('english', $1),
'MaxFragments=2, MaxWords=15, MinWords=5'
) AS snippet
FROM dataset_chunks
WHERE to_tsvector('english', content) @@ plainto_tsquery('english', $1)
ORDER BY score DESC
LIMIT 5;
In Ecto di solito la incapsuli in una funzione:
defmodule MyApp.Rag.Search do
import Ecto.Query
alias MyApp.{Repo, DatasetChunk}
def search_bm25(query, opts \\ []) do
limit = Keyword.get(opts, :limit, 5)
ts_query = fragment("plainto_tsquery('english', ?)", ^query)
base =
from c in DatasetChunk,
where: fragment("to_tsvector('english', ?) @@ ?", c.content, ^ts_query),
select: %{
chunk: c,
score: fragment("ts_rank_cd(to_tsvector('english', ?), ?)", c.content, ^ts_query),
snippet:
fragment(
"ts_headline('english', ?, ?, 'MaxFragments=2,MaxWords=5,MinWords=5')",
c.content,
^ts_query
)
},
order_by: [desc: fragment("ts_rank_cd(to_tsvector('english', ?), ?)", c.content, ^ts_query)],
limit: ^limit
Repo.all(base)
end
end
BM25 è utile quando gli utenti digitano parole che compaiono davvero nei documenti, quando vuoi snippet leggibili, e quando la precisione lessicale è importante.
Per alcuni casi d’uso RAG questa da sola è già sufficiente. Molte persone complicano troppo il retrieval quando una full-text search ben configurata basata su BM25 risolverebbe il problema.
Ricerca su vettori densi, più metriche, stessa tabella
Passiamo al lato semantico. Hai già salvato gli embedding nella colonna embedding. pgvector ti mette a disposizione diversi operatori di distanza.
In pgvector:
<->è la distanza L2 (Euclidea)<=>è la distanza cosine<#>è l’inner product
Scegli la metrica nell’ORDER BY e allinei la operator class dell’indice a quella metrica.
Ricerca con distanza cosine
La distanza cosine è la scelta predefinita in molti tutorial sul RAG.
L’idea:
- trasformi il testo della query in un vettore
q - esegui
ORDER BY embedding <=> qe prendi le distanze più piccole
In Elixir:
def search_embeddings_cosine(query, opts \\ []) do
with {:ok, vector} <- embed_query(query) do
do_search_cosine(vector, opts)
end
end
defp embed_query(text) do
case MyApp.Embeddings.embed(text) do
{:ok, list} when is_list(list) ->
{:ok, Pgvector.new(list)}
error ->
error
end
end
defp do_search_cosine(vector, opts) do
import Ecto.Query
alias MyApp.{Repo, DatasetChunk}
limit = Keyword.get(opts, :limit, 5)
q =
from c in DatasetChunk,
where: not is_nil(c.embedding),
select: %{
chunk: c,
distance: fragment("? <=> ?", c.embedding, ^vector)
},
order_by: fragment("? <=> ?", c.embedding, ^vector),
limit: ^limit
Repo.all(q)
end
Quando Postgres lo ritiene opportuno, userà l’indice ivfflat vector_cosine_ops definito nella migration.
Usa la cosine quando vuoi similarità semantica classica, quando gli embedding sono normalizzati e ti interessa più la direzione che la lunghezza dei vettori.
Ricerca con distanza L2
Passare a L2 è solo questione di cambiare operatore e usare l’indice giusto.
In SQL l’idea è:
SELECT ... FROM dataset_chunks ORDER BY embedding <-> $1 LIMIT 5;
In Elixir:
def search_embeddings_l2(query, opts \\ []) do
with {:ok, vector} <- embed_query(query) do
import Ecto.Query
alias MyApp.{Repo, DatasetChunk}
limit = Keyword.get(opts, :limit, 5)
q =
from c in DatasetChunk,
where: not is_nil(c.embedding),
select: %{
chunk: c,
distance: fragment("? <-> ?", c.embedding, ^vector)
},
order_by: fragment("? <-> ?", c.embedding, ^vector),
limit: ^limit
Repo.all(q)
end
end
Hai già creato un indice ivfflat con vector_l2_ops, quindi Postgres può sfruttarlo.
Scegli L2 se il tuo modello o il tuo caso d’uso presuppone una geometria euclidea, o se empiricamente vedi risultati migliori nei benchmark.
Ricerca con distanza L1
La distanza L1 (Manhattan) si vede meno nei tutorial, ma nella tua repo di benchmark è risultata molto veloce.
Purtroppo pgvector non fornisce un operatore dedicato per L1 in tutte le versioni, quindi la storia L1 dipende dalle operator class che hai compilato. Nella repo usi vector_l1_ops e un modulo di ricerca separato, che va benissimo se la tua build di pgvector lo supporta.
Il pattern lato Elixir è lo stesso di L2 e cosine. Fai l’embed della query, poi fai ORDER BY usando un’espressione di distanza che corrisponde alla operator class indicizzata. Se la tua versione di pgvector non ha un operatore L1 decente, hai due opzioni:
- aggiorni pgvector, oppure
- lasci perdere L1 e usi cosine / L2 / inner product
Forzare L1 quando l’estensione non lo supporta non ha senso. Finiresti solo per combattere con il database.
Ricerca con inner product
Per l’inner product usi <#>. È comodo quando vuoi uno score di similarità e non una distanza.
L’inner product cresce quando i vettori puntano in direzioni simili e hanno magnitudini maggiori. Con embedding normalizzati si comporta di fatto come una similarità cosine.
In SQL potresti scrivere:
SELECT id, -1 * (embedding <#> $1) AS score FROM dataset_chunks ORDER BY score DESC LIMIT 5;
Moltiplicare per -1 inverte il segno e ti permette di ordinare in modo decrescente sugli score più alti.
In Elixir:
def search_embeddings_dot(query, opts \\ []) do
with {:ok, vector} <- embed_query(query) do
import Ecto.Query
alias MyApp.{Repo, DatasetChunk}
limit = Keyword.get(opts, :limit, 5)
score_expr = fragment("-1 * (? <#> ?)", ^vector, field(DatasetChunk, :embedding))
q =
from c in DatasetChunk,
where: not is_nil(c.embedding),
select: %{
chunk: c,
score: fragment("-1 * (? <#> ?)", c.embedding, ^vector)
},
order_by: [desc: fragment("-1 * (? <#> ?)", c.embedding, ^vector)],
limit: ^limit
Repo.all(q)
end
end
Hai anche definito un indice ivfflat con vector_ip_ops nella migration, quindi può scalare.
Usa il dot product quando qualche logica a valle si aspetta uno score di similarità e quando sai che i tuoi embedding sono normalizzati o comunque su scale confrontabili.
Ricerca binaria Hamming e Jaccard
I vettori densi non sono l’unico modo per fare ricerca.
Puoi quantizzare ogni embedding in un bitstring e confrontare quelli.
Per un vettore di dimensione 384 puoi creare un bitstring lungo 384. Una regola semplice è: i valori maggiori di zero diventano 1, gli altri 0. È quello che faceva la funzione to_binary_bits/1 vista prima.
Per rendere utile questa cosa in fase di ricerca ti servono due elementi lato database:
- una colonna che contenga i bitstring (
embedding_binary) - funzioni e operatori che calcolino la distanza fra due bitstring
Le tue migration della repo li definiscono già. Una versione semplificata è:
def change do
execute(
"""
CREATE OR REPLACE FUNCTION binary_hamming_distance(text, text)
RETURNS integer
LANGUAGE SQL
IMMUTABLE
AS $$
SELECT bit_count(CAST($1 AS bit(384)) # CAST($2 AS bit(384)));
$$;
""",
"DROP FUNCTION IF EXISTS binary_hamming_distance(text, text)"
)
execute(
"""
CREATE OR REPLACE FUNCTION binary_jaccard_distance(text, text)
RETURNS double precision
LANGUAGE SQL
IMMUTABLE
AS $$
WITH a AS (SELECT CAST($1 AS bit(384)) AS bits_a),
b AS (SELECT CAST($2 AS bit(384)) AS bits_b)
SELECT
CASE
WHEN bit_count((a.bits_a | b.bits_b)) = 0 THEN 0.0
ELSE 1.0 - (
bit_count((a.bits_a & b.bits_b))::double precision /
NULLIF(bit_count((a.bits_a | b.bits_b)), 0)
)
END
FROM a, b;
$$;
""",
"DROP FUNCTION IF EXISTS binary_jaccard_distance(text, text)"
)
execute(
"""
CREATE OPERATOR <~> (
PROCEDURE = binary_hamming_distance,
LEFTARG = text,
RIGHTARG = text
)
""",
"DROP OPERATOR IF EXISTS <~> (text, text)"
)
execute(
"""
CREATE OPERATOR <%> (
PROCEDURE = binary_jaccard_distance,
LEFTARG = text,
RIGHTARG = text
)
""",
"DROP OPERATOR IF EXISTS <%> (text, text)"
)
end
Se la tua build di pgvector include anche bit_hamming_ops e bit_jaccard_ops, puoi creare indici ivfflat su embedding_binary usando quelle operator class.
La ricerca poi è concettualmente identica a quella sui vettori densi.
Per Hamming:
def search_embeddings_hamming(query, opts \\ []) do
with {:ok, bits} <- embed_and_quantize(query) do
import Ecto.Query
alias MyApp.{Repo, DatasetChunk}
limit = Keyword.get(opts, :limit, 5)
q =
from c in DatasetChunk,
where: not is_nil(c.embedding_binary),
select: %{
chunk: c,
distance: fragment("? <~> ?", c.embedding_binary, ^bits)
},
order_by: fragment("? <~> ?", c.embedding_binary, ^bits),
limit: ^limit
Repo.all(q)
end
end
defp embed_and_quantize(text) do
with {:ok, vec} <- MyApp.Embeddings.embed(text) do
{:ok, to_binary_bits(vec)}
end
end
Per Jaccard ti basta cambiare operatore in <%>.
Perché sbattersi con questa cosa? Per sperimentare. La ricerca binaria può essere più leggera o veloce in alcuni contesti e ti dà un modo economico per giocare con la quantizzazione e il matching approssimato senza introdurre un altro sistema.
Non farà miracoli. Non supererà automaticamente una ricerca vettoriale densa ben ottimizzata su ogni dataset, quindi trattala come un’opzione, non come una soluzione magica.
Scegliere la strategia di ricerca nella pratica
Ora hai diversi strumenti nello stesso database:
- full-text search BM25
- cosine, L2, L1, inner product su vettori densi
- Hamming e Jaccard su embedding binari
Non devi eleggere un vincitore assoluto per sempre. La cosa sensata è:
- partire da BM25 più una metrica vettoriale (cosine o L2)
- fare benchmark sui tuoi documenti reali e sulle tue query
- guardare sia la velocità sia la pertinenza, non solo una delle due
Nella tua repo di benchmark L1 è risultata più veloce delle altre per quel dataset. È un dato interessante, ma non significa che L1 sia magicamente la migliore ovunque. Ogni combinazione di dataset, indice e hardware si comporta in modo un po’ diverso.
Se vuoi tenere tutto semplice:
- usa BM25 quando l’utente sembra fare keyword search vera
- usa cosine o L2 quando vuoi ricerca semantica
- se serve, mescola i risultati facendo due query e riordinando
Sopra questo puoi sempre aggiungere qualcosa di più sofisticato in un secondo momento.
Ti serve davvero un vector database separato?
Arriviamo alla risposta diretta.
Se hai:
- milioni di chunk, non miliardi
- traffico da tipica app SaaS, non da TikTok
- un team già a suo agio con la gestione di Postgres
allora PostgreSQL con pgvector di solito basta per costruire e far girare un RAG serio.
Guadagni:
- una sola strategia di backup
- una sola storia di access control
- transazioni tra dati business e dati RAG
- meno rumore operativo
In cambio accetti:
- strutture di indice meno “esotiche” di alcuni motori specializzati
- un po’ di tuning manuale se vuoi prestazioni al top
Se invece stai puntando a:
- centinaia di milioni o miliardi di embedding
- latenze ridicole sotto carichi concorrenti molto alti
- multi-tenant complesso gestito direttamente dal motore vettoriale
allora sì, un vector database dedicato può avere senso. Scambi complessità con funzionalità e scala.
Il punto di tutto questo setup su Postgres non è fingere che i vector database non servano mai. È metterti in condizione di fare esperimenti tuoi prima di aggiungere un’altra scatola al diagramma dell’architettura.
Ora hai tutto quello che ti serve per farlo:
- una migration che trasforma Postgres in uno store per RAG
- un setup Elixir con pgvector integrato
- pattern SQL ed Ecto chiari per BM25, ricerca vettoriale densa e ricerca binaria
Carica i tuoi documenti reali, prova qualche ricerca, misura la latenza, guarda la qualità dei risultati e solo dopo decidi se un vector database separato vale davvero i pezzi in più che porta nello stack.
Windsurf
All my projects and even this website is build using Windsurf Editor. Windsurf is the most intuitive AI coding experience, built to keep you and your team in flow.