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.

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.

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

Section titled “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"

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.

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.

Every embedding model has a hard input ceiling — 8192 tokens for BGE-M3, 8191 for both OpenAI models — so a row longer than that cannot be one vector. It is split into overlapping passages of about 2000 characters (200 of overlap, cut on the nearest paragraph, line or sentence boundary), each embedded and stored on its own. A search returns the row once, ranked by its best passage.

This is automatic and needs no configuration. Three consequences worth knowing:

  • A row is capped at 32 chunks — roughly 64 KB of indexed text. Past that the remainder is not embedded, and the drop is logged with the row’s id.
  • Short rows are unchanged. A row that fits in one chunk keeps the plain item id as its vector id, exactly as before chunking existed, so no re-index is required — existing vectors stay valid.
  • Editing a row shortens its chunk list safely. Cutting a document from five passages to two deletes the other three, so text you removed stops matching queries. (Vector stores here cannot delete by metadata filter, so this happens by deriving the ids — see staleChunkIds in services/vectorize.ts if you are changing that code.)

Before chunking, an over-long row failed differently on each provider and silently on both: OpenAI rejects the request, so the row ended up with no vector and was invisible to mode: "vector" forever, while Workers AI truncates, so only the opening of the document was searchable.

Retrieval: ask for the passage, not the document

Section titled “Retrieval: ask for the passage, not the document”

Search returns whole rows. For RAG that is the wrong unit — you want the passage that matched, to put in a prompt. Pass passages: true and each row carries the chunks it matched on, best first:

Terminal window
curl -X POST $APP_URL/api/items/articles/search \
-H 'content-type: application/json' \
-d '{"q":"how do refunds work","mode":"vector","passages":true}'
{
"data": [
{
"id": "…", "title": "Billing handbook", "body": "…the whole document…",
"_passages": [
{ "text": "Refunds are issued to the original method within…", "score": 0.83, "index": 4 },
{ "text": "A partial refund leaves the subscription active…", "score": 0.71, "index": 5 }
]
}
],
"mode": "vector", "limit": 20
}

Also on client.from("articles").search({ q, passages: true }), on the collections.search MCP tool, and in GraphQL as a passages argument feeding a passages field on the row.

Three things to know:

  • Vector and hybrid only. A row matched by the keyword index alone has no passages and is returned without the key — an empty array would read as “this row matched no text”.
  • At most three passages per row, so one long document cannot fill a prompt on its own.
  • Withheld when the caller’s permission carries a field allow-list. A passage is text as embedded — every vectorize field concatenated — so returning it would hand back in full a field the row itself is stripped of. Chunk boundaries do not follow field boundaries, so it cannot be censored per field; the honest answer is to omit it.

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.

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 }

A stored file becomes rows with POST /api/items/{slug}/ingest — one row per section, which then flow through the ordinary create path and pick up the collection’s full-text and vector indexes on the way.

Terminal window
bun backlex items ingest docs \
--key handbook.md --body-field body \
--title-field title --source-field source --section-field section --replace
await client.from("docs").ingest({
key: "handbook.md",
bodyField: "body",
titleField: "title",
sourceField: "source", // set to the key on every row
sectionField: "section", // 0-based position in the document
replace: true, // drop this document's previous rows first
});

Also on the collections.ingest MCP tool, so an agent can make a handbook answerable without a human in the loop.

Why sections and not one row per document. A row is capped at 32 chunks — about 64 KB of indexed text — so a single-row ingest of a real handbook would stop being indexed part-way through, and silently. Sections keep every row well under the cap, and they are the granularity retrieval wants anyway: a search should answer with the section, not hand back a document to go read. Markdown headings are the boundary when the document has them; paragraphs otherwise, accumulated to about 4000 characters and never split mid-sentence.

Text-native formats only — .txt, .md, .html, .csv, .json. These decode with no dependency and behave identically on all eight runtimes backlex ships to. A PDF or Office document is refused by name rather than half-handled: extracting one needs either a library that does not run on workerd or a per-runtime capability (Cloudflare’s AI.toMarkdown()), which is its own piece of work. Convert the file first.

Two more things worth knowing:

  • replace needs sourceField — without it there is no way to tell which rows came from this document — and it needs delete permission in its own right, since the route’s own gate is create. Rows are removed through the ordinary delete path, so their vectors go with them.
  • Without replace, re-ingesting the same document adds a second copy. That is deliberate: silently deleting rows on a plain create would be the surprising behaviour.

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. Every namespace is additionally prefixed with the active workspace before it reaches the store, so two workspaces owning a same-slug collection never share vectors.

A namespace that names a collection takes that collection’s permission

Section titled “A namespace that names a collection takes that collection’s permission”

embedAndUpsert writes a collection’s chunks under its slug, so {"namespace": "employees"} on the raw endpoints addresses the employees collection’s index exactly. These endpoints therefore resolve the collection and require the same verb an equivalent items call would:

EndpointRequires on the named collection
POST /search, POST /queryread
POST /upsert, POST /embed-upsert, POST /deleteupdate

Matches are then hydrated back through the physical table with the caller’s own row condition, workspace, soft-delete and draft filters re-applied — the vector store has no permission model of its own, so an id that comes out of it is not yet an answer. A caller whose permission carries a field allow-list gets the match without metadata.content: a chunk is built from every vectorize field at once and chunk boundaries do not follow field boundaries, so it cannot be censored field by field (the same rule POST /items/{slug}/search applies to _passages).

A namespace that names no collection is a free-form per-workspace scratch space and stays open to any member of that workspace, as it has always been.

Collection slugs are not treated as secret

Section titled “Collection slugs are not treated as secret”

Those two behaviours are distinguishable: a namespace naming a collection the caller may not read answers 403, and a namespace naming nothing falls through to the scratch space. So a caller can probe namespaces and learn which collection slugs exist — including an app-plane end-user, for whom route-planes.ts declares /api/vector as plane: "either" and who has no GET /api/collections to list them with.

This is a decision, not an oversight (#333). A workspace’s collection names are routinely visible in the client application it serves — in URLs, in SDK calls, in the shape of its own API — so treating them as a secret would be a guarantee this system does not otherwise make. What is protected is the contents: the 403 is real, and the scratch space holds nothing a collection put there.

The alternative was to answer 403 for every namespace an app-plane caller may not read, which removes the oracle but also removes app-plane access to the scratch space entirely. That was not taken. If a deployment needs slugs to be unguessable, the answer is plane: "platform" on /api/vector, not a per-namespace disguise.

Changed in the 2026-09 hardening. All six endpoints were previously session-only, so any signed-in identity — including a workspace’s own app-plane end-users — could read back metadata.content, the verbatim indexed text of a collection whose GET /api/items/<slug> answered 403, and delete could silently destroy the index every mode: "vector" search depends on.

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.

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.

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.

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.

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.