Salta ai contenuti

Aggregazioni e ricerca

count, sum, avg, min e max sulle righe selezionate da un filtro, senza doverle recuperare:

GET /api/data/orders/aggregate?select=count(),sum(total)
{ "data": [{ "count": 128, "sum_total": 40522 }] }

Raggruppa per ottenere una riga per valore:

GET /api/data/orders/aggregate?select=count(),sum(total)&groupBy=status
{
"data": [
{ "status": "paid", "count": 96, "sum_total": 31200 },
{ "status": "pending", "count": 32, "sum_total": 9322 }
]
}

I risultati sono indicizzati per funzione e campo — count() diventa count, sum(total) diventa sum_total.

I gruppi si paginano come le righe di un elenco. limit li limita, offset (o page) li salta e orderBy li ordina per un campo di groupBy o per una chiave del risultato. L’ordine termina sempre sulle chiavi del gruppo, quindi ogni confine di pagina cade nello stesso punto. Un aggregato raggruppato senza limit viene troncato al valore predefinito di un elenco (50 gruppi), come un elenco. Con un limit — incluso quello predefinito — la risposta indica se ce ne sono altri, e aggregate() dell’SDK lo restituisce come meta nel risultato:

GET /api/data/orders/aggregate?select=count()&groupBy=customer&orderBy=count:desc&limit=20&offset=20
{ "data": [ … ], "meta": { "limit": 20, "offset": 20, "hasMore": true } }

offset, page o orderBy senza groupBy, o un cursore su qualsiasi aggregazione, restituisce 400 INVALID_AGGREGATE_WINDOW: un’aggregazione non raggruppata ha una sola riga, e un gruppo non è una riga dopo cui un cursore possa proseguire.

Dall’SDK, aggregate() accetta lo stesso orderBy di un find() — una tupla [key, "asc" | "desc"] oppure un elenco di tuple — dove la chiave è una colonna di groupBy o una chiave del risultato. Entrambe sono tipizzate: su un modello generato, una chiave che non è nessuna delle due non compila. In-process (rebase.data), i gruppi tornano nello stesso ordine che si ha via HTTP.

const topCustomers = await client.data.orders.aggregate({
select: [{ fn: "count" }, { fn: "sum", field: "total" }],
groupBy: ["customer"],
orderBy: ["sum_total", "desc"],
limit: 10
});

Accetta gli stessi filtri dell’endpoint di elenco, quindi un’aggregazione può essere ristretta nello stesso modo in cui si filtra un elenco:

GET /api/data/orders/aggregate?select=sum(total)&status=eq.paid&createdAt=gte.2026-01-01

Le aggregazioni richiedono un driver che le implementi. In caso contrario, l’endpoint risponde con 501 invece di un risultato vuoto — una dashboard non dovrebbe mostrare “nessun risultato” quando in realtà è “non supportato”.

Una colonna json o jsonb può essere filtrata per percorso (path), usando la sintassi a freccia di Postgres:

// Ordini i cui metadati indicano che il paese è US
const { data } = await client.data.orders
.where("metadata->>country", "==", "US")
.find();
// I percorsi nidificati si navigano con -> e si estrae il valore foglia con ->>
await client.data.orders.where("metadata->address->>city", "==", "Berlin").find();

Tramite REST:

GET /api/data/orders?metadata->>country=eq.US

I segmenti del percorso vengono sempre inviati come parametri associati (bound parameters), mai inseriti direttamente nel codice SQL.

->> restituisce testo, quindi i confronti sono confronti testuali — tranne quando a un operatore di ordinamento (>, >=, <, <=) viene passato un numero, nel qual caso esegue il cast a numerico:

await client.data.orders.where("metadata->>score", ">", 100).find(); // numerico: 9 < 100
await client.data.orders.where("metadata->>version", ">", "1.2").find(); // testo

Le righe il cui valore in quel percorso non è un numero vengono escluse dal confronto numerico invece di far fallire la query. I booleani vengono confrontati come "true" / "false", che è il modo in cui ->> li restituisce.

// Tramite parametri find
const { data } = await client.data.products.find({
searchString: "wireless headphones"
});
// Stile fluent
const { data } = await client.data.products
.search("wireless headphones")
.limit(10)
.find();

Per impostazione predefinita, si tratta di una ricerca per sottostringa senza distinzione tra maiuscole e minuscole (case-insensitive) tra le proprietà string di primo livello della collezione. Non è una ricerca full-text: non agisce all’interno delle proprietà map o array, non esegue lo stemming né il ranking, e non può utilizzare un indice.

Una collezione Postgres può abilitare una vera ricerca full-text dichiarando un blocco search, che rende inoltre i risultati ordinabili per _score. Consulta Search.

Per le collezioni con una proprietà vector, ordina le righe per similarità rispetto a un embedding di query. Le righe vengono restituite a partire dalle più vicine, ciascuna corredata da una _distance. La distanza è l’ordinamento: un orderBy sulla stessa query viene ignorato. Senza un limit una ricerca vettoriale restituisce 10 righe, non le 50 di un elenco.

