Skip to content
Data

Vector search & AI

Semantic (vector) search over your collections — pgvector on Postgres, native vectors on Turso/libSQL, Cloudflare Vectorize on D1.

Backlex can embed your records and run semantic (vector) search over them. Mark a collection vectorizable, and every write auto-embeds the chosen fields and stores the vector; queries embed the search text and run approximate nearest-neighbour (ANN) search. The same vectors back the Ask AI page’s vector.search tool.

Two things have to be in place: a vector store (where vectors live) and an embedding provider (text → vector). Which vector store you get depends on your database.

Vector store by database

Where vectors live depends on the database:

  • Postgres has the pgvector extension built in — nothing extra.
  • Turso / libSQL has native vector functions (F32_BLOB columns, vector_distance_cos()) — vectors live in-database, no extra service.
  • D1 and plain Bun SQLite have no vector primitives (D1 can’t load extensions; bun:sqlite has no vector funcs), so they must pair with Cloudflare Vectorize.
DatabaseVector storeExtra setup
Postgres (Neon, Supabase, self-host)pgvector (in your DB)none — works out of the box
Turso / libSQL (LIBSQL_URL)native libSQL vectors (in your DB)none — works out of the box
Cloudflare D1Cloudflare Vectorize (required)create + bind indexes (below)
Bun SQLite (bun:sqlite)none — use Turso/libSQL or Postgres insteadswitch to LIBSQL_URL (even file:) for in-DB vectors
Xata PostgresCloudflare VectorizeXata ships no pgvector, so pair it with Vectorize

Any deployment can instead point at an external vector database — see Bring your own vector database.

If none is configured, the vector endpoints fail loudly with a “configure a vector backend” message rather than silently no-op’ing.

Turso/libSQL vector search is exact (brute-force), not approximate. It scans every row in the collection’s namespace and orders by cosine distance — correct for any dimension (including openai-3-large at 3072, which exceeds Vectorize’s 1536 cap) and exact per namespace. The libSQL ANN index (vector_top_k) is a future optimization for very large collections.

Enable native vectors on Turso / libSQL

