Perché forse non ti serve un vector database se PostgreSQL può alimentare il tuo RAG

Perché forse non ti serve un vector database se PostgreSQL può alimentare il tuo RAG

By Yuriy Zhar 8 min read
Vuoi costruire RAG senza trascinarti in casa un nuovo vector database? In questa guida vediamo passo passo come trasformare il “vecchio” PostgreSQL in un backend RAG serio usando pgvector, Elixir e una singola tabella dataset_chunks per alimentare BM25, ricerca vettoriale densa e perfino ricerche binarie Hamming e Jaccard, così puoi vedere cosa funziona davvero sui tuoi dati prima di aggiungere altra infrastruttura.

Probabilmente hai visto questa ricetta per costruire un sistema RAG decine di volte:

  1. Scegli un modello di embedding
  2. Scegli un vector database
  3. 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 pgvector tramite 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:

  1. La dipendenza in mix.exs
  2. Una definizione di tipo personalizzata per Postgrex
  3. 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:

  1. trasformi il testo della query in un vettore q
  2. esegui ORDER BY embedding <=> q e 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:

  1. una colonna che contenga i bitstring (embedding_binary)
  2. 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
Strumento Consigliato

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.

Share this article:
Yuriy Zhar

Yuriy Zhar

github.com

Passionate web developer. Love Elixir/Erlang, Go, TypeScript, Svelte. Interested in ML, LLM, astronomy, philosophy. Enjoy traveling and napping.

Rimani aggiornato

Iscriviti alla nostra newsletter e ricevi gli ultimi articoli direttamente nella tua casella di posta.