const { data } = await client.data.docs
.vectorSearch("embedding", queryVector, { threshold: 0.35 })
.where("status", "==", "published")
.limit(10)
.find();

where sulla stessa query è un filtro su quali righe sono candidate — questo restituisce le righe più vicine che soddisfano anche i criteri, non le righe più vicine filtrate a posteriori. Sull’indice HNSW questo vale da pgvector 0.8.0 (vedi Filtri su un indice approssimato); su un pgvector più vecchio un filtro che poche delle righe più vicine superano restituisce una pagina corta. Generare queryVector è compito tuo: Rebase memorizza e cerca gli embedding, non li calcola.

Da 0.25 L’SDK invia una ricerca vettoriale come POST /api/data/<collection>/query, con l’embedding nel body: nell’URL, 768 dimensioni sono circa 17 KB, oltre ciò che accettano Node (16 KB) e la maggior parte dei proxy (nginx: 8 KB). count() con una ricerca vettoriale segue la stessa strada, come POST /api/data/<collection>/count. Contro un server precedente alla 0.24, che non ha una route del genere, entrambi ripiegano sulla GET — e un embedding lungo lì può comunque essere rifiutato con un 431 o un 414.

Da 0.25 Con un threshold, il risultato non ha meta.total — contare le righe entro una distanza lo calcola per ogni riga della tabella. Pagina con meta.hasMore, che resta esatto.

  • pgvector. Una proprietà vector viene compilata in una colonna VECTOR(n), e quel tipo proviene dall’estensione vector. Rebase la installerà per te, ma solo dove indichi che è consentito farlo:

    // config/resources.ts
    export const main = database({ extensions: ["vector"] });

    Quella riga è un permesso piuttosto che una richiesta — Rebase esegue CREATE EXTENSION IF NOT EXISTS vector solo quando qualcosa nel tuo schema ne ha bisogno. È un’opzione esplicita (opt-in) perché l’installazione di un’estensione dipende da fattori che Rebase non può verificare dall’interno della connessione: l’immagine deve includere la libreria (l’immagine di scaffold pgvector/pgvector:pg18 la include, una standard postgres:18 no), il ruolo deve avere i permessi per installarla, e un provider gestito deve averla inclusa nella allow-list.

    Se non specifichi nulla, Rebase non installerà niente — puoi in alternativa installarla una volta manualmente. In entrambi i casi la colonna viene creata, e Postgres la rifiuterà con type "vector" does not exist su un database privo dell’estensione, indicando entrambe le soluzioni possibili.

La colonna, il suo indice ANN e quel CREATE EXTENSION vengono generati all’interno di .rebase/sql/vector.sql, accanto a schema.sql e policies.sql, e rebase db push li applica automaticamente. Risiedono in un file separato perché Atlas — il motore alla base di db push — calcola il proprio diff materializzando schema.sql in un database temporaneo (scratch) che viene cancellato all’inizio di ogni esecuzione, quindi un VECTOR(n) lì dentro verrebbe risolto a fronte di un database che non può mai avere pgvector.

rebase db generate aggiunge quel file alla migrazione che genera, così una migrazione rieseguita su un database pulito creerà anche la colonna. Una modifica limitata alla sola proprietà vettoriale non produce alcuna migrazione, poiché lo schema di cui Atlas calcola il diff rimane invariato — db generate lo segnala quando accade.

Ogni colonna vettoriale riceve un indice HNSW per la distanza del coseno, creato insieme alla tabella e riportato all’avvio. La distanza del coseno viene usata perché è la metrica che vectorSearch utilizza a meno che non venga specificato distance — un indice supporta esattamente un operatore, quindi una query l2 eseguita su un indice coseno torna silenziosamente a una scansione sequenziale.

Puoi configurarlo, o disattivarlo, direttamente sulla proprietà:

embedding: {
type: "vector",
dimensions: 1536,
// Valori predefiniti: un indice HNSW, cosine. Ognuno di questi parametri può essere omesso.
index: {
method: "hnsw", // o "ivfflat"
distance: ["cosine", "l2"], // un indice per ciascuno
m: 24, // hnsw
efConstruction: 128 // hnsw
}
}

index: false mantiene intenzionalmente la scansione esatta. Al di sopra di 2000 dimensioni pgvector non è in grado di creare nessuno dei due tipi di indice, quindi la colonna viene creata e lasciata non indicizzata, e il log di avvio lo segnala — vectorSearch risponde comunque, tramite una scansione esatta.

vectorSearch è una query, non una sottoscrizione: invocare .listen() su di essa viene rifiutato invece di essere gestito come un normale elenco, perché nulla ricalcola le distanze a ogni operazione di scrittura.

  • Interrogare i dati — il query builder su cui queste funzionalità si basano
  • Ricerca — come vengono configurate la ricerca full-text e vettoriale sul backend
  • API REST — le stesse query tramite HTTP