Point the app at a libSQL database with LIBSQL_URL (a Turso libsql://… URL plus LIBSQL_AUTH_TOKEN, or a local file:…/:memory: path). The migration adds an F32_BLOB embedding column to each per-model table automatically; on write the chosen fields are embedded and stored in-database. Then configure an embedding provider and a default model:

Terminal window
LIBSQL_URL=libsql://my-db-org.turso.io
LIBSQL_AUTH_TOKEN=eyJ...
OPENAI_API_KEY=sk-... # or an [ai] binding / EMBEDDING_HTTP_URL
EMBEDDING_DEFAULT_MODEL=openai-3-small

No index to create and nothing to bind — unlike Vectorize, the vectors live in the same database as your rows.

On backlex.cloud this is automatic: every project is D1, and provisioning creates and binds a per-project Vectorize index for you. Managed AI is metered + capped per plan; Free projects bring their own model over MCP instead. Self-hosters configure the pieces below.

Enable vectors on a self-hosted D1 / SQLite deploy

You’re on Cloudflare Workers. Create a Vectorize index per embedding model you want (dimensions are fixed at creation), then bind it in wrangler.toml.

Terminal window
# pick the model(s) you need — dimensions must match the model
wrangler vectorize create backlex-bge-m3 --dimensions=1024 --metric=cosine
wrangler vectorize create backlex-openai-1536 --dimensions=1536 --metric=cosine
# wrangler.toml — uncomment only the models you created (CF validates bindings
# at deploy, so a binding to a non-existent index breaks the deploy).
[[vectorize]]
binding = "VECTORIZE_BGE_M3"
index_name = "backlex-bge-m3"
# Workers AI bge-m3 needs the AI binding:
[ai]
binding = "AI"

Then configure an embedding provider for that model (see below) and set a default model so vectorizable collections embed without per-collection config:

[vars]
EMBEDDING_DEFAULT_MODEL = "bge-m3"

Embedding providers

The embedding model determines the provider (and the index dimensions):

Model keyProviderDimensionsNeeds
bge-m3Workers AI1024[ai] binding
openai-3-smallOpenAI1536OPENAI_API_KEY
openai-3-largeOpenAI3072OPENAI_API_KEY (exceeds Vectorize’s 1536 max — Postgres or Turso/libSQL only)
self-host-bge-m3Self-host (TEI / Ollama / vLLM)1024EMBEDDING_HTTP_URL (+ EMBEDDING_HTTP_TOKEN)

OpenAI and self-host run on your own keys (your cost). On backlex.cloud the Workers-AI path runs through the control-plane gateway (metered + hard-capped); self-hosted, it uses your own [ai] binding.

Make a collection vectorizable

Turn on the collection master switch and flag the text fields to embed. On each write, the flagged text / longtext fields are concatenated and embedded; the vector is upserted under the collection’s namespace.

// collection
{
"vectorize": true, // master switch
"vectorizeModel": "bge-m3", // optional; defaults to EMBEDDING_DEFAULT_MODEL
"fields": [
{ "name": "title", "type": "text", "vectorize": true },
{ "name": "body", "type": "longtext", "vectorize": true }
]
}

Embedding on write is best-effort — a provider/store hiccup is logged but never blocks the item write.

In the admin UI: toggle Vector search (semantic) on the collection’s Settings card (it also hosts the embedding-model picker and warns when the chosen model’s provider or the vector store isn’t configured — readiness comes from GET /api/vector/capabilities), and flip Vectorize on each text/longtext field in the Add/Edit field dialog.

Backfilling existing rows

Rows written before the toggle are not embedded automatically (each row is one embedding-provider call, so backfill is a deliberate action — unlike the full-text index, which auto-backfills). Run it once from the Settings card’s Embed all rows button, or:

Terminal window
POST /api/collections/articles/vectorize
# → { "ok": true, "processed": 1240, "skipped": 12, "total": 1252 }

Endpoints

Under /api/vector (see also the vector.search MCP tool):

EndpointPurpose
GET /capabilitiesstore + per-model readiness (drives the admin model picker)
POST /embed-upsertserver embeds text, then upserts
POST /searchserver embeds the query text, then ANN-searches
POST /upsertupsert pre-computed vectors
POST /querysearch by a pre-computed query vector
POST /deletedelete by id (namespace-scoped)

Vectors are isolated per collection via a namespace (the collection slug), so one index safely holds many collections.

MCP: agents get the same surface — vector.search, vector.upsert, vector.capabilities (readiness check), plus schema.update_collection (vectorize / vectorizeModel) and schema.vectorize_backfill for the manual embed backfill.

CLI: backlex collections vectorize <slug> runs the backfill and prints the processed/skipped counts.

Vector (semantic) search and full-text (keyword) search are complementary — embeddings capture meaning, the keyword index captures exact terms. POST /api/items/{slug}/search with mode: "hybrid" runs both and fuses them with Reciprocal Rank Fusion, returning whole rows with the caller’s read permission and tenant scope enforced. See Full-text & hybrid search for the endpoint, RRF details, and the fts / searchable collection flags.

Bring your own vector database

If you already run Pinecone or Qdrant, point backlex at it instead of using the database-native store. Both are configured by environment variables and take precedence over pgvector / libSQL — wiring one on a Postgres deployment is read as “I mean it”.

Resolution order: Vectorize bindings → Pinecone → Qdrant → pgvector → libSQL → none.

Per-model indexes are not optional

An index (Pinecone) or collection (Qdrant) fixes its vector dimension at creation, and the embedding models do not share dimensions:

ModelDimensions
bge-m3, self-host-bge-m31024
openai-3-small1536
openai-3-large3072

So each model you intend to use needs its own index. A model with no index configured throws on use rather than falling back to another one — a cross-dimension write would be rejected anyway, but a same-dimension write would succeed and silently poison every search result in that index.

The admin’s model picker reads this: a model without an index shows as unavailable rather than failing at first embed.

Pinecone

Terminal window
PINECONE_API_KEY=pcsk_…
# The index HOST, not its name — copy it from the Pinecone console or
# `describe_index`. Taking the host avoids a control-plane lookup per cold start.
PINECONE_INDEX_OPENAI=my-1536-idx-abc123.svc.us-east-1.pinecone.io
PINECONE_INDEX_OPENAI_LARGE=my-3072-idx-abc123.svc.us-east-1.pinecone.io
PINECONE_INDEX_BGE_M3=my-1024-idx-abc123.svc.us-east-1.pinecone.io
PINECONE_INDEX_SELF_HOST_BGE_M3=

Namespaces map onto Pinecone’s native namespaces. Because Pinecone carries the namespace on the request rather than the record, an upsert batch spanning several namespaces is split into one call each.

Qdrant

Works with Qdrant Cloud or a self-hosted instance; the API key is optional, so a local Qdrant needs only a URL.

Terminal window
QDRANT_URL=https://xyz.eu-central.aws.cloud.qdrant.io:6333
QDRANT_API_KEY= # omit for a local/anonymous instance
QDRANT_COLLECTION_OPENAI=items-1536
QDRANT_COLLECTION_OPENAI_LARGE=items-3072
QDRANT_COLLECTION_BGE_M3=items-1024
QDRANT_COLLECTION_SELF_HOST_BGE_M3=

Create each collection with the matching size, e.g.:

Terminal window
curl -X PUT "$QDRANT_URL/collections/items-1024" \
-H "api-key: $QDRANT_API_KEY" -H 'content-type: application/json' \
-d '{"vectors":{"size":1024,"distance":"Cosine"}}'

Namespaces are stored as a namespace payload field and filtered on, rather than as a collection each: Qdrant filters payload cheaply, and collection-per-namespace would multiply setup without adding isolation.