Vector and Hybrid Search
Store embeddings alongside your data and run semantic similarity search powered by pgvector, with optional hybrid search that fuses vector similarity and full-text search.
Vector search is ideal for semantic retrieval, recommendations, and Retrieval-Augmented Generation (RAG): you generate embeddings with your own model (OpenAI, Cohere, a local model, etc.), store them in a vector column, and query by nearest neighbour.
How It Works
- Define one or more vector columns on a table by adding
x-vector-metadatato a field. - Insert rows containing the embedding as a plain array of numbers.
- Search by passing a pre-embedded query vector to the
__vector_nearfilter.
The Data Service automatically creates an HNSW index at table-creation time, so nearest-neighbour queries stay fast as your data grows.
The Data Service does not generate embeddings for you. Embed your text/images on the client (or in a Function) and send the resulting float array.
flat_table datasourceVector columns are only supported on tables in a flat_table datasource (they map to real pgvector columns). Tables on a jsonb datasource cannot define vector columns or run __vector_near queries.
Defining a Vector Column
Add a field of type: "any" with an x-vector-metadata object. Only dimensions is required; everything else has a sensible default.
{
"name": "documents",
"json_schema": {
"fields": [
{ "name": "id", "type": "string", "format": "uuid", "constraints": { "required": true } },
{ "name": "title", "type": "string" },
{ "name": "content", "type": "string" },
{
"name": "embedding",
"type": "any",
"x-vector-metadata": {
"dimensions": 1536,
"distance": "cosine",
"index_type": "hnsw",
"storage_type": "vector"
}
}
],
"primaryKey": ["id"]
}
}
Create the table:
POST /api/apps/{app-slug}/datatables/
x-vector-metadata options
| Key | Required | Default | Description |
|---|---|---|---|
dimensions | Yes | — | Number of dimensions in the embedding. 1–2000 for vector storage, up to 4000 for halfvec. |
distance | No | cosine | Distance metric: cosine, l2 (Euclidean), or ip (inner product). |
index_type | No | hnsw | ANN index type. Only hnsw is supported. |
storage_type | No | vector | vector (full float32 precision, max 2000 dimensions) or halfvec (half precision — ~half the storage, supports up to 4000 dimensions, with a small recall trade-off). |
halfvecstorage_type: "vector" is limited to 2000 dimensions. If your embedding is larger (e.g. OpenAI text-embedding-3-large at 3072 dims), set storage_type: "halfvec" — otherwise table creation is rejected.
The index is built with sensible defaults (m: 16, ef_construction: 64). To trade build time and memory for recall, override them with an optional index_params object:
{
"name": "embedding",
"type": "any",
"x-vector-metadata": {
"dimensions": 1536,
"distance": "cosine",
"index_params": { "m": 32, "ef_construction": 128 }
}
}
Higher m / ef_construction improve recall at the cost of a slower, larger index. Leave them unset unless you're tuning for a specific workload.
storage_typeUse vector for most cases. Choose halfvec for very high-dimensional embeddings or when storage size matters more than a small loss in recall.
The metric you set here is baked into the index. Searching with a different metric returns an error (see Errors) — so pick the metric that matches how your embedding model was trained (most modern text embeddings use cosine).
Adding a vector column to an existing table
You don't have to define the vector column up front. Add an x-vector-metadata field to an existing table with a schema update (PATCH) and the Data Service migrates the table for you — it runs ALTER TABLE ADD COLUMN and builds the HNSW index automatically:
PATCH /api/apps/{app-slug}/datatables/{table-name}/
Existing rows get NULL for the new column until you backfill them with embeddings. Rows without an embedding are simply not returned by __vector_near searches.
The HNSW index is created as part of the schema migration. On a table that already holds a lot of rows this can take a while and hold a lock. For very large tables, add the empty vector column first, backfill the embeddings, and build the HNSW index separately during a maintenance window.
Inserting Embeddings
Embeddings are inserted like any other field — as a JSON array of numbers:
- REST API
- Python
POST /api/apps/blog-app/datatables/documents/data/
{
"id": "1f3d...",
"title": "Intro to Vector Search",
"content": "Vector search finds semantically similar items...",
"embedding": [0.012, -0.034, 0.221, ...]
}
client.database.from_("documents").create({
"id": "1f3d...",
"title": "Intro to Vector Search",
"content": "Vector search finds semantically similar items...",
"embedding": query_vector, # list[float] from your embedding model
}).execute()
Similarity Search
Search by passing your query embedding to {field}__vector_near. Results are ordered by nearest neighbour first.
- REST API
- Python
GET /api/apps/blog-app/datatables/documents/data/?embedding__vector_near=[0.01,-0.03,0.22,...]&_topk=10
The vector value is a JSON array. Remember to URL-encode it in real requests.
results = (
client.database.from_("documents")
.vector_search("embedding", query_vector, topk=10)
.execute()
)
Response:
{
"status": "success",
"message": "Data retrieved successfully",
"data": [
{
"id": "1f3d...",
"title": "Intro to Vector Search",
"content": "Vector search finds semantically similar items...",
"_vector_score": 0.0421,
"_similarity_score": 0.9579
}
],
"total": 1
}
Search parameters
| Parameter | SDK argument | Default | Description |
|---|---|---|---|
_topk | topk | 10 | Size of the nearest-neighbour window — the maximum number of candidates the search returns. Must be at least 1; capped at the server limit (1000 by default). |
_vector_metric | metric | column's metric | Validates the request against the column's index metric. Must match the metric the column was created with. |
_vector_threshold | threshold | none | Maximum distance cutoff — results farther than this are discarded. |
_vector_ef_search | ef_search | index default | HNSW probe depth. Higher = more accurate recall, slower query. Must be between 1 and 1000. |
Result scores
Every pure-vector result row carries two scores:
| Field | Meaning | Direction |
|---|---|---|
_vector_score | Raw pgvector distance (useful for tuning _vector_threshold). | Lower = more similar. |
_similarity_score | Normalised, human-facing similarity. | Higher = more similar. |
_similarity_score is derived from the distance per metric:
| Metric | Transform | Range | Perfect match |
|---|---|---|---|
cosine | 1 - distance | [-1, 1] | 1.0 |
l2 | 1 / (1 + distance) | (0, 1] | 1.0 |
ip | de-negated inner product | unbounded | n/a |
For the ip metric, _similarity_score is the raw inner product (higher still means more similar), but it is not a normalised 0–1 value. Use cosine if you need a bounded similarity.
__vector_near is read-only__vector_near is a search operator for reading data only. You can't use it in a delete or update filter (e.g. to "delete the nearest rows") — such requests are rejected. Delete and update by scalar filters (id, status, etc.) instead.
One Vector Column Per Table
A table may declare at most one vector column. To use different metrics or modalities — for example a text embedding and an image embedding, or cosine vs L2 — create separate tables, one vector column each:
// documents_text (cosine)
{
"fields": [
{ "name": "id", "type": "string", "format": "uuid", "constraints": { "required": true } },
{ "name": "embedding", "type": "any", "x-vector-metadata": { "dimensions": 1536, "distance": "cosine" } }
],
"primaryKey": ["id"]
}
// documents_image (l2)
{
"fields": [
{ "name": "id", "type": "string", "format": "uuid", "constraints": { "required": true } },
{ "name": "embedding", "type": "any", "x-vector-metadata": { "dimensions": 512, "distance": "l2" } }
],
"primaryKey": ["id"]
}
Search each table independently (the metric must match the column's index):
GET /.../documents_text/data/?embedding__vector_near=[...]&_topk=10
GET /.../documents_image/data/?embedding__vector_near=[...]&_topk=10&_vector_metric=l2
Declaring more than one vector field in a single schema is rejected at create/update time (HTTP 400). Split additional embeddings into their own tables.
Likewise, a single query may search only one vector field — passing more than one {field}__vector_near filter in the same request is rejected (HTTP 400).
Pagination
Vector search ranks only the top _topk candidates, so pagination must stay inside that window. The page offset can never reach beyond rank _topk.
# _topk=10, 5 per page, page 2 → ranks 6–10 ✅
GET /.../documents/data/?embedding__vector_near=[...]&_topk=10&page=2&page_size=5
If the offset reaches past the window, the request returns 400 with guidance to increase _topk:
# offset (3) >= _topk (3) → 400
GET /.../documents/data/?embedding__vector_near=[...]&_topk=3&offset=3
{
"message": "Offset (3) exceeds the vector search window.",
"detail": "Increase _topk to at least 4 to reach this page (current _topk=3)."
}
To page deeper, raise _topk so the window covers the pages you need.
Hybrid Search
Hybrid search combines vector similarity with PostgreSQL full-text search and fuses the two rankings using Reciprocal Rank Fusion (RRF). This often beats either method alone — vector search captures semantic meaning while full-text search captures exact keyword matches.
1. Enable full-text search on the table
Add search_fields to the schema. This auto-generates a search_vector column and a GIN index.
{
"fields": [
{ "name": "id", "type": "string", "format": "uuid", "constraints": { "required": true } },
{ "name": "title", "type": "string" },
{ "name": "content", "type": "string" },
{ "name": "embedding", "type": "any", "x-vector-metadata": { "dimensions": 1536 } }
],
"primaryKey": ["id"],
"search_fields": [
{ "field": "title", "weight": "A" },
{ "field": "content", "weight": "B" }
],
"search_config": "english"
}
search_fields accepts plain field names or { "field", "weight" } objects (weights A–D, highest to lowest). See Search language for search_config.
2. Query with both signals
- REST API
- Python
GET /api/apps/blog-app/datatables/documents/data/?embedding__vector_near=[...]&search_vector__search=machine+learning&_hybrid_strategy=rrf&_hybrid_alpha=0.5&_topk=10
results = (
client.database.from_("documents")
.vector_search("embedding", query_vector, topk=10)
.search("machine learning")
.hybrid(strategy="rrf", alpha=0.5)
.execute()
)
Hybrid parameters
| Parameter | SDK argument | Default | Description |
|---|---|---|---|
_hybrid_strategy | strategy | — | Fusion strategy. rrf (Reciprocal Rank Fusion) is supported. Required to enable hybrid mode. |
_hybrid_alpha | alpha | 0.5 | Balance between the two signals: 1.0 = pure vector, 0.0 = pure full-text, 0.5 = equal weight. Must be between 0.0 and 1.0. |
Hybrid result scores
| Field | Meaning |
|---|---|
_hybrid_score | Combined RRF relevance score the results are ranked by. |
_vector_score | This row's contribution from the vector ranking. |
_fts_score | This row's contribution from the full-text ranking. |
Hybrid results are ranked by _hybrid_score (rank-based fusion) and do not include _similarity_score, which is specific to pure-vector search.
Search language (search_config)
Full-text search is language-aware. Set search_config on the schema to the PostgreSQL text-search configuration that matches your content's language. It defaults to english.
{
"search_fields": ["title", "content"],
"search_config": "spanish"
}
search_config controls stemming and stop-word handling for both the stored search_vector column and the query, so they always stay consistent. It must be one of the text-search configurations installed in PostgreSQL — for example english, spanish, french, german, portuguese, russian, or simple (no stemming). An invalid value is rejected at table creation.
Errors and Validation
The following conditions return 400 Bad Request:
| Condition | Example |
|---|---|
| Query vector dimensions don't match the column | 3-dim vector against a 4-dim column |
_vector_metric doesn't match the column's index metric | _vector_metric=l2 on a cosine column |
| Invalid metric | _vector_metric=manhattan |
| Empty vector | embedding__vector_near=[] |
| Non-finite values | vector containing NaN or Infinity |
| Non-numeric element | vector containing true or a string |
| Searching a field that isn't a vector column | unknown_field__vector_near=[...] |
_topk less than 1 | _topk=0 |
_vector_ef_search out of range | _vector_ef_search=0 or _vector_ef_search=2000 |
_hybrid_alpha out of range | _hybrid_alpha=1.5 |
| Unknown hybrid strategy | _hybrid_strategy=bm25 |
| Offset beyond the top-k window | offset=3 with _topk=3 |
dimensions above 2000 with storage_type: "vector" | use storage_type: "halfvec" instead |
Invalid distance, index_type, or storage_type at table creation | distance: "manhattan", index_type: "ivfflat", storage_type: "float8" |
search_config isn't an installed PostgreSQL text-search config | search_config: "klingon" |
Limits and Configuration
| Setting | Default | Description |
|---|---|---|
| Max dimensions | 2000 (vector) / 4000 (halfvec) | Maximum dimensions per vector column, by storage type. |
Max _topk | 1000 | Upper bound on the search window per request (protects the database from excessive work). Requests above this are clamped, not rejected. |
Vector search is always available — there is no setting to turn it off. The pgvector extension is provisioned by the platform, so you don't need to install anything to use vector columns.
Related
- Querying and Filtering — filters, sorting, and full-text search basics
- Indexes — GIN and general index details (the HNSW vector index is created for you automatically)
- Schema Reference — full field and schema options
- Functions — generate embeddings server-side