# Layer — Community Edition Search endpoint: https://hevlayer.com/api/ask/ce # Agents Source: https://hevlayer.com/docs/ce/agents import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; Layer ships agent skills as files, in the [`hev/layer`](https://github.com/hev/layer) repository. They are plain `SKILL.md` documents with YAML frontmatter — no harness-specific format and nothing to install beyond putting the directory where your agent looks. ```sh git clone https://github.com/hev/layer.git ``` | Skill | Use it for | | --- | --- | | `hevlayer-search-app` | Building a search application: schema design, connecting a source, chunking, indexing, querying, generating the UI. | | `hevlayer-docs` | Answering Layer questions from the docs instead of from memory. | | `hevlayer-layer-cli` | Driving the `layer` CLI — environments, indexes, pipelines, install. | `hevlayer-search-app` also carries `store-capabilities.md`, a generated table of which wire features each backend serves — the answer to "can I do this?", so an agent reads coverage instead of guessing and hitting a [`422 UnsupportedByStore`](/docs/ce/stores#the-fail-fast-contract). ## Install the skills Claude Code picks the skills up from a clone with no setup: `.claude/skills/` in the repository links to `skills/`. For any other harness that reads a skill directory: ```sh scripts/install-skills.sh ``` That copies the skills into `$AGENT_SKILL_HOME`, defaulting to `${CODEX_HOME:-~/.codex}/skills`. Point it elsewhere with the variable: ```sh AGENT_SKILL_HOME=~/.config/my-agent/skills scripts/install-skills.sh ``` If your harness has no skill directory, paste the body of a `SKILL.md` into `AGENTS.md` or the equivalent. The files are written to work either way. The repository's own `AGENTS.md` is the starting point. The skills teach Layer against the local Compose stack — the gateway, Postgres and the CPU embedding service on your laptop. No account, no license, no API key. Use the docs from your coding agent through the same edition-specific corpus as the site. Search results cite heading anchors in the selected view. ## Local docs From the repository's `site/` directory: ```sh pnpm run ask --edition ce overview pnpm run ask --edition ce search "write and query rows" pnpm run ask --edition ce section get "quickstart#2-write-rows" ``` Use `--edition pro` to select the full documentation. Local retrieval needs no API key. It generates the edition projections from the committed digest and source corpus, preserving untouched full-corpus summaries and glossary. ## Hosted docs Point the ask CLI at `https://hevlayer.com/api/ask/ce` for Community Edition or `https://hevlayer.com/api/ask/pro` for the full docs. For example: ```sh layer ask --endpoint https://hevlayer.com/api/ask/ce tree ``` ## Text exports [CE index](/docs/ce/llms.txt) and [CE full text](/docs/ce/llms-full.txt) contain only the CE view. The full-product equivalents are [full index](/docs/pro/llms.txt) and [full text](/docs/pro/llms-full.txt). Keep the edition in copied citations so readers and agents open the same view. # CLI retrieval Source: https://hevlayer.com/docs/ce/cli-retrieval ## JSON contract Retrieval commands emit JSON by default. `-o json` makes that intent explicit; `-o names` is rejected. Supply a JSON request object through `-f request.json` or `-f -` for stdin. The request uses the existing gateway grammar; the CLI preserves numeric IDs, filters, ranking expressions, and unknown fields for gateway validation. It does not rewrite a query into another retrieval mode. ```sh layer query articles -f query.json -o json layer search articles -f search.json -o json layer read articles -f read.json -o json layer capabilities articles -o json layer capabilities primary --store -o json ``` Query, search, read, and scan replies have this envelope: ```json { "contract": "layer.retrieval.v1", "data": { "rows": [], "next_cursor": "opaque" }, "headers": { "x-layer-next-cursor": "opaque" } } ``` `data` is the complete gateway JSON body, including actual hybrid legs, routing, performance, rerank degradation, and future response extensions. `headers` includes any returned `x-layer-next-cursor`, `x-layer-stable-as-of`, `x-layer-warning`, `x-layer-cache`, and `traceparent`. Missing fields stay missing. A successful command prints one JSON object. Failures print no result, return exit 1, and report the HTTP status and gateway error code on stderr. Malformed local input returns exit 2. Error messages and credentials are not copied from gateway bodies. Requests are not retried or redirected. `--timeout 30s` bounds each command (including both capability reads). SIGINT and SIGTERM cancel in-flight requests. Cancellation does not delete a server scan job: use `scan delete` explicitly. Request files are limited to 8 MiB and responses to 32 MiB; reduce page size if a response exceeds the limit. ## Supported retrieval APIs | Command | Gateway API and semantics | | --- | --- | | `query NS -f FILE` | `POST /v2/namespaces/NS/query`; vector, stored-vector seeds, BM25, HybridText, Embed, ordered/filter-only queries, batch and supported native fusion/aggregation bodies | | `query-all -f FILE` | `POST /v2/query`; federated retrieval across body-specified namespaces | | `explain NS -f FILE` | Native `explain_query` plan where the store supports it | | `search NS -f FILE` | `POST /v2/namespaces/NS/search`; schema-based query embedding, actual ANN/BM25/fuzzy leg reports and optional reranking; no pagination | | `read NS -f FILE` | `POST /v2/namespaces/NS/documents`; `ids` plus optional `include_attributes`, returning documents and missing IDs | | `capabilities NS` | Namespace store report plus metadata/schema; fails if either read fails | | `capabilities STORE --store` | `GET /v2/vectorstores/STORE/capabilities`; store report only | | `scan create NS -f FILE` | Create count, IDs or values selection; filters, full text, HybridText or ANN radius where supported | | `scan list NS` / `scan get NS ID` | Discover jobs and read status, failure and completeness | | `scan results NS ID --limit 1000 --offset 0` | Read a bounded IDs/values page; limit 1..10000, offset nonnegative | | `scan delete NS ID` | Delete server scan state | This is a consumer of the existing [query](/docs/ce/api/query), [search](/docs/ce/api/search), and [embedding](/docs/ce/api/embed) contracts. Unsupported store operations retain their gateway error; the CLI implements no local fuzzy, regex, dense, or hybrid search engine. ## Capabilities and actual legs Namespace capability output is `{contract, capabilities, metadata}`. `capabilities` identifies the selected store and its declared features and limits. `metadata.schema` identifies namespace indexing, including `full_text_search`, `fuzzy`, vector definitions, and any `embed` model declarations. Store support alone does not prove an attribute is indexed, nor that a particular query ran every possible leg. Preflight must inspect both the feature report and the schema. Preserve the receipt in the private integration, then inspect the query response's actual `hybrid` echo. HybridText can execute a single BM25 leg when fuzzy indexing is absent; `hybrid.legs: 1` alone does not prove dense or fuzzy retrieval. Query routing may add ANN only where configured and supported. `/search` reports individual effective leg kinds and dropped legs; use those reports to label runs. Never infer executed legs from a command name. BM25 uses `rank_by: ["text", "BM25", "query"]`. HybridText uses `rank_by: ["text", "HybridText", "query", {"fuzziness":"auto"}]`. Fuzzy and regex filters are sent using the gateway grammar only where the store and attribute support them. There is no separate CLI regex contract: check the effective store report and a successful gateway response for the exact requested semantics. Missing support is a gap, not a fallback engine. ## Dense embedding compatibility For schema-managed embeddings, query with `rank_by: ["text", "ANN", ["Embed", "query text"]]` and inspect the namespace's `embed` declaration. The gateway resolves that declaration for query embedding. A source declaration alone does not prove historical vectors were produced by it: verify the existing corpus embedding manifest before claiming compatibility. `search` also uses the namespace's schema embedding declaration; set `"rerank": false` explicitly when reranking is unwanted. For numeric ANN, including batch, federated and radius-scan selectors, `query`, `query-all`, and `scan create` require `--embedding-space space.json`. The receipt must contain matching `query` and `document` identities: ```json { "query": { "model": "model-name", "revision": "immutable-model-revision", "dims": 2, "normalization": "l2", "distance_metric": "cosine_distance" }, "document": { "model": "model-name", "revision": "immutable-model-revision", "dims": 2, "normalization": "l2", "distance_metric": "cosine_distance" }, "document_vectors_verified": true, "document_manifest_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } ``` Numeric ANN accepts one vector per leg; multivector bags are outside this verified-space CLI contract. The CLI checks identity equality, complete fields, finite vector components, and dimension agreement before querying. This is an assertion from a verified private corpus receipt; the CLI cannot authenticate the provenance of stored vectors or the manifest. Pin the manifest and verify its contents separately. Do not fill it with guessed identities. `nearest_to_id` reuses the stored vectors without creating query embeddings. Compatibility verification requires no row writes or bulk reembedding. ## Pagination and exhaustive selection Pass a returned `data.next_cursor` (or the corresponding response header) as `cursor` in the next query body. The CLI fetches one page and does not claim that a top-k result exhausts a namespace. Some stores use ordered queries plus an advancing ID filter instead of cursors; unsupported cursor modes fail. For exhaustive selection, create a job with the required selector, for example: ```json {"mode":"ids","source":"origin","exhaustive":true,"filters":["year","Gte",2020]} ``` Poll `scan get` until the gateway reports completion, then page results by `offset` and `limit`. Check failures, totals, and `truncated` where returned; never treat an unfinished, truncated, capped or failed scan as exhaustive. Fetch selected IDs with `read` as needed. Job creation and result pagination are separate operations so cancellation does not lose the continuation ID. ## Pinning an executable Build from a committed source revision in the private monorepo: ```sh cd apps/layer-cli revision=$(git rev-parse HEAD) go build -trimpath -ldflags "-X main.version=cli-retrieval-v1.$revision" -o /tmp/layer-retrieval . /tmp/layer-retrieval version shasum -a 256 /tmp/layer-retrieval ``` Record source revision, executable hash, Go version, contract version, namespace/store capability receipt, corpus manifest, embedding identities, and actual query legs in the private integration. Pass gateway credentials through the existing environment configuration, outside agent tool payloads. Gold relevance labels belong outside retrieval tools and receipts. The namespace `/v1/.../query` compatibility alias is covered by the v2 query command. Agent-specific retrieval at `/v2/agents/NAME/query` is outside these row-retrieval commands; its embedding-profile and tool-loop contract remains in the agent SDK. The CLI does not expose telemetry metric queries as document retrieval. # Layer CLI Source: https://hevlayer.com/docs/ce/cli import Edition from "../../components/docs/Edition.astro"; For queries, document reads, capability receipts, and exhaustive scans, see [CLI retrieval](/docs/ce/cli-retrieval). The CLI connects to the standalone gateway. Set its base URL to your local service and leave the key blank for the local Postgres selection. When the gateway fronts Turbopuffer, the key is the gateway bearer token — your Turbopuffer API key. ## Install From the repository root: ```sh go build -o layer ./apps/layer-cli ``` ## Configuration `layer` reads named environments from `~/.hevlayer/config.toml`. The directory is created with mode `0700`; the config file is written with mode `0600`. ```toml active = "local" [envs.local] base_url = "http://localhost:8080" api_key = "" ``` Resolution order is: | Priority | Source | | --- | --- | | 1 | Explicit flags such as `--base-url` and `--api-key` | | 2 | `LAYER_BASE_URL`, `LAYER_API_KEY`, and the `HEVLAYER_` twins | | 3 | Environment selected by `--env` or `LAYER_ENV` | | 4 | Active environment in `~/.hevlayer/config.toml` | | 5 | Built-in base URL default | A shell exporting `LAYER_BASE_URL` or `LAYER_API_KEY` keeps the env-var-only behavior and does not need a config file. `--env` and `LAYER_ENV` select an environment for one invocation without changing the active environment. | Flag | Environment | Default | | --- | --- | --- | | `--base-url` | `LAYER_BASE_URL`, `HEVLAYER_BASE_URL` | `https://aws-us-east-1.hevlayer.com` | | `--api-key` | `LAYER_API_KEY`, `HEVLAYER_API_KEY` | none | | `--env` | `LAYER_ENV` | active config env | | `-o`, `--output` | none | `table` | Output formats are `table`, `json`, and `names`. ## Environments ```sh layer env add local --base-url http://localhost:8080 --api-key "" layer env use local layer env ls layer env show local -o json ``` `env add` prompts for missing values on a TTY. On a non-TTY, the required values must be supplied by flags. API keys are masked in `env ls` and `env show`. ## Initialize a Namespace For a Turbopuffer namespace: ```sh layer init products --shards 8 layer init products --shards 8 --watch=false layer init products --shards 8 --poll-interval 5s ``` `init` calls `POST /v2/namespaces/{namespace}/init` through the selected gateway environment. The command creates or reattaches to the namespace shard marker, starts the shard backfill, and watches by default until `shard_lag_rows` reaches `0` and scatter/gather is active. Re-running with the same shard count is idempotent; requesting a different shard count returns a conflict message instead of changing the marker. ## TUI The environment and index views read the standalone gateway. Function, pipeline, and key views require the full runtime. Snapshot history columns also require the full runtime. ## VectorStores `layer vectorstore list` and `layer vectorstore get NAME` read standalone connection metadata. Configure the connection on the gateway through [configuration](https://github.com/hev/layer/blob/main/docs/setup.md). ## Inspect an index `layer index list` and `layer index get NAME` read namespace metadata. Snapshot-history columns require Pro. ## Delete an index `layer index delete NAME` requests namespace deletion. For normal local shutdown, use `docker compose down`. ## Ask the docs From the `site/` directory, use `pnpm run ask --edition ce search "query"`. For deployed docs, use `layer ask --endpoint https://hevlayer.com/api/ask/ce tree`. Full documentation is available at `/api/ask/pro`. # Concepts Source: https://hevlayer.com/docs/ce/concepts import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; import StoreMatrixLink from "../../components/docs/StoreMatrixLink.astro"; import ShardingCompare from "../../components/docs/ShardingCompare.astro"; ## Wire protocol matching Layer matches the Turbopuffer wire contract to make migration straightforward. Compatible request bodies remain usable when an application moves to the gateway; the Layer clients expose the full API beyond that compatible subset. With Turbopuffer as the store, Layer forwards native requests after gateway validation. With another store, Layer translates supported operations into that store's native calls, and a valid request the store cannot serve returns `422 UnsupportedByStore` instead of silently dropping part of it. The [capability matrix](/docs/ce/stores) lists what each store supports and [how we validate it](/docs/ce/stores#how-we-validate-it). See the [API reference](/docs/ce/api/introduction) for authentication and client setup. ## Gateway enhancements Layer also enhances its backends through gateway features, including [agentic search](/docs/pro/api/agents), while keeping one client endpoint. [Hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion) combines retrieval legs, [query routing](/docs/ce/api/query#query-routing) selects a strategy, [scans](/docs/ce/api/scans) select or count matching rows, and [federated queries](/docs/ce/api/federated-query) combine named namespaces. Support depends on the gateway routes, capability checks and backend operations that compose each feature. The API reference calls out current integration gaps at the relevant feature; missing integration does not define the product's scope. The [Layer clients](/docs/ce/api/introduction#install) expose these additions; plain HTTP can call the same API. Native requests and enhanced requests can share the gateway endpoint. Where Layer needs bookkeeping attributes, it reserves the `_hevlayer_*` prefix. Treat these fields as read-only; the [document model](/docs/ce/document-model) defines the contract. ## Gateway and store The gateway receives writes and queries over HTTP and executes them against the selected store. Local Compose supplies Postgres with pgvector and `pg_search`; an existing Turbopuffer account is another supported backend. See [configuration](https://github.com/hev/layer/blob/main/docs/setup.md) and [store support](/docs/ce/stores). ## Namespaces and rows A namespace groups rows addressed by ID. A row contains attributes and can include vectors. The first [write](/docs/ce/api/write) creates a namespace. ## Retrieval [Query routing](/docs/ce/api/query#query-routing) chooses a ranking strategy. [Scans](/docs/ce/api/scans) select rows or aggregate matching values, while [federation](/docs/ce/api/federated-query) merges results across explicit namespaces. Support depends on the backing store and request shape. ## Scatter/gather Layer stamps every row it writes to Turbopuffer with a `_hevlayer_shard` hash bucket. For an existing namespace, initialization backfills rows that were written without one. Scatter/gather starts after `layer.shard_lag_rows` reaches zero; the single-namespace path serves queries while backfill runs. See [CLI initialization](/docs/ce/cli#initialize-a-namespace). ## Glossary | Concept | Meaning | | --- | --- | | Wire protocol | The HTTP methods, paths, request fields, response shapes, and status codes exchanged by client and server. | | Wire feature | An individual operation or option whose backend support is declared in the capability matrix. | | Gateway | The Layer service that receives client requests, validates them, and executes them against the configured stores. | | [VectorStore](/docs/ce/api/vectorstores) | A serving connection to the backend that stores and queries rows. | | [Warehouse](/docs/pro/api/warehouses) | An upstream source connection, separate from the store serving retrieval requests. | | Namespace | A named collection of rows addressed through `/v2/namespaces/{namespace}`. | | Document / row | An ID and application attributes, optionally including vectors. | | [Scan](/docs/ce/api/scans) | Row selection that returns matching IDs, field values, or a count; supported selectors depend on the backend. | | Shard | A hash bucket within a namespace, identified by the reserved `_hevlayer_shard` attribute. | | Scatter/gather | Running subqueries across shards or namespaces and combining their results into one response. | | Leg | One subquery contributing to a hybrid or federated result. | | RRF | Reciprocal rank fusion: combining ranked lists using each result's position in its input lists. | | Tokenizer policy | The rules that turn input text into retrieval tokens, including word boundaries, case normalization, and token limits. | | Route | A retrieval strategy, such as `hybrid_text`, `semantic`, or `fused`, selected by the query router where supported. | | Routing policy | The deterministic, versioned rules used to select an `Auto` route. | | Deferral | An `Auto` response with `executed: false`: the application must supply an embedding before the selected route can execute. | # Standalone configuration Source: https://hevlayer.com/docs/ce/configuration import FeatureGate from "../../components/docs/FeatureGate.astro"; The [Community Edition repository](https://github.com/hev/layer/blob/main/docs/setup.md) contains installation, CI, and standalone configuration instructions. Start with the [quickstart](/docs/ce/quickstart) to write and query your first rows. # Demos Source: https://hevlayer.com/docs/ce/demos Every demo below is a live app built on Layer that reimplements nothing. Each composes shipped gateway features — [routing](/docs/ce/api/query#query-routing), [hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion), fuzzy matching, [local embedding serving](/docs/ce/api/embed), [pipelines](/docs/pro/api/pipelines), [snapshots](/docs/pro/api/snapshots), and the [function runtime](/docs/pro/functions) — over a different corpus, and makes the gateway's behavior legible in the UI. They are also the fastest way to see what the gateway does without standing up a cluster. The composition the demos share (embed, text legs, ANN leg, fuse, reorder the top) is also one gateway call: [`/search`](/docs/ce/api/search). | Demo | What it shows | Corpus | | --- | --- | --- | | [shelf](https://shelf.hevlayer.com) | The query router, made legible | Books | | [chart](https://chart.hevlayer.com) | Query routing on clinical search, with a number | PMC-Patients case reports | | [hybrid-text](https://hybrid-text.hevlayer.com) | Hybrid text fusion, proven with qrels | BEIR/SciFact abstracts | | [wiki](https://wiki.hevlayer.com) | Auto routing plus CPU-only Lattice embedding, at scale | Simple English Wikipedia | | [lens](https://lens.hevlayer.com) | Text-to-image search with zero GPUs | Wikimedia Commons Quality images | These deployed applications demonstrate retrieval behavior. Running their full workloads can require external stores, model artifacts, and data preparation; they are separate from the local Compose quickstart. ## shelf — book search that shows its routing **Live:** [shelf.hevlayer.com](https://shelf.hevlayer.com) · **Source:** [github.com/hev/shelf](https://github.com/hev/shelf) One search box, three routes. Type an author, a title, or a vibe; the gateway's `Auto` rank expression picks keyword (`hybrid_text`), `semantic`, or a `fused` blend from the shape of the query, and shelf renders that decision as a badge with the reason. The routing policy keys on token count, so the canned chips visibly change route as the query gets longer. This is the text-native routing showcase: it makes the [query router](/docs/ce/api/query#query-routing) decision the hero, not a footnote. Built on the [query router](/docs/ce/api/query#query-routing) (`Auto`), [hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion), and fuzzy matching. ## chart — clinical patient-notes search that shows its routing **Live:** [chart.hevlayer.com](https://chart.hevlayer.com) The same routing hero on the corpus with the sharpest bimodal query distribution there is: clinicians search both by exact token (`metformin 500mg`, `CABG`, `aspirn`) and by clinical picture (`elderly woman with progressive dyspnea and bilateral lower-extremity edema`). chart is the first Layer demo with real relevance judgments — PMC-Patients ReCDS qrels — so the routing and hybrid claims are measured, not asserted. Behind the search box, an open-weight Gemma cascade (vLLM, scale-to-zero on the GPU pool) reads each note once and extracts clinical events and facet labels: the [function runtime](/docs/pro/functions) showcase. The corpus is published, de-identified case reports (PMC-Patients, CC-BY-NC-SA). It is a search demo — not raw EHR, and not clinical advice. Built on the [query router](/docs/ce/api/query#query-routing), [hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion) with fuzzy matching, [pipelines](/docs/pro/api/pipelines), the [function runtime](/docs/pro/functions), and [snapshots](/docs/pro/api/snapshots). ## hybrid-text — hybrid text fusion over SciFact **Live:** [hybrid-text.hevlayer.com](https://hybrid-text.hevlayer.com) · **Source:** [github.com/hev/hybrid-text-fusion-demo](https://github.com/hev/hybrid-text-fusion-demo) The eval-shaped sibling of the routing demos, over ~5,000 scientific abstracts from BEIR/SciFact. One query string fans out into a full-input BM25 leg plus one fuzzy leg per token, fused by reciprocal rank fusion — so results survive typos and morphological variants without losing BM25's signal. It is purely lexical: no embeddings, no GPU, no vector index. SciFact ships qrels, so the UI flags known-relevant abstracts and the demo scores nDCG@10 / recall@10; every search also shows its gateway round-trip time and a fusion inspector (tokens, legs, RRF constant). Built on [hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion) and fuzzy matching. ## wiki — all of Simple English Wikipedia, routed and embedded on CPU **Live:** [wiki.hevlayer.com](https://wiki.hevlayer.com) · **Source:** [github.com/hev/wiki](https://github.com/hev/wiki) The routing hero at corpus scale: one `Auto` query over all 283,997 Simple English Wikipedia articles (1.74M paragraph rows) routes each search to full-text, semantic, or a fused RRF blend, and the UI renders the gateway's `routing` echo beside every result. The semantic leg is the [Lattice](/docs/ce/api/lattice) showcase — the whole corpus embedded through `prefer: lattice`, an ~8 MB int4 lookup-table artifact served in-process on the gateway CPU, with `performance.embedding_ms` and `embedding_tokens` echoed whenever the chosen route embeds. No GPU anywhere in the write or query path. Built on the [query router](/docs/ce/api/query#query-routing), [hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion), and [local embedding serving](/docs/ce/api/lattice) (`prefer: lattice`). ## lens — text-to-image search with zero GPUs **Live:** [lens.hevlayer.com](https://lens.hevlayer.com) · **Source:** [github.com/hev/lens](https://github.com/hev/lens) Cross-modal search over Wikimedia Commons [Quality images](https://commons.wikimedia.org/wiki/Commons:Quality_images): type `sunset over water`, get sunsets. The schema is two lines — a string `image_url` attribute with a [local CLIP](/docs/ce/api/embed#images) embedding profile. The gateway fetches each image and runs CLIP's image tower in-process on CPU at write time, then embeds query text with the same checkpoint's text tower at query time. The app posts writes and queries and renders the echo; it contains no embedding, tokenizer, or image-preprocessing code, and there is no GPU worker or autoscaler pool anywhere in the path. Every result pairs the fixed serving contract (`prefer: local`, gateway CPU) with the live `performance.embedding_ms` echo, and carries its Commons attribution and license. Built on [local CLIP serving](/docs/ce/api/embed#images) (`serving.prefer: local`, `modality: image`) and [full-runtime schema configuration](/docs/pro/indexes#schema-attribute-embedding). # Document model Source: https://hevlayer.com/docs/ce/document-model import Edition from "../../components/docs/Edition.astro"; import StoreNote from "../../components/docs/StoreNote.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; A row has an `id`, application attributes, and optionally a vector. Supply these fields in [write requests](/docs/ce/api/write). Use the [store matrix](/docs/ce/stores) for supported attribute and write shapes. ## Reserved attributes The `_hevlayer_*` prefix belongs to the gateway. Do not set these attributes in client writes. Select your backend to see which attributes Layer manages. | Attribute | Type | Purpose | | --- | --- | --- | | `_hevlayer_upserted_at` | integer (epoch ms) | Server-stamped on row-producing writes through Layer. Records when the gateway received the write. | | `_hevlayer_shard` | integer | Hash bucket stamped on every row Layer writes (`xxh64(id) % shard_count`). The count is the namespace's shard marker, or `LAYER_SHARD_COUNT` (default 16) before one exists. Not related to Turbopuffer's own [sharding](/docs/ce/api/upstream-features#sharding). Lets the gateway [scatter/gather](/docs/ce/concepts#scattergather) a query across the shards of one namespace. | Initialize an existing namespace with [`layer init --shards N`](/docs/ce/cli#initialize-a-namespace) to backfill rows written before Layer. Scatter/gather starts after `layer.shard_lag_rows` reaches zero. Layer keeps the same no-SQL document model over Postgres. Your schema and document attributes are mapped onto internal Postgres rows and indexes so the database can filter and rank them efficiently. Those rows are an implementation detail: there is no database table or SQL interface for an application to use. All reads and writes go through the Layer API, including the document fields, filter expressions, and vector queries. You do not need to know how the underlying rows are laid out, and you cannot interact with them directly. SQL transactions and indexes provide write visibility and query execution behind that API, so Layer does not need `_hevlayer_upserted_at` visibility stamps or `_hevlayer_shard` hash buckets for this backend. Row and column upserts insert a new row or replace the row with the same `id`. If your application needs a per-row modification time, supply an attribute such as `updated_at`; transaction visibility does not add a timestamp to the row. # Failure Modes Source: https://hevlayer.com/docs/ce/failure-modes import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; ## Read A gateway connection failure returns an error to the client. SDKs do not retry directly against the backing store. Check `/health`, gateway logs, and connectivity to the configured database or upstream service. A backing-store failure fails the request. Unsupported request shapes return `422 UnsupportedByStore`. Use the [store matrix](/docs/ce/stores) to choose a supported operation. ## Write Success requires the backing-store write to succeed. An invalid nonblank Turbopuffer key fails against Turbopuffer; it does not select local Postgres. Use an explicitly blank key for the local [quickstart](/docs/ce/quickstart). ## Client failures Check the base URL, authentication settings, and error body. Retrying a timed-out write may repeat a request that reached the store; use stable row IDs and the store's write semantics when designing retries. See [health and metrics](/docs/ce/observability) for gateway diagnostics. # FAQ Source: https://hevlayer.com/docs/ce/faq import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; ## Do I need an account? No. The [CE quickstart](/docs/ce/quickstart) runs the gateway and Postgres locally without signup, an API key, or a license key. ## Where are my rows stored? In the local Postgres Docker volume, or in the existing store you configure. `docker compose down` stops the local services and keeps the volume. ## Can I use Turbopuffer? Yes. Configure an existing account and use its key for inbound authentication. See [configuration](https://github.com/hev/layer/blob/main/docs/setup.md) and [store support](/docs/ce/stores). ## What is the source license? The gateway uses BSL 1.1. See [licensing](/docs/ce/licensing) for the source license and trademark links. ## Who built Layer? [Adam Hevenor](https://hevmind.com/about). Layer is a hev mind product. # Guarantees Source: https://hevlayer.com/docs/ce/guarantees import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import StoreMatrixLink from "../../components/docs/StoreMatrixLink.astro"; ## Commitments - Successful writes require success from the selected backing store. - Unsupported store operations fail explicitly; the gateway does not silently replace them with a narrower request. - Client-visible behavior is documented per store and API. - Gateway or backing-store failures remain visible to callers. See [failure modes](/docs/ce/failure-modes). You operate the deployment and its backups. The local Compose volume holds rows; the bundle does not configure an external backup service. # Introduction Source: https://hevlayer.com/docs/ce import Edition from "../../components/docs/Edition.astro"; import Diagram from "../../components/docs/Diagram.astro"; import { layerMapDiagram, layerMapDiagramNarrow, ceGatewayDiagram, ceGatewayDiagramNarrow, ceGatewayDiagramLabel } from "../../lib/diagrams"; import StoreNote from "../../components/docs/StoreNote.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Layer Community Edition is the same standalone retrieval gateway included in Pro, ready to run on your laptop or in your pipeline. It serves the Layer API, including a Turbopuffer-compatible subset, against Postgres with pgvector and `pg_search` for a range of operations—see the [store capability matrix](/docs/ce/stores) for coverage. You can also run it in front of Turbopuffer to add [query routing and hybrid fusion](/docs/ce/api/query), [scans and facets](/docs/ce/api/scans), and [federated queries](/docs/ce/api/federated-query). {ceGatewayDiagram} ## Start locally The [quickstart](/docs/ce/quickstart) runs the gateway with Docker Compose and returns your first search result. Started without a key, Compose supplies a local Postgres database and rows persist in its volume; started with a Turbopuffer key, data stays in Turbopuffer. Either way the gateway is the API entry point. ## Connect and query Use the [Python, Go, or TypeScript clients](/docs/ce/api/introduction), or curl. [Write](/docs/ce/api/write) rows and vectors, [query](/docs/ce/api/query) with vector or text ranking, [scan](/docs/ce/api/scans) matching rows, and [federate](/docs/ce/api/federated-query) across named namespaces. [Configuration](https://github.com/hev/layer/blob/main/docs/setup.md) describes standalone connections and authentication. The [store matrix](/docs/ce/stores) records each backend's supported operations. CE can also front an existing Turbopuffer account. ## Observe Use [health and metrics](/docs/ce/observability) to check the gateway and [failure modes](/docs/ce/failure-modes) to diagnose request errors. # Licensing Source: https://hevlayer.com/docs/ce/licensing import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Community Edition is source-available under the Business Source License 1.1. Read the [LICENSE](https://github.com/hev/layer/blob/main/LICENSE) for its permissions, restrictions, and conversion date. Trademark rights are covered separately by [TRADEMARKS.md](https://github.com/hev/layer/blob/main/TRADEMARKS.md). The local [Compose quickstart](/docs/ce/quickstart) needs no signup or runtime license key. # Limits Source: https://hevlayer.com/docs/ce/limits import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Limits depend on the selected store and operation. The [store matrix](/docs/ce/stores) documents unsupported shapes. An unsupported request returns an error rather than a partial translation. - Batch queries accept up to 16 ranked legs per request. - Live values scans retain up to 1,000,000 distinct values and report `truncated: true` when the result exceeds the cap. - Row sizes, attribute shapes, vector dimensions, and indexing throughput follow the selected backend's limits. Scan and federation concurrency also consume gateway memory and upstream capacity. Review [scans](/docs/ce/api/scans) and [federated query](/docs/ce/api/federated-query) before increasing fan-out. # Health and metrics Source: https://hevlayer.com/docs/ce/observability ## Health ```sh curl --fail http://localhost:8080/health ``` The health route requires no bearer token. Use Compose health checks and gateway logs to diagnose startup and database connectivity. ## Metrics `GET /metrics` exposes Prometheus-compatible gateway metrics without auth. The metrics catalog and proxy routes under `/v2/metrics` provide names and query access; proxy queries require a configured metrics service. The local Compose bundle does not include that service. ```sh curl --fail http://localhost:8080/metrics ``` ## Telemetry The standalone gateway sends anonymous telemetry to `https://telemetry.hevlayer.com`. It is fire-and-forget, off the request path, and never blocks startup. The local Compose bundle disables it by default. Disable it with either variable: ```sh LAYER_TELEMETRY=off DO_NOT_TRACK=1 ``` On Kubernetes, the Helm chart sets `LAYER_TELEMETRY=off` for you when `gateway.telemetry.enabled=false`. The gateway sends two events: - `gateway_started`, once at startup: gateway version, an anonymous instance ID, and the backend kinds it serves (`pgvector`, `turbopuffer` or `search`). - `gateway_heartbeat`, the same fields plus aggregate counts. Feature counters (automatic routing, hybrid/RRF, fuzzy surfacing, facets, scans, federated query, multi-store routing) are cumulative since startup. `usage` counts successful queries, writes, and rows upserted, patched and deleted since the previous heartbeat. The gateway checks hourly and sends a heartbeat when there is usage to report, or once a day when idle. A gateway running a valid license also sends the license's issuance ID (`licenseId`) and tier (`licenseTier`), which lets us see that a license we issued is running. It never sends the license key or the email it was issued to. A tool that starts the gateway can name itself with `LAYER_TELEMETRY_SOURCE` (lowercase letters, digits and `-`, up to 32 characters, for example `kit`) and `LAYER_TELEMETRY_SOURCE_VERSION` (up to 32 characters of letters, digits, `.`, `+` and `-`). Both are sent as `distribution` and `distributionVersion`; a value outside that shape is not sent. Telemetry never includes query text, vectors, namespace names, document IDs, bearer tokens, API keys, or document contents. See the generated [telemetry reference](https://github.com/hev/layer/blob/main/docs/telemetry.md). # Quickstart Source: https://hevlayer.com/docs/ce/quickstart import StoreNote from "../../components/docs/StoreNote.astro"; import Edition from "../../components/docs/Edition.astro"; import CodeTabs from "../../components/docs/CodeTabs.astro"; Run Layer on your laptop with a local Postgres database or your existing Turbopuffer account. You'll need Docker with Compose, Git, and `curl`. The local database needs no account, API key, or license key. ## 1. Clone and start Choose your backend. **Postgres** keeps your data on your machine. For **Turbopuffer**, replace `tpuf_...` with your API key before you run the tab. ```sh git clone https://github.com/hev/layer.git cd layer docker compose up -d --wait curl --fail http://localhost:8080/health ``` ```sh git clone https://github.com/hev/layer.git cd layer export TURBOPUFFER_API_KEY="tpuf_..." docker compose up -d --wait curl --fail http://localhost:8080/health ``` The gateway listens on `localhost:8080`. Compose includes a Postgres database with pgvector and `pg_search`, which the gateway uses unless `TURBOPUFFER_API_KEY` is set in your shell. Setting the key selects Turbopuffer instead. The Compose file runs the latest release; set `GATEWAY_IMAGE=hevlayer/layer-gateway:edge` only to opt into the development build. Allow a few minutes for the first image downloads. Compose also starts `embed`, the bundled CPU embedding service (`hevlayer/layer-embed`), and hands the gateway its address as `LAYER_EMBED_URL`. Its image carries the two [CPU text models](/docs/ce/api/embed#cpu-models), so it needs no key, GPU or download after the pull. It listens only on an internal Compose network with no route out of your machine and no host port. Override its image with `EMBED_IMAGE` the same way as `GATEWAY_IMAGE`. For CI setup and standalone configuration, see the [repository setup guide](https://github.com/hev/layer/blob/main/docs/setup.md). ## 2. Write rows The first write creates the namespace. Run this in the same shell as step 1. ```sh curl --fail-with-body http://localhost:8080/v2/namespaces/products \ -H 'Content-Type: application/json' \ -d '{ "distance_metric": "cosine_distance", "schema": {"title": {"type": "string", "full_text_search": true}}, "upsert_rows": [ {"id": "earbuds", "title": "wireless earbuds", "vector": [1, 0, 0]}, {"id": "speaker", "title": "portable speaker", "vector": [0, 1, 0]} ] }' ``` ```sh curl --fail-with-body http://localhost:8080/v2/namespaces/products \ -H "Authorization: Bearer $TURBOPUFFER_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "distance_metric": "cosine_distance", "schema": {"title": {"type": "string", "full_text_search": true}}, "upsert_rows": [ {"id": "earbuds", "title": "wireless earbuds", "vector": [1, 0, 0]}, {"id": "speaker", "title": "portable speaker", "vector": [0, 1, 0]} ] }' ``` ## 3. Query ```sh curl --fail-with-body http://localhost:8080/v2/namespaces/products/query \ -H 'Content-Type: application/json' \ -d '{"rank_by": ["vector", "ANN", [1, 0, 0]], "top_k": 1, "include_attributes": true}' ``` ```sh curl --fail-with-body http://localhost:8080/v2/namespaces/products/query \ -H "Authorization: Bearer $TURBOPUFFER_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rank_by": ["vector", "ANN", [1, 0, 0]], "top_k": 1, "include_attributes": true}' ``` The first row has `id: "earbuds"` and `$dist: 0`. Continue with the [API docs](/docs/ce/api/introduction) for client SDKs and query options, or explore the [demos](/demos) to see what you can build. # Changelog Source: https://hevlayer.com/docs/ce/roadmap import FeatureGate from "../../components/docs/FeatureGate.astro"; The [published release history](https://github.com/hev/layer/releases) records released versions and their artifacts. Production documentation follows the released tag; development previews follow their version branch. Surfaces marked **in preview** are deliberately absent from this page. They are documented and usable, but they sit outside the release line and carry no compatibility promise, so they are not part of a version's contract. ## Edge `edge` is the opt-in development build. It tracks the `v0.7` branch and is republished as the branch moves; pin a released tag for anything you depend on. Landing in 0.7, on the `edge` image today: - 🐘 Postgres `VectorStore` backend (`kind: pgvector`) — the Community Edition default store, documented in the [store matrix](/docs/ce/stores); a keyless `docker compose up` is the whole stack - 🔢 [Ordered scans](/docs/ce/api/query#rank-expressions) on Postgres — `rank_by` on an attribute or `id`, filter-only queries in id order, and byte-order string range filters - ✅ [Conditional writes](/docs/ce/api/write) on Postgres — `upsert_condition` and `delete_condition`, including `$ref_new` - 📚 Several `full_text_search` attributes per namespace on Postgres — one `pg_search` BM25 index over all of them, ranked one attribute at a time; see the [schema limits](/docs/ce/stores#schema-limits) - 🧠 [CPU embedding service](/docs/ce/api/embed#cpu-models) — `layer-embed` beside the gateway, reached through `LAYER_EMBED_URL`; `prefer: local` needs no key, GPU, or download - 🏷️ Typed `feature` on `422 UnsupportedByStore` — match on the identifier, not the message ### Running edge ```sh git clone --branch v0.7 https://github.com/hev/layer.git cd layer export GATEWAY_IMAGE=hevlayer/layer-gateway:edge export TURBOPUFFER_API_KEY="" docker compose up -d --wait curl --fail http://localhost:8080/health ``` The [quickstart](/docs/ce/quickstart) covers the rest, including using your own Turbopuffer key instead of the bundled local database. ## 0.6 ### Community Edition - 📦 One-bundle Community Edition — a single Compose file and a public image, run with a Turbopuffer key (a keyless Postgres start shipped as a preview) - 🧑‍💻 [`layer` CLI](/docs/ce/cli) on Community Edition — keyless `index get`, `env add`, and inspection ### Gateway and pipelines - 🚦 Higher fan-out for pinned namespaces — 4× the default concurrency - 📄 REST source pagination — `searchAfter` over enumerated snapshots - 🦾 arm64 REST worker - 🗑️ Namespace deletion without S3 ### Fixes - 🔒 Scoped keys confined to their namespaces - 🧮 Document-cache scans agree with counts - 🪣 Standalone gateway runs without an object store ## 0.5 ### Embedding - 🧮 [Gateway-side query embedding](/docs/ce/api/embed) — `embed:` attributes resolved on the wire - 🎛️ [Serving preference](/docs/ce/api/embed) — per-attribute choice of where embedding runs - 🤝 Native Turbopuffer embeddings wired through the gateway - 🪟 Lattice embedding provider — pinned artifact mounted by Helm - 🖼️ Local CLIP provider — multimodal without a GPU worker - 🧰 BYO models — the blended embedding policy removed - 💸 Native embed cost attribution ### Install and CLI - 🧙 `layer install aws` — guided Terraform + Helm install wizard - ♻️ Profile-aware [`layer install`](/docs/ce/cli) lifecycle — the install script deleted - 🗄️ Document cache Helm profiles - 🚀 [Quickstart](/docs/ce/quickstart) from a clone — the bundled Compose flow ### Licensing - 🎟️ [Team tier gates](/docs/ce/licensing) — custom InfraRules and multiple vector stores - 🔏 Authenticated `/v2/license` probe - ⏳ Trial licenses skip post-expiry grace ### Pipelines - 📚 Enumerated REST full snapshots - 🧬 Idempotent sweeps — content hashes keep Function-written attributes ### Stores and gateway - 🗃️ Kubernetes vector stores preferred in pro - 🧊 Turbopuffer base64 vectors accepted - 🎯 Namespace delete routed by vector store - 🔓 Segment lock convoys resolved - 📴 Standalone gateway runs with no object store ## 0.4 ### API hardening - 🧩 Finalize CRDs - 🚆 Wire-compatible pass-through reads and writes - 🏷️ Naming things ### Lifecycle and operability - 🎚️ [Autoscaling compute](/docs/pro/scaling) for pipelines and UDFs - 🗄️ [Document cache endpoint](/docs/ce/api/query#fetch) for multi-stage pipelines - 📸 [Index snapshot history](/docs/pro/api/snapshots) - 🧨 Coordinated delete - ⛵ [Helm and Terraform install](/docs/pro/install) scripts - 🔐 [Scoped API keys](/docs/pro/api/keys) — minted [`ApiKey` resources](/docs/pro/api/keys) - 🔑 License key validation - 🧾 Key audit logs — lifecycle events to S3 - 🏭 [Warehouse CRD](/docs/pro/api/warehouses) — declared Snowflake and Hugging Face sources - 🏗️ Production cluster cutover — lean topology live in prod ### Surfaces - 🪟 [Dashboard MVP](/docs/pro/dashboard) — CRD management and observability - 📚 Documentation site - 🧰 Official Python, Go, and TypeScript clients ### Search - 🎯 [Stable reads](/docs/ce/api/query#stable-reads) during heavy writes - 🕰️ Temporal queries — `as_of` / `between` selector on reads - 🚦 [Ready signal](/docs/pro/api/namespace-metadata) — namespace reports when every row is indexed - 📜 Precomputed facet listings in [snapshots](/docs/pro/api/snapshots) - 🪙 Precomputed facet counts in [snapshots](/docs/pro/api/snapshots) - 🪃 [Scans](/docs/ce/api/scans) — row selection by filter, `fts`, `hybrid_text`, or `ann` - 🆔 Search by id via document-cached vector - 🪢 [Hybrid text fusion](/docs/ce/api/query#hybrid-text-fusion) - 🧭 [Query routing](/docs/ce/api/query#query-routing) - 📰 [Search history](/docs/pro/api/search-history) saved to S3 - 🔥 Trending searches - 🗂️ [Enhanced namespace metadata](/docs/pro/api/namespace-metadata) ## Proposals Unreleased design proposals are tracked separately from published release notes. A proposal does not establish store or edition support; use the selected edition's API reference and the [store matrix](/docs/ce/stores). # Store capability matrix Source: https://hevlayer.com/docs/ce/stores import Edition from "../../components/docs/Edition.astro"; import StoreMatrix from "../../components/docs/StoreMatrix.astro"; import DocsExamplesObserved from "../../components/docs/DocsExamplesObserved.astro"; Layer serves one API across its configured stores. Each cell below comes from the gateway's backend capability declaration. [Wire protocol matching](/docs/ce/concepts#wire-protocol-matching) explains the contract, and [how we validate it](#how-we-validate-it) is below the matrix. Every page offers a picker when more than one backend is documented for the edition, so the selection stays with you as you navigate. ## Postgres configuration Postgres uses `VectorStore` `spec.kind: pgvector` with a PostgreSQL connection URI in `spec.endpoint.url`. The [standalone configuration](https://github.com/hev/layer/blob/main/docs/setup.md#postgres-pgvector) covers standalone configuration, database authentication and extension versions, namespace-to-table mapping, and schema-driven HNSW, BM25, and scalar indexes. The [quickstart](/docs/ce/quickstart) runs the bundled database and gateway locally. Read the Postgres column below for supported wire features and gaps. The [write](/docs/ce/api/write) and [query and fetch](/docs/ce/api/query) references describe Postgres limitations alongside the affected features. Text ranking follows the shared [BM25-class scoring caveat](#fts-ranking). A namespace may declare any number of `full_text_search` string attributes (see [schema limits](#schema-limits)); a `BM25` `rank_by` names one of them and scores that attribute only. Postgres keeps every full-text attribute on one `pg_search` BM25 index. Declaring a further `full_text_search` attribute on an existing namespace rebuilds that index inside the same write transaction, so the write takes time proportional to the namespace's row count and the new attribute is rankable as soon as the write returns. Removing `full_text_search` from a declared attribute returns `400`. ## Matrix ## Reading the cells **Supported** means the declared feature is available. **Approximate** means it is served with the limits described in the cell. **Unsupported** means the store cannot serve that feature and returns `422 UnsupportedByStore`. Capabilities describe individual features, not every combination of options. The [schema limits](#schema-limits) table beneath the matrix gives the numeric bounds behind the multiple-fields row. Two request shapes express hybrid retrieval, and each has its own row. A store accepts Supported routes and Approximate routes within their stated limits. Unsupported routes reject. | Route | Request | Row | |---|---|---| | `HybridText` | `rank_by: [field, "HybridText", input]` — the gateway runs one ranked query per leg and fuses with RRF | [HybridText rank operator](#feature-hybrid) | | Multi-query | a `queries` body — independent legs, fused by the store when `rerank_by` is set | [Multi-query](#feature-multi_query) | On Postgres, `HybridText` is approximate — served only with the shape its cell states (`fuzziness: 0`, no cursor or temporal filter) — and a `queries` or `rerank_by` body returns `422 UnsupportedByStore` naming `multi_query`. ## How we validate it Matching the wire does not promise identical index internals, latency, scores, or ranking across stores. In particular, [full-text ranking](#fts-ranking) is backend-specific. Layer's additional request fields, routes, and response metadata are documented as gateway enhancements. - **API and client contracts.** The SDK harness compares the gateway OpenAPI operations and generated Python client with the upstream API, and checks captured HTTP requests against documented examples using a mock server. These checks catch route, field, and serialization drift; they do not prove that a real backend returns the right results. - **Backend acceptance.** Store-specific suites send requests through a real gateway and backend using generated clients. They check supported operations and explicit rejection of unsupported requests. The Postgres suite runs against the Compose database. - **Documented examples.** A committed selection of upstream examples runs against a real gateway and store. Each request is classified as **ok**, **unsupported**, **fail**, or **blocked** by a prerequisite. A baseline change fails the check for review; matching a baseline can still preserve known failures. This is a selected test corpus, not proof that every upstream request or combination works. The [observed results](#observed-against-the-turbopuffercom-docs-examples) are at the end of this page. The matrix is generated from backend declarations and checked for source drift. It states the contract; acceptance results are evidence of behavior. Both are needed to assess compatibility. ## The fail-fast contract Unsupported requests fail with `422 UnsupportedByStore`. The gateway does not silently drop unsupported predicates or substitute a different backend. The body names the store, the route, and the rejected feature: ```json { "error": "UnsupportedByStore", "store": "pgvector", "route": "/v2/namespaces/traces", "feature": "patch_rows", "message": "UnsupportedByStore: pgvector: patch_rows" } ``` `feature` is a stable identifier. Match on it; do not parse `message`. - A wire-feature id from the [matrix](#matrix) when one owns the request: `multi_query` for a `queries` or `rerank_by` body, `patch_rows`, `search_after` for `cursor` or `searchAfter`, `conditional_writes` for a `patch_condition` on Postgres. The same string appears in the matrix and the 422. - Otherwise the rejected wire key, dotted when nested: a second embedded attribute on Postgres is `schema.embed` and a chunked one is `embed.chunk`; a filter operator is its name, such as `Contains`. - Schema-count limits use the limit name, such as `max_vector_fields` for a second vector attribute on Postgres. `message` stays human-readable and starts with `UnsupportedByStore: {store}: {feature}`; any detail follows after a colon. `feature` is additive, so existing clients that read `error` and `message` are unaffected, and clients ignore values they do not know. A rejection that names no single feature carries no `feature` field. ```python from hevlayer import AsyncHevlayer, HevlayerError try: await client.write_namespace("traces", body) except HevlayerError as e: if e.error == "UnsupportedByStore" and e.feature == "multi_query": ... # choose the HybridText route instead ``` ## Observed against the turbopuffer.com docs examples The matrix above is what each backend declares. The table below is what happened when the committed selection of documented requests from [turbopuffer.com/docs](https://turbopuffer.com/docs) was run against a store through the gateway. **Ok** means the configured response checks passed (including the explicitly case-insensitive delete-status check). **Unsupported** means the store returned `422 UnsupportedByStore` naming the feature. **Fail** means some other status, or a result that contradicts the docs; those are open findings, not declared gaps. **Blocked** means a prerequisite did not pass. The "Matrix row" column links each example to the declared cell it exercises. Regenerate with `python3 tools/tpuf-docs-e2e/run.py --docs-json site/src/generated/tpuf-docs-e2e.json` after a store change. # Tradeoffs Source: https://hevlayer.com/docs/ce/tradeoffs import Edition from "../../components/docs/Edition.astro"; import StoreNote from "../../components/docs/StoreNote.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; The gateway adds a network hop and performs validation, routing, and result merging for Layer-specific requests. Wider scans and federated queries can increase upstream work and gateway memory use. The local Compose database uses a persistent Docker volume. Back up that database according to your durability needs. Removing its volume erases the local rows. Turbopuffer sharding and write timestamps add reserved indexed attributes. Store capabilities differ: [check the matrix](/docs/ce/stores) before relying on the same query shape across backends. # Blobs Source: https://hevlayer.com/docs/ce/api/blobs import StoreNote from "../../../components/docs/StoreNote.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import Edition from "../../../components/docs/Edition.astro"; Blobs store opaque bytes under their sha256 and serve them through the gateway. A row never stores the bytes themselves. It stores an ordinary string attribute such as `image_blob: "blob://products/"`. Use blobs for media, source files or other binary payloads that rows point at but that should not ride the document write path. ## Routes | Route | Method | Behavior | | --- | --- | --- | | `PUT /v1/namespaces/{ns}/blobs` | PUT | Store raw bytes by sha256 and return a `blob://` reference. | | `GET /v1/namespaces/{ns}/blobs/{sha256}` | GET | Serve the bytes by sha256. | | `POST /v1/namespaces/{ns}/blobs/move_to_s3` | POST | Move a store-held blob set to S3. See [Move a blob set to S3](#move-a-blob-set-to-s3). | ## Where the bytes live Blobs go to the S3-compatible object store set by `S3_BUCKET`, or to the namespace's store when it has a native binary type. The routes and the `blob://` reference are the same whichever backend holds the bytes. `LAYER_BLOB_STORAGE` on the gateway chooses between them for stores that could hold the bytes themselves: | `LAYER_BLOB_STORAGE` | Blobs a native store could hold | | --- | --- | | `auto` (default) | S3 on turbopuffer namespaces when `S3_BUCKET` is set; the store otherwise. | | `s3` | S3 whenever `S3_BUCKET` is set. | | `store` | The store, up to its per-value cap. | turbopuffer namespaces default to S3 because turbopuffer bills each blob read as a query, by the size of the whole blob namespace rather than the blob returned. Reading many blobs from a large blob namespace multiplies that cost. An S3 read is billed per request and by the bytes it returns. Blobs larger than the store's per-value cap, and every blob on a store without native bytes, go to S3 under every setting. | Store | Backend | Largest blob in the store | | --- | --- | --- | | turbopuffer | A `{ns}__hevlayer_blobs` namespace, one row per sha256 with a `bytes` attribute | 6 MiB | | Postgres (pgvector) | The `layer_pgvector.blobs` table, one `bytea` row per sha256 | 10 MiB (the gateway cap) | | Stores without native bytes | S3 | None; every blob goes to S3 | The gateway accepts blobs up to 10 MiB. turbopuffer caps a `bytes` value at 8 MiB of base64 on the wire, which is 6 MiB of blob. A blob between 6 MiB and 10 MiB on turbopuffer goes to S3. With no `S3_BUCKET`, a blob that does not fit in the store is rejected rather than dropped: | Case | Status | `error` | | --- | --- | --- | | Blob over 10 MiB | 413 | `payload_too_large` | | Blob over the store's cap, no S3 | 413 | `blob_exceeds_store_cap` | | Store without native bytes, no S3 | 422 | `object_store_not_configured` | A turbopuffer blob namespace appears in namespace listings next to the namespace it serves, and deleting a namespace does not delete its blobs. The per-store values are in the [store capability matrix](/docs/ce/stores#blobs). ## Move a blob set to S3 A namespace that switched to S3 still has the blobs it wrote to its turbopuffer blob namespace. Reads find them there until they move. A move copies the blob namespace to S3 in pages of up to 10,000 blobs, one turbopuffer query per page and never one per blob: ```bash curl -X POST "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/move_to_s3" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"max_pages": 1}' ``` ```json { "namespace": "products", "done": false, "cursor": "7d1a54127b222502f5b79b5fb0803061...", "pages": 1, "moved": 9987, "already_in_s3": 13, "mismatched": 0, "moved_bytes": 2147483648, "scanned_bytes": 2150215680, "page_size": 10000, "billable_bytes_queried": 10737418240, "billable_bytes_returned": 2863311530, "updated_at": "2026-10-13T18:04:11Z", "run": { "pages": 1, "moved": 9987, "already_in_s3": 13, "mismatched": 0, "moved_bytes": 2147483648, "scanned_bytes": 2150215680, "billable_bytes_queried": 10737418240, "billable_bytes_returned": 2863311530 } } ``` Each call moves up to `max_pages` pages (default 1) and returns. Call it again until `done` is `true`. The top-level counters are totals for the whole move; `run` is what this call did. `billable_bytes_queried` and `billable_bytes_returned` are what turbopuffer billed for the move's queries. | Field | Default | Meaning | | --- | --- | --- | | `page_size` | Sized by bytes | Blobs per turbopuffer query, 1 to 10,000. Unset, the first page is 100 blobs and later pages hold about 256 MiB of blobs at the mean size seen so far, up to 10,000. The gateway holds a whole page in memory. | | `max_pages` | `1` | Pages this call moves before returning. | | `restart` | `false` | Start again from the first blob, even after the move finished. | A move is safe to rerun or interrupt. Progress is saved in the object store after every page, so the next call starts after the last page saved. A blob already in S3 is not rewritten. A row whose bytes do not hash to its sha256 is counted in `mismatched` and not copied. The move is `done` when a page comes back empty, so the last call makes one query that finds nothing. Once `done` is `true`, further calls return the totals without querying turbopuffer. A finished move changes reads. Before it finishes, a blob missing from S3 is looked up in the turbopuffer blob namespace. After it finishes, S3 is the only backend, and a blob missing from S3 is a 404 with no turbopuffer query. The move leaves the turbopuffer blob namespace in place. Delete it yourself once reads are served from S3. A blob written to the store after a move finished (because the namespace switched back to `store`, or a gateway had not picked up the switch yet) reopens the move: S3 misses ask the store again, and the next call copies the new blob. Deleting a namespace, or branching onto its name, clears its move. The namespace's blob storage must already resolve to S3: the route returns 409 when it resolves to the store, and 422 `object_store_not_configured` without `S3_BUCKET`. ## Store ```python with open("image.jpg", "rb") as f: stored = await client.put_blob("products", f.read()) print(stored.ref) ``` ```go body, _ := os.ReadFile("image.jpg") stored, err := client.PutBlob(ctx, "products", body, nil) ``` ```typescript import fs from "node:fs/promises"; const bytes = await fs.readFile("image.jpg"); const stored = await client.putBlob("products", bytes); ``` ```bash curl -X PUT "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @image.jpg ``` Response: ```json { "ref": "blob://products/9f86d081884c7d659a2feaa0c55ad015...", "sha256": "9f86d081884c7d659a2feaa0c55ad015...", "size": 48213 } ``` The same bytes always return the same reference. The route rejects empty bodies and bodies over 10 MiB. Write the returned `ref` as a normal row attribute: ```json { "id": "B0123", "vector": [0.1, 0.2], "image_blob": "blob://products/9f86d081884c7d659a2feaa0c55ad015..." } ``` The removed document `blobs` payload shape is still rejected. Binary bytes do not traverse `/v2/namespaces/{ns}` writes. ## Fetch ```python image = await client.get_blob("products", stored.sha256) ``` ```go image, err := client.GetBlob(ctx, "products", stored.Sha256) ``` ```typescript const image = await client.getBlob("products", stored.sha256); ``` ```bash curl "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/$SHA256" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -o image.jpg ``` Successful responses include immutable cache headers: ```http Cache-Control: public, max-age=31536000, immutable ETag: "" ``` The gateway sniffs common image types (`jpeg`, `png`, `gif`, `webp`) for `Content-Type`; otherwise it returns `application/octet-stream`. ## Reads A namespace that keeps blobs in S3 reads S3 first, then its store for blobs not yet [moved](#move-a-blob-set-to-s3). A namespace that keeps blobs in its store reads the store first, then S3, so blobs written to S3 before the store held bytes still serve. # Data connections Source: https://hevlayer.com/docs/ce/api/data-supply ## VectorStores See [VectorStores](/docs/ce/api/vectorstores) to list and inspect serving connections. ## Warehouses See [Warehouses](/docs/pro/api/warehouses) to list and inspect upstream source connections. # Embed Source: https://hevlayer.com/docs/ce/api/embed import Edition from "../../../components/docs/Edition.astro"; import Callout from "../../../components/docs/Callout.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; The `embed` schema block and the `Embed` rank expression use Turbopuffer's request shapes. Availability depends on the selected store's capabilities. The Layer API also includes gateway-managed operations; support depends on the selected backend. Add an `embed` block to a string attribute in your schema and Layer computes that attribute's vector on every write. Put `["Embed", "..."]` in `rank_by` and Layer computes the query vector with the same model. Your application never loads a model or sends a vector of its own. ## Example This write creates the `articles` namespace with an embedded `text` attribute and upserts two rows. Layer embeds each row's `text` with MiniLM, one of the [CPU models](#cpu-models), before storing it. ```python await client.write_namespace("articles", { "upsert_rows": [ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."}, ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "sentence-transformers/all-MiniLM-L6-v2", "dims": 384, "serving": {"prefer": "local"}, }, }, }, }) ``` ```go _, err := client.WriteNamespace(ctx, "articles", hevlayer.TurbopufferWriteRequest{ "upsert_rows": []map[string]any{ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."}, }, "distance_metric": "cosine_distance", "schema": map[string]any{ "text": map[string]any{ "type": "string", "embed": map[string]any{ "model": "sentence-transformers/all-MiniLM-L6-v2", "dims": 384, "serving": map[string]any{"prefer": "local"}, }, }, }, }) ``` ```typescript await client.writeNamespace("articles", { upsert_rows: [ { id: "planet-1", title: "Planet", text: "Jupiter is the biggest planet in the Solar System." }, { id: "photo-1", title: "Photosynthesis", text: "Plants turn sunlight, water, and carbon dioxide into food." }, ], distance_metric: "cosine_distance", schema: { text: { type: "string", embed: { model: "sentence-transformers/all-MiniLM-L6-v2", dims: 384, serving: { prefer: "local" }, }, }, }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."} ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "sentence-transformers/all-MiniLM-L6-v2", "dims": 384, "serving": { "prefer": "local" } } } } }' ``` Query by meaning with `Embed`. Layer reads the model from the `text` schema and embeds the query string with it: ```python response = await client.query_namespace("articles", { "rank_by": ["text", "ANN", ["Embed", "largest planet in the solar system"]], "top_k": 3, "include_attributes": ["title", "text"], }) print(response.rows, response.performance) ``` ```go response, err := client.QueryNamespace(ctx, "articles", &hevlayer.QueryRequest{ RankBy: []any{"text", "ANN", []any{"Embed", "largest planet in the solar system"}}, TopK: 3, IncludeAttributes: []string{"title", "text"}, }) ``` ```typescript const response = await client.queryNamespace("articles", { rank_by: ["text", "ANN", ["Embed", "largest planet in the solar system"]], top_k: 3, include_attributes: ["title", "text"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "ANN", ["Embed", "largest planet in the solar system"]], "top_k": 3, "include_attributes": ["title", "text"] }' ``` ```jsonc { "rows": [ { "id": "planet-1", "$dist": 0.137, "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System." } ], "performance": { "embedding_tokens": 7, "embedding_ms": 9 } } ``` ## Where the vector comes from `embed.serving.prefer` picks the provider that runs the model: | `embed.serving.prefer` | Who computes the vector | | --- | --- | | `native` | The default. On Turbopuffer, Layer forwards the declaration and Turbopuffer embeds. On a store that cannot embed, Layer picks a provider once; see [per-store resolution](#per-store-resolution). | | `turbopuffer` | Turbopuffer's hosted embedding service, from its managed model menu. Layer calls it on any store and writes the vector itself. It supports `instructions`, `revision`, and `chunk`. | | `local` | The locally configured embedder: the `layer-embed` CPU service the gateway reaches at `LAYER_EMBED_URL`, or an in-process Lattice or CLIP artifact. No key, no GPU, no account. | | `worker` | A GPU container in your own cluster, serving an open-weights model you declared. | The provider is independent of the store the vector is written to, so any store that accepts `embed` can use any configured provider. Layer never substitutes one provider for another. A profile that selects a provider the deployment has not configured fails validation at write time, and a provider that fails at request time returns an error instead of falling back to a different model. `autoscaler` is accepted as an alias for `turbopuffer`, and `lattice` for `local` on a [Lattice](/docs/ce/api/lattice) profile. `native` is its own value, not an alias. All four values are available. Layer validates a `native` profile and forwards it unchanged to the upstream `POST /v2/namespaces/{ns}` and `POST /v2/namespaces/{ns}/query`, so Turbopuffer computes the vector and it never passes through Layer. `turbopuffer`, `local`, and `worker` compute the vector in Layer and write an ordinary ANN column. A `turbopuffer` profile gets its vector from the same hosted service a `native` one does, but through Layer, which is what lets it take the Layer extensions. ### Per-store resolution Omitting `serving` selects `native`. Only Turbopuffer can compute that vector itself. On a store that cannot embed, Layer computes the vector and the store receives an ordinary vector column. The rule depends on the store, never on the request: | Store | `native` (the default) | `turbopuffer`, `local`, `worker` | | --- | --- | --- | | Turbopuffer | Forwarded unchanged, and Turbopuffer embeds. | Layer embeds and writes an ANN column. | | hev search | Gateway-resolved through the configured Turbopuffer embedding provider. | Layer embeds. | | Postgres | Gateway-resolved: the `local` embedder if it lists the model, otherwise the configured Turbopuffer embedding provider. | Layer embeds. | Layer resolves a gateway-resolved profile once, on the write that declares the attribute, and the namespace keeps that provider for good. If neither provider serves the model, the write returns `503` and nothing is stored. Postgres takes the same `embed` declaration as Turbopuffer. On Community Edition Compose, which runs the CPU service and has no Turbopuffer key, a profile naming one of the [CPU models](#cpu-models) needs no `serving` block: ```json { "schema": { "text": {"type": "string", "full_text_search": true, "embed": {"model": "BAAI/bge-small-en-v1.5"}} }, "upsert_rows": [ {"id": "planet-1", "text": "Jupiter is the biggest planet in the Solar System."} ] } ``` Layer embeds each row before the write's transaction, so if the provider fails, no rows or schema are written. The vector lands in `embed_` (or `embed.attribute`), declared `[N]f32` on the first write that carries rows; a schema-only write declares no vector column yet. `distance_metric` is optional and defaults to `cosine_distance`. `rank_by: [attr, "ANN", ["Embed", text]]` and `Auto` both embed the query text. Multi-query bodies are not supported on Postgres; use `Auto` or `HybridText`. The profile is stored in Postgres next to the namespace schema, so it survives a gateway restart with no object store configured, and `GET .../schema` returns the `embed` block you wrote. These return `422 UnsupportedByStore`, and nothing is written: | Request | `feature` | | --- | --- | | A second attribute declares `embed` | `schema.embed` | | A later write changes `model`, `dims` or the provider | `schema.embed` | | A client `vector` in the same namespace | `max_vector_fields` | | `embed.chunk` on a profile other than `turbopuffer` or `worker` | `embed.chunk` | A Postgres namespace holds either gateway-embedded vectors or client vectors, never both. To change models, write to a new namespace. [Chunking](#chunking-long-documents) works on Postgres when the profile sets `serving.prefer: turbopuffer` or `worker`. ## CPU models Layer serves three CPU embedding models, small enough to run on a laptop under Docker. They use the same schema as the larger GPU and hosted models, with lower retrieval quality. Community Edition Compose starts the text service next to the gateway, and the Helm chart runs it as a gateway sidecar; neither needs a key, GPU or download after the image pull, and the service has no route out of the machine. | Model | Dims | Modality | Use it for | | --- | --- | --- | --- | | `sentence-transformers/all-MiniLM-L6-v2` | 384 | text | Start here. 22M parameters, symmetric, no prefixes. | | `BAAI/bge-small-en-v1.5` | 384 | text | Better retrieval for 50% more parameters. Try it when MiniLM's results are weak. | | `openai/clip-vit-base-patch32` | 512 | image | Text-to-image search. Both towers run on CPU. | The two text models are served by `layer-embed`, a separate CPU process whose image contains both sets of weights. The gateway reaches it over a private network at `LAYER_EMBED_URL` (for example `http://embed:8081`); nothing is downloaded at startup, and the gateway image carries no model weights. Set the variable and `prefer: local` resolves any model the service advertises. Leave it unset and a `prefer: local` write that names a text model returns `422`. CLIP runs inside the gateway process from the directory named by `LAYER_LOCAL_CLIP_MODEL_PATH`; see [Images](#images). There is no default model: a `prefer: local` profile always names one. Omit `dims` and Layer uses the dimension the service advertises for that model. Every model here pins a fixed artifact; `revision`, `instructions`, and `chunk` are not accepted on a local profile. Query embedding has a 10 second budget and each write 60 seconds, shared by every batch of that write. Set `LAYER_EMBED_QUERY_TIMEOUT_MS` and `LAYER_EMBED_WRITE_TIMEOUT_MS` to change them. A service that is still loading returns `503` for local profiles only; native and GPU profiles are unaffected. ### Text The two text models are embedded differently, and Layer handles both; the [example](#example) at the top of this page uses MiniLM. bge expects an instruction prefix on queries and MiniLM expects none, so the gateway adds the prefix exactly once, only when it embeds a query, and never on the text it stores. bge uses CLS pooling and MiniLM uses mean pooling. Both outputs are L2-normalized, so `cosine_distance` scores are comparable across rows. Switching a namespace between them requires a [full re-index](#changing-models-requires-a-re-index). ### Images With `modality: image` on a CLIP model, Layer embeds each attribute value with the image tower at write time and embeds `Embed` query text with the text tower, against the same vector column. Both towers run on CPU beside the gateway. ```python await client.write_namespace("photos", { "upsert_rows": [ {"id": "commons-131", "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg"}, ], "distance_metric": "cosine_distance", "schema": { "image_url": { "type": "string", "embed": { "model": "openai/clip-vit-base-patch32", "dims": 512, "modality": "image", "serving": {"prefer": "local"}, }, }, }, }) ``` ```go _, err := client.WriteNamespace(ctx, "photos", hevlayer.TurbopufferWriteRequest{ "upsert_rows": []map[string]any{ {"id": "commons-131", "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg"}, }, "distance_metric": "cosine_distance", "schema": map[string]any{ "image_url": map[string]any{ "type": "string", "embed": map[string]any{ "model": "openai/clip-vit-base-patch32", "dims": 512, "modality": "image", "serving": map[string]any{"prefer": "local"}, }, }, }, }) ``` ```typescript await client.writeNamespace("photos", { upsert_rows: [ { id: "commons-131", title: "Sunset at Porto Covo", image_url: "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg" }, ], distance_metric: "cosine_distance", schema: { image_url: { type: "string", embed: { model: "openai/clip-vit-base-patch32", dims: 512, modality: "image", serving: { prefer: "local" }, }, }, }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/photos" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "commons-131", "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg"} ], "distance_metric": "cosine_distance", "schema": { "image_url": { "type": "string", "embed": { "model": "openai/clip-vit-base-patch32", "dims": 512, "modality": "image", "serving": { "prefer": "local" } } } } }' ``` Query the image column with text: ```python response = await client.query_namespace("photos", { "rank_by": ["image_url", "ANN", ["Embed", "sunset over water"]], "top_k": 16, "include_attributes": ["title", "image_url"], }) ``` ```go response, err := client.QueryNamespace(ctx, "photos", &hevlayer.QueryRequest{ RankBy: []any{"image_url", "ANN", []any{"Embed", "sunset over water"}}, TopK: 16, IncludeAttributes: []string{"title", "image_url"}, }) ``` ```typescript const response = await client.queryNamespace("photos", { rank_by: ["image_url", "ANN", ["Embed", "sunset over water"]], top_k: 16, include_attributes: ["title", "image_url"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/photos/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["image_url", "ANN", ["Embed", "sunset over water"]], "top_k": 16, "include_attributes": ["title", "image_url"] }' ``` ```jsonc { "rows": [ { "id": "commons-131", "$dist": 0.412, "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg" } ], "performance": { "embedding_tokens": 5, "embedding_ms": 134 } } ``` Write responses report `embedding_images` instead of `embedding_tokens`. CPU CLIP handles query-time text embeds and write-time image embeds for small and medium corpora. A bulk image backfill needs a GPU. The provider is set per namespace, so a backfill can run on a GPU while queries embed on CPU. The [lens demo](https://lens.hevlayer.com) runs this setup: Wikimedia Commons Quality images embedded and searched on CPU, with the gateway's `performance` echo shown beside each result. Source at [github.com/hev/lens](https://github.com/hev/lens). ### Image input An image profile accepts an HTTP(S) URL or a base64 image string and fetches at most 20 MiB per URL. A rate-limited image host returns HTTP `429` with error `upstream_error`; a server error from the image host returns HTTP `503` with error `service_unavailable`. Layer preserves the host's `Retry-After` response header for both. Malformed URLs or base64, non-retryable 4xx responses, oversize content, and undecodable images return `422 validation_error`. ### Changing models requires a re-index The two text models both emit 384 dimensions, so switching between them leaves the namespace schema valid. It still requires re-embedding every row. Different models place vectors in unrelated spaces. If documents were embedded with MiniLM and queries are embedded with bge, the distances compare incompatible vectors and the ranking is meaningless. The same applies when moving from a CPU model to a hosted or GPU model, and when the weights, tokenizer, pooling, or prefixes behind a model id change: equal dimensions never make two vector spaces compatible. Layer records the artifact fingerprint a local profile was declared with. A write that names a different model for an attribute that already has rows, or a write or `Embed` query after the service starts serving a different artifact for the pinned model, returns `422` and names the pinned model. Re-index from your source data into a fresh namespace so every vector comes from the new model, verify it, then switch your application to it. ### Bringing your own weights You can mount a model directory into the `layer-embed` service to add or override text checkpoints. Set `LAYER_EMBED_MODELS_DIR` on that service to a read-only bundle in the same manifest format as its baked models: a directory with a `manifest.json` listing each model's files with their sizes and SHA-256 hashes. Mounted entries override baked entries by model id. The service verifies every listed file, loads and warms the whole menu, and only then reports ready; the gateway discovers the merged menu at startup. Only BERT text models with 384-dimensional CLS or mean pooling are accepted. The mount is optional and the bundled models need none. Overriding a model id with different weights changes its artifact fingerprint, so existing namespaces on that id need a [full re-index](#changing-models-requires-a-re-index) after the restart. Under Compose, mount the directory into the `embed` service read-only and set `LAYER_EMBED_MODELS_DIR` to its path in a Compose override file. The stock Compose file mounts nothing. For a single static retriever that trades retrieval quality for microsecond embeds and a few megabytes of deployment size, see [Lattice](/docs/ce/api/lattice). ## Query with `Embed` `["Embed", text]` computes a query vector from `text` using the model settings of the attribute being ranked. When `rank_by` names the source attribute, Layer reads the model from its schema: ```python response = await client.query_namespace("clinical-notes", { "rank_by": ["text", "ANN", ["Embed", "chest pain radiating to left arm"]], "top_k": 10, }) print(response.rows) ``` ```go response, err := client.QueryNamespace(ctx, "clinical-notes", &hevlayer.QueryRequest{ RankBy: []any{"text", "ANN", []any{"Embed", "chest pain radiating to left arm"}}, TopK: 10, }) ``` ```typescript const response = await client.queryNamespace("clinical-notes", { rank_by: ["text", "ANN", ["Embed", "chest pain radiating to left arm"]], top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/clinical-notes/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "ANN", ["Embed", "chest pain radiating to left arm"]], "top_k": 10 }' ``` When `rank_by` names the derived vector attribute `embed_`, pass the model explicitly. Omitting it returns `422` with `a model name must be provided`. ```python response = await client.query_namespace("clinical-notes", { "rank_by": ["embed_text", "ANN", ["Embed", "chest pain radiating to left arm", { "model": "acme/clinical-retrieval-v3", }]], "top_k": 10, }) ``` ```go response, err := client.QueryNamespace(ctx, "clinical-notes", &hevlayer.QueryRequest{ RankBy: []any{"embed_text", "ANN", []any{"Embed", "chest pain radiating to left arm", map[string]any{"model": "acme/clinical-retrieval-v3"}}}, TopK: 10, }) ``` ```typescript const response = await client.queryNamespace("clinical-notes", { rank_by: ["embed_text", "ANN", ["Embed", "chest pain radiating to left arm", { model: "acme/clinical-retrieval-v3", }]], top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/clinical-notes/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["embed_text", "ANN", ["Embed", "chest pain radiating to left arm", { "model": "acme/clinical-retrieval-v3" }]], "top_k": 10 }' ``` Query vectors are cached for 60 seconds by default. Set `LAYER_EMBED_CACHE_TTL_MS` to change the TTL. Instruction prefixes are part of the cache key. A missing provider returns `503 service_unavailable`. ### `Embed` with `Auto` An inline `Embed` lets [query routing](/docs/ce/api/query#query-routing) run a semantic or fused query in one request: ```python response = await client.query_namespace("articles", { "rank_by": ["title", "Auto", "how plants turn sunlight into food", { "vector": ["Embed", "how plants turn sunlight into food", {"field": "text"}], }], "top_k": 10, }) ``` ```go response, err := client.QueryNamespace(ctx, "articles", &hevlayer.QueryRequest{ RankBy: []any{"title", "Auto", "how plants turn sunlight into food", map[string]any{ "vector": []any{"Embed", "how plants turn sunlight into food", map[string]any{"field": "text"}}, }}, TopK: 10, }) ``` ```typescript const response = await client.queryNamespace("articles", { rank_by: ["title", "Auto", "how plants turn sunlight into food", { vector: ["Embed", "how plants turn sunlight into food", { field: "text" }], }], top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["title", "Auto", "how plants turn sunlight into food", { "vector": ["Embed", "how plants turn sunlight into food", {"field": "text"}] }], "top_k": 10 }' ``` The first tuple field (`title`) is the attribute for the lexical legs. `field` inside `Embed` names the attribute whose schema supplies the embedding profile and vector column (`text` above). Omit `field` when both are the same attribute. Layer chooses the route before it resolves `Embed`, so a short input routed to `hybrid_text` never calls the embedding provider. A semantic or fused response reports the routing decision (`routing.policy: "v1"`, `routing.executed: true`) and merges embedding measurements into `performance`. Without a vector or an inline `Embed`, the router returns `routing.executed: false` and leaves embedding to the caller. ## Model settings The full `embed` block: | Field | Meaning | | --- | --- | | `model` | A provider-namespaced Hugging Face repo id. | | `dims` | The vector dimension. Must match what the model emits. | | `attribute` | Where the vector is stored. Defaults to `embed_`, and may not be the source attribute. | | `serving.prefer` | `native` (the default), `turbopuffer` (alias `autoscaler`), `local` (alias `lattice`), or `worker`. See [where the vector comes from](#where-the-vector-comes-from). | | `modality` | `text` (the default) or `image`. `image` requires a CLIP-family model. | | `revision` | Pins a checkpoint revision, including a finetuned one. | | `instructions.document` / `instructions.query` | Prefixes for asymmetric retrieval models. Both affect the query-cache key. | | `chunk` | Splits the attribute into one row per chunk on write. See [chunking long documents](#chunking-long-documents). | The first write that establishes an embedded schema must also set `distance_metric`. `revision`, `instructions`, `chunk`, and `modality` are Layer extensions. Layer applies them itself and never forwards them to the store. A profile that sets any of them must use `serving.prefer: turbopuffer` or `worker`, or the write returns `422`. The one exception is a CLIP profile with `modality: image` on `local`, which may not set the other three. A `turbopuffer` profile with an extension also needs a configured Turbopuffer embedding provider, and returns `503` without one. A `worker` profile embeds text only and rejects `revision`; bake the revision into the container image instead. ## Chunking long documents A single vector for a long document matches queries poorly, so long sources should be split into chunks and each chunk embedded as its own row. Split documents in your indexer and write one row per chunk. Store the parent document's id as an ordinary attribute so each hit can be traced back to its source. ## Performance accounting Write and query responses report embedding work under `performance`: ```jsonc { "rows": [ /* ... */ ], "performance": { "embedding_tokens": 8, "embedding_ms": 42 } } ``` Queries omit `embedding_tokens` on a cache hit. Layer merges provider measurements into the same object and exports the work as `hevlayer_embed_tokens_total` and `hevlayer_embed_compute_seconds_total`, labeled by namespace, store kind, model, and serving mode. # Federated query Source: https://hevlayer.com/docs/ce/api/federated-query import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; A **federated query** runs one query across a **set** of namespaces. `POST /v2/query` is namespace-less: the per-namespace [query endpoint](/docs/ce/api/query) names its namespace in the path, while this endpoint takes the set in the body, so the namespace is no longer a path parameter. It has no upstream equivalent — turbopuffer's multi-query is single-namespace, so the fan-out and the merge are a Layer composition over per-namespace reads. One ranking is spread across every namespace in the set and merged into a single ranked list — "search my whole feed / my whole library." For several **independent** rankings instead, query each namespace separately; a federated query always returns one fused list. ## Fan-out and fuse Supply one ranking and a `namespaces` set. The gateway runs the ranking against each namespace and returns one merged `rows` list, each row tagged with the namespace it came from. ```python response = await client.query({ "namespaces": ["moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"], "rank_by": ["text", "Auto", "evaluating RAG systems"], "top_k": 12, "filters": ["published_at", "Gte", 1740000000], "include_attributes": ["text", "show", "source_url", "start_sec"], }) ``` ```go response, err := client.Query(ctx, &hevlayer.FederatedQueryRequest{ Namespaces: []string{"moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"}, RankBy: []any{"text", "Auto", "evaluating RAG systems"}, TopK: 12, Filters: []any{"published_at", "Gte", 1740000000}, IncludeAttributes: []string{"text", "show", "source_url", "start_sec"}, }) ``` ```typescript const response = await client.query({ namespaces: ["moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"], rank_by: ["text", "Auto", "evaluating RAG systems"], top_k: 12, filters: ["published_at", "Gte", 1740000000], include_attributes: ["text", "show", "source_url", "start_sec"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "namespaces": ["moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"], "rank_by": ["text", "Auto", "evaluating RAG systems"], "top_k": 12, "filters": ["published_at", "Gte", 1740000000], "include_attributes": ["text", "show", "source_url", "start_sec"] }' ``` `rank_by` accepts the same vocabulary as a single-namespace query, including the Layer [`HybridText`](/docs/ce/api/query#hybrid-text-fusion) and [`Auto`](/docs/ce/api/query#query-routing) expressions. The query text is identical across the fan-out, so `Auto` routes **once** and the chosen route runs against every namespace — a single `routing` block is echoed. `filters` apply to every namespace. Name the explicit set to search, or pass `namespaces: ["*"]` to expand the authenticated key's namespace allowlist at request time. Omitting `namespaces` is equivalent to `["*"]`. ### Response One fused list. Each row carries `$namespace` (its origin) and `$rank` (its position within that namespace's results — the key the merge orders on). A `merge` block names the strategy, and a `namespaces` block reports each namespace's freshness and how many rows it returned to the merge. ```json { "rows": [ { "id": "ep123#7", "$namespace": "moment-pod-latent-space", "$rank": 1, "$score": 11.4, "show": "Latent Space", "text": "..." } ], "merge": { "method": "rank-interleave", "route": "fused" }, "routing": { "route": "fused", "policy": "v1", "tokens": 3, "executed": true }, "hybrid": { "tokens": ["evaluating", "rag", "systems"], "tokens_dropped": 0, "fuzziness": "auto", "rank_constant": 60, "legs": 4, "per_leg_limit": 60 }, "namespaces": [ { "namespace": "moment-pod-latent-space", "stable_as_of": 1747300000123, "matched": 14 }, { "namespace": "moment-pod-changelog", "stable_as_of": 1747299999001, "matched": 9 }, { "namespace": "moment-pod-no-priors", "stable_as_of": 1747300000050, "matched": 0 } ] } ``` | Field | Meaning | | --- | --- | | `$namespace` | The namespace this row came from. | | `$rank` | The row's 1-based rank **within its namespace**. The merge orders on this on a text route; see [Merge](#merge). | | `$score` / `$dist` | The row's native score within its namespace, carried as provenance. Comparable **within** a namespace, not across the fused list — do not threshold on it. On a vector route `$dist` is the merged ordering key and *is* globally comparable. | | `merge` | The merge applied. `method` is `"distance"` on a vector route, `"rank-interleave"` on a text route; `route` is currently always `"fused"` (reserved for future route-specific provenance). | | `namespaces` | Per-namespace echo: each reached namespace's `stable_as_of` watermark and `matched` — the rows it **returned to the merge** (up to [`per_namespace_limit`](#fusion-options)), not its surviving share of the final `top_k`. A namespace that matched nothing reports `matched: 0`. | `routing` and `hybrid` echo exactly as they do for a single-namespace [`Auto`](/docs/ce/api/query#query-routing) / [`HybridText`](/docs/ce/api/query#response) query, since the route runs once for the whole fan-out. ### Fusion options | Option | Default | Meaning | | --- | --- | --- | | `fusion.per_namespace_limit` | `clamp(2 × top_k, 10, 100)` | How many rows each namespace returns to the merge. Shallower than the single-namespace `per_leg_limit`: across a wide fan-out most namespaces contribute nothing to the final `top_k`, so deep per-namespace retrieval is wasted. Integer > 0. | | `fusion.rank_constant` | `60` | Reserved for route-dependent fusion tuning. It is accepted for forward compatibility and is inert in the current gateway. | ## Filters One `filters` expression is applied to **every** namespace in the set, and each namespace evaluates it independently against its own schema. So a filter only behaves uniformly when the attributes it references are part of a **shared filterable schema** across the set — present in every namespace, with the same type, and declared filterable. This is the filter analog of the [shared embedding space](#vector-merge-requires-a-matching-embedding-space) a vector merge needs: a federated query is only as coherent as the contract its namespaces share. A namespace that cannot evaluate the filter — the attribute is absent, has an incompatible type, or is not filterable there — is reported as `filter_schema_mismatch`, not as `matched: 0`. This is a per-namespace validity failure: the namespace did not participate in the filtered ranking because it could not evaluate the predicate. In default best-effort mode, the namespace is omitted from `rows`, listed in `errors`, and the response carries `x-layer-partial: true`. With `strict: true`, the whole request fails with `422`. This docs contract selects `422` for the strict-mode failure because the namespace set and filter expression are valid only if every required namespace can evaluate the filter. Keep filters to attributes declared filterable in every namespace in the set. Using the same embedding model does not imply a shared filterable schema; scope the set to namespaces that support the filters you send. ## Merge A federated query merges by the quantity the route makes comparable across namespaces: - **Vector route** (`ANN` over a query vector) — distances are comparable across namespaces that share an embedding space, so the gateway merges by `$dist` and the fused order is the exact global nearest-neighbor ranking. `merge.method` is `"distance"`. - **Text route** (`BM25`, `HybridText`, or a routed `fused`/`hybrid_text`) — BM25 and hybrid scores are **not** comparable across corpora (different term statistics; a hybrid `$score` is already a per-namespace fusion). The gateway merges by **rank-interleave**: rows are ordered by their `$rank` within their namespace, ties broken by `id`. `merge.method` is `"rank-interleave"`. No single pod dominates the head of the list; each contributes its best matches in rank order. The fused list exposes `$rank` as the ordering key and carries each row's native `$score` only as provenance. `top_k` truncates the merged list. Each namespace returns [`fusion.per_namespace_limit`](#fusion-options) rows to the merge. ### Vector merge requires a matching embedding space Merging by distance is only meaningful when every namespace in the set embeds into the same geometry: model, output dimension, normalization, and distance metric must match. A standalone namespace without an embedding profile uses the best-effort downgrade below, or fails when `strict: true`. The gateway compares those profiles before a vector fan-out uses distance merge: - All reached namespaces have the same profile → `merge.method: "distance"`. - A profile is missing or differs → best-effort responses downgrade to `merge.method: "rank-interleave"` and include `merge.downgraded_reason` (`"missing_embedding_profile"` or `"embedding_profile_mismatch"`). - With `strict: true`, a missing or mismatched profile is a `422`. A text fan-out is rank-interleave regardless, so embedding profiles do not affect it. ## Consistency Each namespace has its own [stable-read](/docs/ce/api/query#stable-reads) watermark; there is no single consistent cut across independent namespaces. The `namespaces` block reports each namespace's `stable_as_of`, and the response header `x-layer-stable-as-of` carries the **minimum** across the reached namespaces — the most conservative answer to "as of when." Layer does not manufacture a global watermark. ## Partial failure Across many namespaces, one may be deleted, time out, error upstream, or fail to evaluate the [filter](#filters). Unlike single-namespace [multi-query](/docs/ce/api/query#batch-query), the fan-out defaults to **best-effort**: it returns the namespaces that succeeded, lists the rest in an `errors` block, and sets `x-layer-partial: true`. A feed search does not blank because one pod is briefly unavailable. ```json { "rows": [ "..." ], "errors": [ { "namespace": "moment-pod-no-priors", "error": "Upstream error: namespace not found" } ] } ``` Availability failures carry a human-readable upstream message in `error`, not a stable machine code — match on `namespace`, not on the `error` string. Filter schema mismatches are the exception: they carry the stable `code: "filter_schema_mismatch"` plus a human-readable `detail` so clients can separate "namespace unavailable" from "namespace cannot evaluate this filter." ```json { "rows": [ { "id": "ep123#7", "$namespace": "moment-pod-latent-space", "$rank": 1 } ], "errors": [ { "namespace": "moment-pod-no-priors", "code": "filter_schema_mismatch", "error": "filter schema mismatch", "detail": "filter attribute published_at is absent, not filterable, or has an incompatible type" } ] } ``` Set `"strict": true` to opt into fail-fast: any namespace error fails the whole request. Reach for it when a missing namespace would make the result misleading rather than merely thinner. ## Pinned namespace concurrency Federated queries run up to `64` pinned and ready namespace legs concurrently, configured with `LAYER_PINNED_FEDERATED_QUERY_NAMESPACE_THREADS` (clamped to `1..512`). Other namespace legs retain `LAYER_FEDERATED_QUERY_NAMESPACE_THREADS` (default `16`). Mixed requests use separate lane limits with an overall ceiling of `512` when a pinned lane is present. Readiness uses the same [metadata observations and expiry as scans](/docs/ce/api/scans#pinning-and-fan-out). HybridText retains its `15`-token expansion limit regardless of pinning. Pinning changes scheduling capacity, not token selection or ranking semantics. ## Limits | Limit | Value | | --- | --- | | Namespaces per request | 512. Over the cap → `422` naming the excess. | | Pagination | Not supported. `cursor` is rejected — a fused cursor across independent namespaces does not form the monotone bands pagination relies on. | ## Validation | Condition | Status | | --- | --- | | A namespace read fails upstream | 200 with `errors`, or 502 with `strict: true` | | A namespace cannot evaluate `filters` (attribute absent, wrong type, or not filterable) | 200 with `errors[].code: "filter_schema_mismatch"` and `x-layer-partial: true`, or 422 with `strict: true` | | `namespaces` is empty | 422 | | `namespaces: ["*"]` mixed with other names | 422 | | `namespaces` set exceeds the cap | 422 | | Vector route missing/mismatched embedding profiles with `strict: true` | 422 | | `cursor` present | 422 | | `rank_by` expression invalid | 422 (same checks as a single-namespace query) | # Introduction Source: https://hevlayer.com/docs/ce/api/introduction import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; Layer supports migration through a Turbopuffer-compatible wire subset and adds gateway features across its backends, including agentic search. Compatibility is the entry point; the full Layer API is available through the Layer clients for Community Edition and Pro. The gateway selects the backend. Each API page describes the current integration's supported features and gaps. ## Install There are four ways to call Layer: the Python client, the Go client, the TypeScript client, and the REST API itself. The clients are generated from `apps/layer-gateway/openapi.yaml`, so all four expose the same operations — every endpoint page on this site shows them side by side. Anything the clients can do, plain HTTP can do. ```sh pip install hevlayer # Python 3.11+ go get github.com/hev/layer-go # Go 1.22+ npm install hevlayer # Node 18+ ``` Point a client at the gateway: ```python import os from hevlayer import AsyncHevlayer client = AsyncHevlayer( base_url=os.environ["LAYER_GATEWAY_URL"], api_key=os.environ.get("LAYER_GATEWAY_API_KEY", ""), ) ``` ```go import ( "os" hevlayer "github.com/hev/layer-go" ) client := hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("LAYER_GATEWAY_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) ``` ```typescript import { Hevlayer } from "hevlayer"; const client = new Hevlayer({ baseUrl: process.env.LAYER_GATEWAY_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); ``` ```bash curl "$LAYER_GATEWAY_URL/health" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Code examples across these pages assume this `client` — and in Go, a `ctx context.Context`. The cURL tab on each page is the bare REST contract; any HTTP stack works the same way. ## Authentication Set `LAYER_GATEWAY_URL` to the gateway origin, without `/v2`. Set `LAYER_GATEWAY_API_KEY` to the inbound bearer token when the gateway requires one. An isolated local gateway configured with `inboundAuth.mode: open` needs no token. Engine credentials belong on the gateway, not in application code. With `deriveFromStore`, the configured Turbopuffer store key is accepted as an admin bearer. Pro also supports minted namespace-scoped keys. See [API keys](/docs/pro/api/keys). The local Postgres quickstart is open by default. See [configuration](/docs/ce/configuration) before exposing the gateway. Routes enforce read, write or admin authorization and namespace grants. A request outside a key's scope returns 403. Configure the gateway's inbound authentication separately from its engine connection. ## Gateway failures Clients return connection errors when the gateway is unreachable. They do not retry directly against a backing store. Unsupported store features return `422 UnsupportedByStore`; malformed input and gateway validation errors are reported separately. A client method's existence does not establish support for every backend. ## Gateway operations Start with [write](/docs/ce/api/write) and [query](/docs/ce/api/query). Other routes include [scans](/docs/ce/api/scans), [embedding](/docs/ce/api/embed), [federation](/docs/ce/api/federated-query) and [metadata](/docs/pro/api/namespace-metadata). The same clients call the entire Layer API, including operations outside the compatible subset. Backend-specific notes remain beside the affected examples. ## Response metadata Layer returns retrieval metadata in [response headers](/docs/ce/api/response-headers). Use the generated clients' performance response options where needed. Stable-watermark and pagination headers depend on the backend and query mode; they are not guarantees made by every store. # Latest-row aggregates Source: https://hevlayer.com/docs/ce/api/latest-relation import Edition from "../../../components/docs/Edition.astro"; `POST /v2/namespaces/{namespace}/aggregate` accepts `latest_by` for an exact Count/Sum over the winning revisions observed by a complete source read. This is separate from ranked collapse. It returns aggregates, never revisions. The same strict request is accepted on `/query` when `latest_by` is present. Generated clients expose `aggregateLatest` / `aggregate_latest` with typed `LatestRelationRequest` and `LatestRelationResponse` models. ```json { "latest_by": { "key": ["entity"], "order_by": [ {"field":"version", "direction":"desc", "nulls":"last"}, {"field":"id", "direction":"desc", "nulls":"last", "canonical_key":"entity"} ] }, "scan": {"max_rows":100000, "max_bytes":67108864, "consistency":"observed"}, "filters": ["status", "Eq", "active"], "aggregate_by": {"entities":["Count"], "amount":["Sum","amount"]} } ``` The relation selects one complete row per key, then evaluates `filters`, then aggregates. An older active revision cannot reappear when the winning row is inactive. Ordering is lexicographic in the supplied terms. `canonical_key` compares whether the physical `id` equals that row's named key attribute; `desc` prefers the canonical ID. It is allowed only on `field: "id"`. A descending physical-ID tie breaker is always appended. IDs must be uniformly strings or unsigned integers; strings compare by UTF-8 bytes. Attribute types must be consistent across nonnull values. An ordering attribute cannot mix floating-point values with integers outside the exact binary64 integer range (−2^53 through 2^53); that whole source domain is rejected before returning winners. Integer-only ordering preserves the full int64/uint64 range. Null and missing compare together, with placement explicit for each term. Logical keys must contain nonempty strings, numbers or booleans; null, missing and empty string keys fail the request rather than merging unrelated rows. Count counts selected matching rows. Sum ignores null/missing values and returns zero for an empty set. Non-numeric values and numeric overflow fail. Integer sums remain integers; fractional sums use compensated binary64 arithmetic. A fractional sum with an integer subtotal outside ±2^53 is rejected. JSON integers remain exact on the wire; consumers must preserve integer precision when decoding large totals. Filters support Eq, NotEq, Gt, Gte, Lt, Lte, In, NotIn, Contains, NotContains, ContainsAny, NotContainsAny, And, Or and Not. Eq null matches missing as well as explicit null; range comparisons never match null. Unknown operators and options fail before source reads. Attribute references are literal flat names, including names containing dots; JSON paths and nested object traversal are not supported. ## Cost and consistency Turbopuffer reads all revisions in strong ID-ordered pages inside Layer. For a namespace with active sharding, Layer scans independent shards concurrently using the existing namespace scan thread default and readiness maximum (`Index.spec.scan.threads`; see [scan concurrency](/docs/ce/api/scans)). Each shard advances its own ordered cursor to exhaustion. A disjoint remainder partition reads rows whose shard tag is missing, null or outside that topology. Cached readiness is a partitioning hint, never proof that every row is tagged. The remainder is read on every request, under the same concurrency and global budgets; it is not a second full scan. Layer then selects winners once across all partitions, before applying mutable predicates. Invalid manifest namespace identity or completion metadata fails the request, as do topology auth, throttle, transport and server errors. Absent or unsupported metadata permits a complete unpartitioned read. Counts of zero or above 4,096 use one complete serial cursor before partition dispatch rather than constructing an unbounded filter. Unsharded namespaces retain a single complete cursor. No physical shards are created or resized by this request. If sharding is not ready, the complete unpartitioned scan includes tagged and untagged data and excludes the namespace metadata marker. Every partition excludes the marker after decoding, without sending a string-ID filter to unsigned-integer ID namespaces. Marker response bytes and pages still count. For scalar requests without named grouped plans, the HTTP adapter requests only attributes referenced by the logical key, winner ordering, every filter branch and Sum metrics. Physical IDs are retained for ordering and ties. This avoids transferring and decoding unused attributes such as large payloads. All revisions are still read; predicates run in Layer after winner selection. If a partition's first projected query returns Turbopuffer's explicit HTTP 400 error for an attribute absent from its schema, Layer retries that partition once with all attributes. This preserves missing-value semantics, including Sum of an absent field and null filters. The failed read adds a source query and its response bytes count against the byte budget; any reported billing is retained. It may add backend cost. Other errors propagate. After any successful page in that partition, schema loss fails the request without restarting the scan. Projection is attempted afresh on each request, so later schema additions remain visible. Grouped requests read all nonvector attributes, including attributes not referenced by any plan, to preserve complete prefilter winner binding on continuations. Existing adapters without explicit projection support retain their full-source implementation. Layer processes every observed revision, and gateway memory scales with revision count and returned row size. Each page is a separate source query; the backend may rescan data on every page. Physical source work, billed bytes and latency are unmeasured and may grow with namespace size times page count. Attribute projection does not establish fewer physical rows read, fewer storage bytes scanned or lower billing. This is not a native latest-row pushdown. No rows are transported to the API consumer. The scan does not use the cache. Each page is a separate read: concurrent writes, updates or deletes may change the observed relation. `observed` does **not** mean a point-in-time snapshot, and can miss inserts behind the cursor or observe a winner that is later replaced. `snapshot` is rejected on this backend. Both scan budgets are required. Exceeding either fails the whole request; there is no partial total or silent 10,000-row cap. Limits bound accepted source rows and decoded JSON bytes; up to the configured scan width of source pages can be in flight before a byte overrun is detected. The row and byte limits apply to the whole request, not separately to each shard. A failed shard fails the request and cancels pending sibling reads; caller cancellation also drops those reads. These are gateway transfer budgets, not limits on backend scanned or billable bytes. Source byte counts measure decoded row-page response bytes. Metadata and sharding checks can make additional source calls outside these counters. Billing and end-to-end latency must be measured separately. Response `relation` reports the actual source rows, pages, bytes, selected and matching rows, the declared consistency and the exhausted-source flag. `complete` means the scan reached its end under that consistency contract. It does not certify a snapshot under concurrent writes. Only explicitly supported adapters may execute this plan. Other query options, ranked queries, joins and native group_by are rejected in this initial slice. Postgres support and cross-namespace join isolation require separate validation; native physical-row Aggregate support does not imply latest-row support. There is no persistent analytics store and no reuse of a previous request's selected rows. ## Named grouped plans Add `grouped` to evaluate up to 16 named plans on the same complete selected, filtered relation. Scalar `aggregate_by` remains required. For example: ```json {"grouped":{"projects":{"group_by":[{"kind":"field","field":"project"}],"metrics":{"sessions":{"kind":"count"},"spend":{"kind":"sum","field":"amount"}},"page_size":100}}} ``` Each plan accepts literal flat field dimensions (`field`, `utc_day`) and an optional expansion: `timestamps` with `field` and IANA `timezone`, or `models` with an object field mapping model names to numeric metric objects. Timestamp expansion enables `weekday` (Monday=0) and `hour`; model expansion enables `model` and `model_sum`. UTC dates consume integer epoch milliseconds. Repeated DST hours merge while counting every instance. Empty expansions produce no instances. Missing scalar dimensions form a typed null group; empty strings remain distinct. No absent groups are filled with zero. Row `sum` under model expansion rejects; per-model amounts are never inferred from session totals. Timestamp expansion repeats a row sum per instance. Response `grouped[name]` contains typed keys, aggregates, `next_cursor`, `total_groups`, and `complete` for the group page. Outer `relation.complete` certifies the observed source scan; it is distinct from page completeness. Pass a page's cursor in that named plan's `cursor`. Every continuation repeats the complete scan and grouping. It rejects changed selected content, selection or filter plan, named plan, page size, namespace, store or authenticated caller. It does not pin an upstream snapshot. Client scope/boundary fields reject. All plans share finite ceilings: 10 million expanded instances and 100,000 accumulated groups, divided equally among the submitted plans. Each plan has at most 10,000 output groups per page. Any budget or numeric failure rejects the entire request, including scalar totals and earlier plans. Work scales with revisions plus selected rows times plans plus expanded instances; named plans share one source scan and one latest/filter selection, not grouping work. Integer sums are exact; mixing any integer outside ±2^53 with floats rejects. Unsupported grade/tool/cell predicates and joins require consumer fallback. The grade semi/anti-join kernel has no authorized HTTP surface here. # Lattice Source: https://hevlayer.com/docs/ce/api/lattice import Edition from "../../../components/docs/Edition.astro"; import Preview from "../../../components/docs/Preview.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro";

Lattice is in preview. It is documented and usable, but it sits outside the release line: it is not listed in the changelog, its configuration can change without a deprecation cycle, and it carries no compatibility promise. The supported way to embed text on CPU is the [bundled model menu](/docs/ce/api/embed#cpu-models); Lattice is for workloads that have measured that menu and need something smaller.

Lattice is a static retriever — a token lookup table rather than a transformer. It embeds text in microseconds on a CPU and adds a few megabytes to a deployment. It scores materially below a real dense embedder on retrieval, which is the trade it exists to make. It is an explicit serving leg. A namespace that selects Lattice never falls back to another leg, and an unconfigured artifact is a validation error rather than a silent substitution. ## Provisioning Generate a deployment artifact with the upstream [Lattice slicer](https://github.com/ErikKaum/lattice/tree/main/slicer), place its `model.safetensors` and `tokenizer.json` together, and set `LAYER_LATTICE_MODEL_PATH` to the model file before starting the gateway. The supported model id is `erikkaum/lattice-retrieval`; the requested `embed.dims` must match the loaded artifact, and only text modality is supported. ```bash uv run slicer slice \ --dim 512 \ --quant int4_row \ --output-dir /var/lib/hevlayer/lattice export LAYER_LATTICE_MODEL_PATH=/var/lib/hevlayer/lattice/model.safetensors ``` ```jsonc "text": { "type": "string", "embed": { "model": "erikkaum/lattice-retrieval", "dims": 512, "serving": { "prefer": "lattice" } } } ``` `prefer: lattice` selects the Lattice artifact. `prefer: local` also resolves to it when the declared model is `erikkaum/lattice-retrieval`. The recommended operating point is an int4-per-row, 512-dimensional artifact. Int4 quantizes the model's lookup-table weights only. Layer writes the resulting normalized vectors as `[512]f32`; Turbopuffer's int8 minimum for quantized vector storage is a separate choice and is not used by this path. ## End-to-end example Declare the Lattice profile on a string attribute, write rows, and query with `Embed`. The gateway embeds both sides in-process — no external inference provider is involved. Write two rows into a namespace whose `text` attribute carries the profile above: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."} ], "schema": { "text": { "type": "string", "embed": { "model": "erikkaum/lattice-retrieval", "dims": 512, "serving": { "prefer": "lattice" } } } } }' ``` Query by meaning rather than exact phrase: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "ANN", ["Embed", "largest planet in the solar system"]], "top_k": 3, "include_attributes": ["title", "text"] }' ``` ```jsonc { "rows": [ { "id": "planet-1", "$dist": 0.137, "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System." } ], "performance": { "embedding_tokens": 7, "embedding_ms": 1 // in-process lookup — no network hop to a provider } } ``` A live example of exactly this contract is the [Wikipedia × Lattice demo](https://wiki.hevlayer.com): all 283,997 Simple English Wikipedia articles (1.74M paragraph rows) embedded through Lattice and searched on Turbopuffer, with the `performance` echo displayed beside each result. Source at [github.com/hev/wiki](https://github.com/hev/wiki). ## Limits - Text only. An image modality on a Lattice profile is a validation error. - No [revision pins or instructions](/docs/ce/api/embed#model-settings). Those extensions require a GPU-served profile. - `embed.dims` must equal the sliced artifact's dimension. A mismatch is a validation error at write time, not a silent reshape. - A directory that fails to load stops the gateway at startup rather than serving a namespace that cannot embed. # Query & Fetch Source: https://hevlayer.com/docs/ce/api/query import PostgresWarning from "../../../components/docs/PostgresWarning.astro"; import Edition from "../../../components/docs/Edition.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import Callout from "../../../components/docs/Callout.astro"; Native queries are wire-compatible with the upstream `POST /v2/namespaces/{ns}/query` endpoint and forwarded as-is after validation. The Layer API includes this compatible subset and the gateway operations documented below; backend support is called out at each feature. This is Layer's query API. Layer reports its own metadata in `x-layer-*` response headers. ## Stable reads Stable reads. Layer tracks a stable watermark for Turbopuffer-backed namespaces. When one is available, `x-layer-stable-as-of` reports that observation. Store capabilities and watcher state determine whether a watermark filter applies; a cold-start gateway can return a query without this header. This is achieved by querying Turbopuffer with `consistency=eventual` and watching each namespace's `index.status`. While the index is updating, Layer injects a hidden `_hevlayer_upserted_at <= watermark` predicate so the read does not include partially indexed rows. Stable or unknown status runs without that predicate; a 429 can trigger one retry with the watermark filter forced on. Responses report `x-layer-stable-as-of` in epoch milliseconds when the watcher has a watermark. The header is omitted on a cold-start gateway that has not yet observed a stable poll. Query responses carry `next_cursor` and, when another page is available, `x-layer-next-cursor`. Cursor behavior depends on the selected store and query mode; unsupported pagination features return `422 UnsupportedByStore`. ## Query by id `nearest_to_id`. Pass `nearest_to_id` in place of `vector` to rank by stored document vectors instead of a raw query vector — exactly one of the two is required. `nearest_to_id` takes an **array of document ids**: the gateway resolves each id's vector (document cache first, the namespace's configured VectorStore on miss with a cache backfill) and averages them component-wise into a single centroid, then ranks nearest neighbors to that centroid. Pass one id to rank by a single document; pass several to get "more like these" over a set of seeds. ```python response = await client.query_namespace("products", { "nearest_to_id": ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], "top_k": 10, "include_attributes": ["title", "category"], }) ``` ```go response, err := client.QueryNamespace(ctx, "products", &hevlayer.QueryRequest{ NearestToID: []string{"asin-B08N5WRWNW", "asin-B07PXGQC1Q"}, TopK: 10, IncludeAttributes: []string{"title", "category"}, }) ``` ```typescript const response = await client.queryNamespace("products", { nearest_to_id: ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], top_k: 10, include_attributes: ["title", "category"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "nearest_to_id": ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], "top_k": 10, "include_attributes": ["title", "category"] }' ``` | Outcome | Status | | --- | --- | | Every id resolved (cache or origin) | 200, ranked results | | Any id has no stored vector anywhere | 404 (names the missing ids) | | `nearest_to_id` empty, or both/neither of `vector` / `nearest_to_id` | 422 | The centroid is an unweighted mean, so seed ids contribute equally regardless of how many you pass. All resolved vectors share the namespace's dimensionality, so no reconciliation is needed across seeds. This fuses the seeds into one ranking; to run several *independent* rankings in a single request, see [batch query](#batch-query). ## Rank expressions The Go typed `QueryNamespace` request currently serializes an empty `collapse` object when unset, causing a validation error. The shared Go example uses the Layer client's raw `QueryTurbopufferNamespace` method instead. After the [write example](/docs/ce/api/write#upsert-and-delete): ```python result = await client.query_namespace("preview_docs", { "rank_by": ["vector", "ANN", [1, 0, 0]], "top_k": 5, "filters": ["And", [["category", "Eq", "space"], ["year", "Gte", 2025]]], "include_attributes": ["body", "category", "year"], }) ``` ```go result, err := client.QueryTurbopufferNamespace(ctx, "preview_docs", hevlayer.TurbopufferQueryRequest{ "rank_by": []interface{}{"vector", "ANN", []float64{1, 0, 0}}, "top_k": 5, "filters": []interface{}{"And", []interface{}{ []interface{}{"category", "Eq", "space"}, []interface{}{"year", "Gte", 2025}, }}, "include_attributes": []string{"body", "category", "year"}, }) ``` ```typescript const result = await client.queryNamespace("preview_docs", { rank_by: ["vector", "ANN", [1, 0, 0]], top_k: 5, filters: ["And", [["category", "Eq", "space"], ["year", "Gte", 2025]]], include_attributes: ["body", "category", "year"], }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rank_by":["vector","ANN",[1,0,0]],"top_k":5,"filters":["And",[["category","Eq","space"],["year","Gte",2025]]],"include_attributes":["body","category","year"]}' ``` ```python text = await client.query_namespace("preview_docs", { "rank_by": ["body", "BM25", "moon"], "top_k": 5, "include_attributes": ["body"], }) ``` ```typescript const text = await client.queryNamespace("preview_docs", { rank_by: ["body", "BM25", "moon"], top_k: 5, include_attributes: ["body"], }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rank_by":["body","BM25","moon"],"top_k":5,"include_attributes":["body"]}' ``` Pass `rank_by` with `top_k` when you need an explicit ranking operator instead of the top-level `vector` / `nearest_to_id` shape. Supported ranks, pagination and consistency depend on the selected backend, as described in this section. On Turbopuffer, native query bodies that omit `top_k` remain pass-through. `rank_by` is mutually exclusive with `vector` and `nearest_to_id`. On a Postgres-backed namespace, `rank_by: ["", "asc"|"desc"]`, `rank_by: ["id", "asc"|"desc"]`, and a list of up to 8 such pairs return rows in that order with `top_k` or `limit`, `filters`, and `include_attributes`. A query with neither `rank_by` nor `vector` is ordered by id ascending. - Missing and `null` values sort first ascending and last descending; id ascending breaks remaining ties. Rows carry no `$dist`. - Strings order and compare (`Gt`, `Lt`, and so on) in byte order. In a namespace holding both id types, unsigned-integer ids sort before string ids. - There is no cursor for ordered queries. Page by advancing a filter on the order attribute, for example `["id", "Gt", last_id]`. A `cursor` returns `422 UnsupportedByStore`. - Ordering by id or by an `int`, `uint`, `float`, or `bool` attribute reads a B-tree index. String attributes have no B-tree index, so ordering by one sorts every row that matches the filter. On a Postgres-backed namespace, `exclude_attributes` returns everything `include_attributes: true` returns except the listed attributes, on ranked, ordered and filter-only queries. `id` is always returned. Vector attributes are left out of both unless named in `include_attributes`. Sending `include_attributes` and `exclude_attributes` together is a `400`. On a Postgres-backed namespace, `Contains`, `NotContains`, `ContainsAny` and `NotContainsAny` filter an array attribute (`[]string`, `[]int`, `[]uint`, `[]float` or `[]bool`), for example `["tool_names", "ContainsAny", ["Bash"]]`. - `Contains` matches rows whose array holds the value. `ContainsAny` matches rows whose array shares at least one element with the list. - A missing, `null` or empty array contains nothing, so `ContainsAny` with an empty list matches no rows. Each `Not` form matches exactly the rows its positive form does not. - Values must have the array's element type. A value of another type, or an array operator on a non-array attribute, is a `400`. - Array attributes take only these four operators. `Eq`, `In`, the ordering comparisons and `rank_by` on an array attribute return `422 UnsupportedByStore`. ## Batch query `nearest_to_id` fuses several seeds into a **single** ranking. To run several **independent** queries in one round trip, each with its own ranking, post a `queries` array. The response is a parallel `results` array — one ranked result set per query, in request order: `{ "results": [{ "rows": ... }] }`. Layer holds every leg on the same stable cut, so a batch reads one consistent view of the index. (The method is `batch_query_namespace`. It is named apart from turbopuffer's own upstream multi-query — a `rerank_by` body, which Layer passes through unchanged, as noted at the end of this section — to keep the two distinct.) ```python batch = await client.batch_query_namespace("products", { "queries": [ {"rank_by": ["vector", "ANN", [0.1, 0.2, 0.3]], "top_k": 10}, {"rank_by": ["title", "BM25", "wireless earbuds"], "top_k": 10}, ], }) # batch.results[0].rows ranked by vector; batch.results[1].rows by text ``` ```go batch, err := client.BatchQueryNamespace(ctx, "products", &hevlayer.BatchQueryRequest{ Queries: []hevlayer.TurbopufferQueryRequest{ {"rank_by": []any{"vector", "ANN", []float64{0.1, 0.2, 0.3}}, "top_k": 10}, {"rank_by": []any{"title", "BM25", "wireless earbuds"}, "top_k": 10}, }, }) ``` ```typescript const batch = await client.batchQueryNamespace("products", { queries: [ { rank_by: ["vector", "ANN", [0.1, 0.2, 0.3]], top_k: 10 }, { rank_by: ["title", "BM25", "wireless earbuds"], top_k: 10 }, ], }); // batch.results[0].rows ranked by vector; batch.results[1].rows by text ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "queries": [ {"rank_by": ["vector", "ANN", [0.1, 0.2, 0.3]], "top_k": 10}, {"rank_by": ["title", "BM25", "wireless earbuds"], "top_k": 10} ] }' ``` All legs in a non-fused batch share one `x-layer-stable-as-of` value. A leg may use native `rank_by`, or the Layer `vector` / `nearest_to_id` single-query shape; `nearest_to_id` is resolved before the leg is sent upstream. Batches must contain 2 to 16 legs. `cursor` is rejected at the top level and per leg because pagination is single-query only. Native multi-query bodies with `rerank_by`. Layer’s `queries` batch API above is separate. When `rerank_by` is present, Layer treats the request as an upstream fused query and passes the body through unchanged. Reach for a batch query when you genuinely need N rankings — distinct user queries batched into one round trip, or hybrid retrieval fused upstream with RRF. Reach for `nearest_to_id` when many seeds should collapse into one "more like these" ranking. To get typo-tolerant text search without building the fused query yourself, see [hybrid text fusion](#hybrid-text-fusion). Every leg here targets the namespace in the path. To fan one query across a **set** of namespaces — and merge them into a single ranked list — see [federated query](/docs/ce/api/federated-query). ## Type-ahead text queries Use the last token as a literal prefix while the user is typing: ```json { "rank_by": ["text", "BM25", "Murp", {"last_as_prefix": true}], "top_k": 8, "collapse": {"by": "project_record_id", "expansion": 3}, "include_attributes": ["job_name", "job_number", "links"] } ``` `last_as_prefix` defaults to `false`. Earlier tokens keep their ordinary BM25 matching; only the last token accepts completions. Prefix matches score `1.0`. The field must have `full_text_search: true`. Collapse ranks the widened candidate window before grouping, so several matching facts can lead to one job. A prefix is not typo correction. Postgres supports this BM25 option using the existing full-text index. It uses the same default tokenizer as that index. This does not enable fuzzy filters or the fuzzy legs of `HybridText` on Postgres. ## Hybrid text fusion The fuzzy text filters used by `HybridText`. BM25 misses typos and morphological variants; fuzzy matching alone loses the relevance signal BM25 provides. `HybridText` runs both in one request: the gateway tokenizes your input string, expands it into one BM25 leg plus one fuzzy leg per token, and the effective legs are RRF-fused into one ranking. One expression in, typo-tolerant ranked results out. On a Postgres-backed namespace `HybridText` is the hybrid route, served with the limits in its capability cell: `fuzziness: 0` only, no `cursor` or temporal filter; other shapes return `422 UnsupportedByStore`. A `queries` or `rerank_by` body returns `422 UnsupportedByStore` naming `multi_query`. `HybridText` is a Layer-only `rank_by` spelling on the existing query route — no new endpoint, no client changes beyond the expression. The gateway tokenizes with [`alyze`](https://github.com/turbopuffer/alyze), turbopuffer's own open-source tokenizer and the same code that segmented your text at index time, so query tokens match index terms by construction. The ranked field must be indexed for both full-text and fuzzy matching — declare it `{"type": "string", "full_text_search": true, "fuzzy": true}` in the namespace schema. The BM25 leg uses the full-text index; the per-token fuzzy legs use the fuzzy index. ```python response = await client.query_namespace("support-tickets", { "rank_by": ["content", "HybridText", "conection timout kubernets"], "top_k": 10, "filters": ["tenant", "Eq", "t-42"], "include_attributes": ["content", "title"], }) ``` ```go response, err := client.QueryNamespace(ctx, "support-tickets", &hevlayer.QueryRequest{ RankBy: []any{"content", "HybridText", "conection timout kubernets"}, TopK: 10, Filters: []any{"tenant", "Eq", "t-42"}, IncludeAttributes: []string{"content", "title"}, }) ``` ```typescript const response = await client.queryNamespace("support-tickets", { rank_by: ["content", "HybridText", "conection timout kubernets"], top_k: 10, filters: ["tenant", "Eq", "t-42"], include_attributes: ["content", "title"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/support-tickets/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["content", "HybridText", "conection timout kubernets"], "top_k": 10, "filters": ["tenant", "Eq", "t-42"], "include_attributes": ["content", "title"] }' ``` An optional fourth tuple element tunes the expansion. Defaults: ```json ["content", "HybridText", "conection timout kubernets", { "fuzziness": "auto", "stopwords": "en", "rank_constant": 60, "per_leg_limit": null }] ``` | Option | Default | Meaning | | --- | --- | --- | | `fuzziness` | `"auto"` | Edit-distance tolerance for the fuzzy legs, keyed to each token's length (turbopuffer requires at least 3 query characters per edit). `"auto"` permits up to distance 2: exact for tokens of 3–5 characters, distance 1 for 6–8, distance 2 for 9 or more. Fixed `0`, `1`, or `2` caps the ladder, so `0` is exact-only. | | `stopwords` | `"en"` | Tokens that do not get a fuzzy leg. `"en"` is the built-in English list, `false` gives every token a fuzzy leg, and an array of strings replaces the built-in list (entries are lowercased). The BM25 leg always ranks the full input and is unaffected. | | `rank_constant` | `60` | turbopuffer's RRF constant, passed through verbatim. Integer > 0. | | `per_leg_limit` | `clamp(5 × top_k, 50, 200)` | How deep each leg retrieves before fusion. Integer > 0. | | `threads` | `8` unless configured per namespace | Maximum concurrent upstream requests when the gateway scatter/gathers the expansion across a [sharded](/docs/ce/concepts#scattergather) namespace — the same fan-out control as [scans](/docs/ce/api/scans). Clamped to active shards. No effect on unsharded namespaces, where the expansion is a single fused upstream call. | Set top-level `include_leg_breakdown: true` to return per-row `$fused.legs` attribution. Each leg entry reports the leg label, that row's 1-based rank within the leg, and the leg's raw score or distance. `rank` and `score` are `null` when the row fell outside that leg's `per_leg_limit` cut. Labels are `bm25`, `fuzzy:`, and `semantic` on routed fused queries. ### Tokenization The input string becomes tokens under a fixed, documented policy: 1. Split on Unicode (UAX #29) word boundaries and lowercase, using `alyze` — the code behind turbopuffer's production `word_v4` tokenizer. Punctuation-only tokens never survive the split. 2. Drop tokens shorter than 2 characters. 3. Dedupe. 4. Remove stop words under the `stopwords` option (English by default). Removed tokens are reported in `stopwords_dropped` and do not count against the cap, so on long queries the cap spends its legs on content tokens. 5. Cap at 15 tokens (15 fuzzy legs + 1 BM25 leg = 16, the upstream subquery limit). Tokens cut by the cap are counted in `tokens_dropped`. Stop words only suppress fuzzy legs: the BM25 leg ranks the full input string. Stemming and language detection are not applied. The input must yield at least one token before stop-word removal. An input made only of stop words is valid: it has no fuzzy legs and ranks on the BM25 leg alone. ### Response Results are the RRF-fused list. A `hybrid` block echoes the effective expansion so defaults are never invisible: ```json { "rows": [ { "id": "ticket-4117", "$score": 0.0639, "content": "...", "title": "Connection timeout on Kubernetes ingress" } ], "hybrid": { "tokens": ["conection", "timout", "kubernets"], "tokens_dropped": 0, "stopwords": "en", "stopwords_dropped": [], "fuzziness": "auto", "rank_constant": 60, "legs": 4, "per_leg_limit": 50 }, "next_cursor": null } ``` | Field | Meaning | | --- | --- | | `$score` | RRF score. Comparable **within** a response, not across requests — do not threshold on it. | | `$fused.legs` | Present only when `include_leg_breakdown: true`. Per-leg attribution in effective leg order; each item has `leg`, `rank`, and `score`. | | `tokens` | Tokens that produced fuzzy legs, post-policy. | | `tokens_dropped` | Tokens removed by the 15-token cap (not by the length or punctuation rules, or as stop words). | | `stopwords` | The effective `stopwords` option: `"en"`, `false`, or the caller's list. | | `stopwords_dropped` | Tokens removed as stop words, in input order. Empty when `stopwords` is `false`. | | `legs` | Total effective subqueries in the fused expansion. Normally the fuzzy legs + 1 BM25 leg (plus 1 ANN leg on routed fused queries). On the `surfaced` fallback there is no BM25 leg, so `legs` equals the token count (one fuzzy leg per token). | | `surfaced` | Present and `true` only when the empty-result fallback fired (see [Surfacing fallback](#surfacing-fallback)). Absent on the normal path. | | `next_cursor` | Top-level field (not inside `hybrid`), always present in the body: the next page token, or `null` on the last page. Mirrors the `x-layer-next-cursor` header. Pass a non-null value back as `cursor`. | Per-leg attribution with `include_leg_breakdown`. The `hybrid` block appears only on `HybridText` responses. On sharded namespaces it also reports the effective `threads` fan-out width. Requests without a `HybridText` expression, including native turbopuffer multi-query + `rerank_by` bodies, keep their upstream-shaped responses byte-for-byte. ### Surfacing fallback Every primary leg ranks by BM25 over the full input, which upstream scores at zero — and drops — when no token matches a stored term exactly. A fully-misspelled query therefore fuses to zero rows. When the primary expansion returns nothing, Layer re-runs one fuzzy leg per token, reorders each leg by field/token edit distance, and fuses those instead, so a typo-heavy query still surfaces near matches. The response then carries `"surfaced": true` in the `hybrid` block, and `legs` reflects the surfacing expansion — one fuzzy leg per token, with no BM25 leg. Working queries never reach this path; the fallback is purely additive and absent (`surfaced` omitted) on the normal path. ### Semantics - **Fusion.** RRF uses the effective leg order: BM25 first, then one fuzzy leg per token, then the semantic ANN leg on routed fused queries. `include_leg_breakdown: true` can require one upstream query per leg on unsharded namespaces so Layer can report per-leg ranks. - **One consistency cut.** Request-level `filters` are replicated to every leg, and the [stable-read](#stable-reads) watermark predicate is injected into every leg from a single read — all legs see the same cut. Responses carry `x-layer-stable-as-of` as usual. - **All-or-nothing.** Any leg failure fails the request; Layer does not return a partial fusion over surviving legs. ### Validation All return `422`: | Condition | Why | | --- | --- | | Input yields zero tokens under the policy before stop-word removal | Nothing to expand. An input made only of stop words is not rejected. | | `HybridText` inside a `queries` array | The expansion is already one batch deep by construction. | | `fuzziness` not in `"auto" \| 0 \| 1 \| 2`; `stopwords` not `"en"`, `false`, or an array of strings; `rank_constant` ≤ 0; `per_leg_limit` ≤ 0; `threads` < 1 | Out of range. | To let the gateway pick between hybrid text and semantic retrieval per query, see [query routing](#query-routing). ## Query routing The `Auto` rank expression. Real search boxes receive both `"timout"` and `"why do pods lose their connection during deploys"`. The first wants [hybrid text fusion](#hybrid-text-fusion); the second wants semantic retrieval — lexical legs add noise on long conversational input, and ANN underperforms on short identifier-shaped tokens. `Auto` is a Layer-only `rank_by` spelling that makes that call per query, so the branch doesn't live ad hoc in your application code. The route is chosen from the shape of the input alone. Supply an inline `Embed` as the vector source and Layer resolves it only after the policy selects `semantic` or `fused`; a `hybrid_text` route never calls the embedding provider. Without either an inline `Embed` or a numeric vector, a vector-needing route returns the routing decision instead of results so the application can embed and re-issue. Short keyword traffic executes immediately and never pays for an embedding. ```python response = await client.query_namespace("support-tickets", { "rank_by": ["title", "Auto", user_input, { "vector": ["Embed", user_input, {"field": "content"}], }], "top_k": 10, "filters": ["tenant", "Eq", "t-42"], }) ``` ```go response, err := client.QueryNamespace(ctx, "support-tickets", &hevlayer.QueryRequest{ RankBy: []any{"title", "Auto", userInput, map[string]any{ "vector": []any{"Embed", userInput, map[string]any{"field": "content"}}, }}, TopK: 10, Filters: []any{"tenant", "Eq", "t-42"}, }) ``` ```typescript const response = await client.queryNamespace("support-tickets", { rank_by: ["title", "Auto", userInput, { vector: ["Embed", userInput, { field: "content" }], }], top_k: 10, filters: ["tenant", "Eq", "t-42"], }); ``` ```bash # One request: title is lexical; content selects the embedding profile. curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/support-tickets/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["title", "Auto", "why do pods lose their connection during deploys", { "vector": ["Embed", "why do pods lose their connection during deploys", { "field": "content" }] }], "top_k": 10, "filters": ["tenant", "Eq", "t-42"] }' ``` ### Routing policy The v1 policy reads the token count of the input under the same [tokenizer policy](#tokenization) as hybrid text fusion: | Tokens | Route | Runs | | --- | --- | --- | | ≤ 2 | `hybrid_text` | The [hybrid text fusion](#hybrid-text-fusion) expansion. | | ≥ 8 | `semantic` | ANN over the supplied query vector. | | 3 – 7 | `fused` | Both, merged upstream by RRF. | Vector availability never changes which route is chosen — only whether it executes in this request. `hybrid_text` always executes and does not resolve an inline `Embed`; `semantic` and `fused` resolve it after routing, execute with a numeric `vector`, and defer only when neither is present. The policy is versioned (`"policy": "v1"`) in the response. ### Options The optional fourth tuple element: | Option | Default | Meaning | | --- | --- | --- | | `route` | `"auto"` | Force `"hybrid_text"`, `"semantic"`, or `"fused"` instead of applying the policy. Used on re-issue after a deferral, and for A/B comparison of strategies on the same input. | | `vector` | — | A numeric query vector, or `["Embed", input, {field?, model?}?]`. `field` selects the source or derived attribute whose embedding profile supplies the model and vector target; it defaults to the Auto lexical field. Layer resolves `Embed` only for `semantic` and `fused`. A derived `embed_` field requires `model`. | | `fuzziness` | `"auto"` | Forwarded to the `HybridText` expansion on the `hybrid_text` and `fused` routes: `"auto"`, `0`, `1`, or `2`. `0` forces exact-only matching. No effect on the `semantic` route. | | `stopwords` | `"en"` | Forwarded to the `HybridText` expansion on the `hybrid_text` and `fused` routes: `"en"`, `false`, or an array of strings. Does not change the token count the routing policy reads. | When the chosen route expands hybrid-text legs, the hybrid defaults apply and the [`hybrid` echo block](#response) appears alongside `routing`. Set top-level `include_leg_breakdown: true` to add `$fused.legs` to each fused row; the fused route includes a final `semantic` leg after the BM25 and fuzzy-token legs. ### Response Every `Auto` response carries a `routing` block: ```json { "rows": [{"id": "ticket-4117", "$score": 0.0639, "title": "..."}], "routing": { "route": "hybrid_text", "policy": "v1", "tokens": 1, "executed": true }, "hybrid": {"tokens": ["timout"], "tokens_dropped": 0, "stopwords": "en", "stopwords_dropped": [], "fuzziness": "auto", "rank_constant": 60, "legs": 2, "per_leg_limit": 50} } ``` | Field | Meaning | | --- | --- | | `route` | The strategy chosen (or forced). | | `policy` | Routing policy version that made the decision. `"forced"` when `route` was supplied. | | `tokens` | Token count the policy read, post tokenizer policy. | | `executed` | `false` on a deferral: the route needs a vector the request didn't supply. `rows` is empty; embed and re-issue with the route forced. | Routed queries follow the same semantics as their underlying strategy: one consistency cut across all legs and all-or-nothing leg failure. When an inline `Embed` is resolved, its `embedding_tokens` and `embedding_ms` measurements are merged into the normal top-level `performance` object. ### Validation All return `422`: | Condition | Why | | --- | --- | | Forced `"semantic"` or `"fused"` without `vector` | Forcing asserts you have the vector; only auto-routing defers. | | Input yields zero tokens under the policy | Nothing to route. | | `vector` dimensionality mismatch | Same check as a plain vector query. | | `Auto` inside a `queries` array | Inherited from [hybrid text fusion](#validation). | ## Collapse Add `collapse` to a ranked query to get one result per group instead of one per row. This is helpful when, for example, your index is built out per page but your users are searching for a specific document. ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/bcc-pages/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "BM25", "kitchen remodel"], "top_k": 10, "collapse": {"by": "document"}, "include_attributes": ["name", "page", "job", "webUrl"] }' ``` ```python response = await client.query_namespace("bcc-pages", { "rank_by": ["text", "BM25", "kitchen remodel"], "top_k": 10, "collapse": {"by": "document"}, "include_attributes": ["name", "page", "job", "webUrl"], }) for group in response["groups"]: print(group["key"], [row["page"] for row in group["rows"]]) ``` ```typescript const response = await client.queryNamespace("bcc-pages", { rank_by: ["text", "BM25", "kitchen remodel"], top_k: 10, collapse: { by: "document" }, include_attributes: ["name", "page", "job", "webUrl"], }); ``` `top_k` still sets how many results come back; with `collapse`, each result is a group. To find them, the gateway reads `top_k × expansion` ranked rows, groups them, and returns the best `top_k` groups. This request reads 100 pages and returns up to ten documents. ### Collapse options | Field | Default | Meaning | | --- | --- | --- | | `by` | required | The attribute to group on, or `"document"`. A list such as `["job", "folder"]` groups each row on the first attribute it has a value for. Up to four. | | `expansion` | `10` | How many rows to read per result: the gateway reads `top_k × expansion` rows, up to 10,000. Raise it when one group can fill the top of the ranking, like a 400-page spec that matches every query about it. | | `missing` | `"own"` | Rows with no value for `by`. `"own"` makes each its own group, `"together"` puts them in one group with key `null`, `"drop"` leaves them out. Null, absent and empty-string values count as missing. Pass `{"mode": "own", "values": ["No job"]}` to also treat listed values as missing. | `expansion` is a tunable candidate-window multiplier, with a default of 10. Higher values read more rows and can improve group diversity when the top rows are dominated by one document, job or folder. Store work, response size and latency can grow with expansion; lower it to reduce that cost. The default is not a claim of an optimal setting for every workload. The read window is capped at 10,000 rows, so increasing expansion past that cap has no further effect. `"document"` means the row's source document. Rows written by a Layer pipeline (layer-extract pages, Function outputs) already carry it in `_hevlayer_parent_id`. Any other namespace names its document attribute in the schema: ```json { "schema": { "doc_id": { "type": "string", "filterable": true, "document": true } } } ``` `"document"` on a namespace with neither returns `400`. A schema attribute marked `document: true` wins over `_hevlayer_parent_id`. A list tries each attribute in turn. `["job", "folder"]` groups by job, and a row without a job is grouped with the other rows from its folder. The group's `by` says which attribute it was formed on. Some data stores a placeholder instead of leaving the value out, such as the string `"No job"`. List it in `missing.values` so the row falls through to the next attribute: ```json { "by": ["job", "folder"], "missing": { "values": ["No job"] } } ``` `missing.mode` defaults to `"own"`. A row that has none of the listed attributes follows the mode. ### Collapsed response ```json { "rows": [ { "id": "doc-41#p7", "$dist": 12.4, "name": "Kitchen spec.pdf", "page": 7 }, { "id": "doc-18#p1", "$dist": 11.9, "name": "Change order 3.pdf", "page": 1 } ], "groups": [ { "key": "doc-41", "by": "document", "rows": [ { "id": "doc-41#p7", "$dist": 12.4, "page": 7 }, { "id": "doc-41#p2", "$dist": 9.8, "page": 2 }, { "id": "doc-41#p9", "$dist": 7.1, "page": 9 } ] }, { "key": "doc-18", "by": "document", "rows": [{ "id": "doc-18#p1", "$dist": 11.9, "page": 1 }] } ], "collapse": { "read": 100, "exhausted": false } } ``` | Field | Meaning | | --- | --- | | `rows` | Each group's best row, best group first. A client that ignores `groups` gets one row per group. | | `groups` | The same groups, each with every row of the group among the rows read, best first. Groups are ordered by their best row, in the order the store ranked it. | | `groups[].key` | The value the group shares, or `null` for the `missing: "together"` group. | | `collapse.read` | Rows the gateway read: `top_k × expansion`, or fewer if the query ran out. | | `collapse.exhausted` | `true` when the store returned fewer rows than requested, so no more groups exist. | A group's `rows` covers only the rows read, so it shows which pages matched best, not every page in the document that matches. Fewer than `top_k` groups can come back when the candidate window contains too few distinct groups, even if more rows exist beyond that window. Increase `expansion` to look further down the ranking. ### Where it runs The gateway groups on every store. Neither turbopuffer nor Postgres can rank whole groups natively (turbopuffer's `group_by` exists only beside `aggregate_by`), so both receive an ordinary query for `top_k × expansion` rows and the response is identical. A schema `document: true` is stored differently per store. On a Postgres-backed namespace, `document: true` is stored in the namespace schema and returned by namespace metadata. On turbopuffer the gateway removes the key before the write, keeps it in object storage, and adds it back when you read the schema, so object storage must be configured. At most one attribute may be `document: true`, and it must be a string. ### Collapse: not yet - **Exact match counts per group.** A group lists the matching rows that were read, not all of them. Exact counts take a second aggregation per query. - **Collapsing a browse.** An empty-query browse would need a distinct listing, which is closer to an aggregation than to ranking. - **Paging.** `cursor` returns `400` with `collapse`. Raise `top_k` or `expansion` instead. - **Multi-query bodies.** `queries` returns `400` with `collapse`. - **Across namespaces.** [Federated queries](/docs/ce/api/federated-query) don't collapse. ## Counting matches To count how many rows match a full-text or vector query, use [scan](/docs/ce/api/scans) count mode with the `fts` or `ann` selector. Ranked counts share the single `/scans` endpoint with filter counts — `fts` is exact, `ann` is a radius scan flagged `approximate`, and both honor the `exhaustive` flag and the count deadline. ## Fetch `GET /v2/namespaces/{ns}/documents/{id}` fetches a row by ID. `POST /v2/namespaces/{ns}/documents` fetches a batch with a body such as `{"ids":["earbuds","speaker"],"include_attributes":["title"]}`. The standalone gateway reads through to the selected store. A missing single row returns 404; a batch response separates `documents` from `missing` IDs. These routes do not require a managed document cache in the Compose bundle. # Record versions Source: https://hevlayer.com/docs/ce/api/record-versions import Edition from "../../../components/docs/Edition.astro"; Record versioning is opt-in on **pgvector** namespaces. Each logical record keeps its usual string `id` (numeric IDs are not supported in this slice). Each accepted full upsert creates an immutable revision with a unique `version_id`. One revision is active at a time. Search, scans and normal reads use only the active record; inactive revisions never enter the candidate set. Other stores return `422` for this configuration and these endpoints. Enable versioning on an empty namespace with a write containing: ```json {"_hevlayer":{"versioning":{"enabled":true,"activation":"explicit"}}} ``` The setting persists across restarts. `automatic` activates every accepted write; `explicit` retains the revision without publishing it. Once enabled, versioning cannot be disabled or its mode changed. Omit the configuration on later writes. Namespaces without versioning keep their existing write and search behavior. ## Stage, publish and restore Use `POST /v2/namespaces/{namespace}` for full row or column upserts. The response includes `versions`, with logical `id`, immutable `version_id` and `active`. Supply `_hevlayer.version_id` for a single-row upsert when retries must return the same revision. Reusing that ID with different content or a different logical ID returns `409`. A successful retry never reactivates a superseded revision. Without a supplied ID, each retry creates another revision. ```sh NS=scratch-lyr319-example BASE="${LAYER_URL}/v2/namespaces/${NS}" curl --fail-with-body "$BASE" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"_hevlayer":{"versioning":{"enabled":true,"activation":"explicit"},"version_id":"v1"},"upsert_rows":[{"id":"guide","text":"First edition"}]}' curl --fail-with-body "$BASE/records/guide/activate" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"version_id":"v1","expected_current_version":null}' curl --fail-with-body "$BASE" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"_hevlayer":{"version_id":"v2"},"upsert_rows":[{"id":"guide","text":"Second edition"}]}' # Normal queries still return First edition. curl --fail-with-body "$BASE/query" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"rank_by":["id","asc"],"top_k":10,"include_attributes":true}' curl --fail-with-body "$BASE/records/guide/activate" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"version_id":"v2","expected_current_version":"v1"}' # Restore v1 without changing either immutable revision. curl --fail-with-body "$BASE/records/guide/activate" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"version_id":"v1","expected_current_version":"v2"}' for SUFFIX in versions versions/v1 versions/v2 activations; do curl --fail-with-body "$BASE/records/guide/$SUFFIX" -H "Authorization: Bearer $LAYER_API_KEY" done curl --fail-with-body -X DELETE "$BASE" -H "Authorization: Bearer $LAYER_API_KEY" ``` `expected_current_version` is required on activation. Use `null` for no active revision. A stale expectation returns `409` and changes nothing. Repeating an activation whose target is already active succeeds without adding an event when the expectation matches. Activation and its separate activation event commit in one transaction with the searchable row. SQL indexing errors roll back the transaction, leaving the previous revision searchable. Pgvector indexing is synchronous: there is no pending revision exposed before a successful commit. Automatic writes use this same transaction; explicit activation works in both modes. Restoring is an activation, not a new content revision. ## History, retention and deletion `GET /v2/namespaces/{namespace}/records/{id}/versions` returns `versions` and `active_version_id`; each entry includes `id`, `version_id`, `row`, and `created_at`. `GET .../versions/{version_id}` retrieves one immutable revision. `GET .../activations` returns the separate ordered `activations` log with `previous_version_id`, `version_id`, `sequence`, and `created_at`. List endpoints accept `after` (exclusive version ID for versions, integer sequence for activations) and `limit` (1–100, default 100), returning `next_cursor` when more results exist. Version IDs sort lexicographically; activation sequences increase. Full upserts and named `deletes` are supported. A delete clears the active pointer and removes the searchable row, logging a null target; retained revisions remain available for restoration. Patches, conditional writes, filtered writes and branch/copy operations are refused on versioned namespaces. Do not write directly to the backing database. Enable versioning before adding records; adopting existing unversioned rows is refused. Revisions and activation events are retained indefinitely. There is no TTL or per-version deletion in this slice. Deleting the namespace removes its current rows, revisions, activation log and versioning configuration together. Use namespace deletion for complete erasure. A failed transaction retains neither a new revision nor an activation event. Embeddings computed by the gateway are part of the retained row; restoring a revision does not rerun an embedding model. # Response Headers Source: https://hevlayer.com/docs/ce/api/response-headers import Edition from "../../../components/docs/Edition.astro"; Layer keeps turbopuffer-compatible read bodies in the upstream shape and returns Layer-specific metadata in response headers. | Header | Values | Returned by | | --- | --- | --- | | `x-layer-stable-as-of` | Epoch milliseconds | Query, multi-query, scan counts | | `x-layer-next-cursor` | Opaque cursor token | Single-query pagination | | `x-layer-warning` | `vector_attribute_dropped` | Query, fetch | | `traceparent` | W3C trace context | Query, multi-query | The watermark header can be absent before a stable observation exists. SDKs expose these headers as fields where that is more convenient. For example, `query_namespace` returns upstream `rows` and also sets `stable_as_of` / `next_cursor` on the parsed response object when the headers are present. # Scan Source: https://hevlayer.com/docs/ce/api/scans import PostgresWarning from "../../../components/docs/PostgresWarning.astro"; import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; ## Complete projected source rows `POST /v2/namespaces/{ns}/scans/rows` returns a bounded complete observed source as projected rows. It requires read authorization for the same namespace and store as query. No job, cache warm, cursor continuation, or snapshot is created. The request is: ```json {"include_attributes":["session_id","end","cost"],"scan":{"max_rows":1000000,"max_bytes":268435456,"consistency":"observed"}} ``` The response contains `rows` and `source` counters (`rows`, `pages`, `bytes`, `complete: true`, `consistency: "observed"`, `threads`). Physical IDs are always included. Rows are returned in physical ID order after complete acquisition. The gateway uses the shared namespace shard fanout and scan width resolver: ordinary default 8, capped by active shard count and namespace limits. An unready or unsharded namespace uses one complete cursor including tagged and untagged rows. With active sharding a disjoint residual cursor also reads null, missing and out-of-range shard tags; cached readiness alone cannot prove source coverage. It shares the same fanout width and budgets. A foreign-namespace manifest is rejected. Topology auth, throttle, transport and malformed-data errors fail the request. Only absent metadata or an unconfigured object store allows compatibility resolution; no readiness drain scan or cache promotion is needed for this route. The reserved namespace marker is excluded locally without string-ID predicates, including on uint stores. Zero or more than 4,096 advertised shards use one complete unpartitioned cursor to bound topology metadata expansion. Only explicitly supported projected source adapters may serve this route; others return structured 422 `UnsupportedByStore` / `projected_source_rows` before source reads. Projection errors never retry with full attributes. Rows and decoded source response bytes share one request budget across shards; all provider calls retain query admission. Any source, budget, identity or cursor failure rejects the whole response. Disconnect or deadline drops pending work; already accepted provider requests can finish remotely. No partial result is returned. Source counters exclude readiness/metadata calls and HTTP framing. This is parallel projected source acquisition, not backend-native aggregates or compact source reduction. Every revision is transferred to the gateway and the projected result to the caller. Consumers must choose latest before mutable filters and retain their existing complete folds and joins. Concurrent writes can cross observed pages; completeness does not imply a transactional snapshot. Live scans select matching IDs, count rows, or aggregate distinct field values against the configured store. Choose `source: "origin"` for standalone scans. Backend support is listed in the [store matrix](/docs/ce/stores). ## Routes Create a job with `POST /v2/namespaces/{ns}/scans`. Poll it through `GET /v2/namespaces/{ns}/scans/{id}` and read its result pages through `GET /v2/namespaces/{ns}/scans/{id}/results`. Count mode responds synchronously. ID and values job state is in-memory and resets on gateway restart. ## ID mode ```json {"mode":"ids","source":"origin","filters":["category","Eq","audio"],"page_size":1000} ``` ## Count mode ```json {"mode":"count","source":"origin","filters":["category","Eq","audio"]} ``` ## Values mode Native facet aggregation. A values scan reads every row the filter admits. ```json {"mode":"values","source":"origin","field":"category","page_size":1000} ``` Values scans return distinct `v` values and their `n` counts. The result is bounded to 1,000,000 distinct values; `truncated: true` indicates that only the most frequent values were retained, each with its exact count. ## Full-text count Count rows matching a BM25 query with the `fts` selector. Full-text counts are exact and always run origin scatter/gather, so `source` must be omitted, `auto`, or `origin`. A `filters` array, when present, is ANDed on as an extra constraint. ```python count = await client.create_scan("products", { "mode": "count", "fts": {"field": "title", "query": "wireless headphones"}, "filters": ["category", "Eq", "Electronics"], "exhaustive": True, }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", Fts: hevlayer.FtsScan{Field: "title", Query: "wireless headphones"}, Filters: []interface{}{"category", "Eq", "Electronics"}, Exhaustive: true, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", fts: { field: "title", query: "wireless headphones" }, filters: ["category", "Eq", "Electronics"], exhaustive: true, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "fts": {"field": "title", "query": "wireless headphones"}, "filters": ["category", "Eq", "Electronics"], "exhaustive": true }' ``` ## Hybrid text count Count rows in the keyword/fuzzy neighborhood of a `HybridText` query with the `hybrid_text` selector. The scan tokenizes `query` with the HybridText policy, then evaluates the BM25 leg, one fuzzy leg per token, **and** one surfacing leg per token (the RFC 0057 empty-result fallback's legs), and counts the de-duplicated union of returned row ids. This count is a **superset** of the `hybrid_text` query route's deduped rows: the scan always includes the surfacing legs, whereas the query route only adds them when its primary legs (BM25 + fuzzy) return nothing. On a partial-typo query whose primary legs do match, the scan can therefore count more rows than the route returns. Use this selector for a generous live count next to `hybrid_text` or `auto` results that routed to `hybrid_text`; plain `fts` counts exact BM25 only. ```python count = await client.create_scan("products", { "mode": "count", "hybrid_text": {"field": "title", "query": "wireles headphones"}, "filters": ["category", "Eq", "Electronics"], }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", HybridText: hevlayer.HybridTextScan{Field: "title", Query: "wireles headphones"}, Filters: []interface{}{"category", "Eq", "Electronics"}, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", hybrid_text: { field: "title", query: "wireles headphones" }, filters: ["category", "Eq", "Electronics"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "hybrid_text": {"field": "title", "query": "wireles headphones"}, "filters": ["category", "Eq", "Electronics"] }' ``` ## Radius count Count rows within `radius` of a query vector with the `ann` selector — a distance-ball scan. `radius` is required and finite (without an upper bound every row is in the ball); `field` defaults to `vector`. Like `fts`, radius counts always run origin scatter/gather. The count is **approximate**: ANN recall means the index's membership of the ball may differ from the true set, independent of saturation, so the response carries `approximate: true`. The radius bound is applied by the gateway to the `$dist` returned by the ranked query. It is not sent upstream as a filter. ```python count = await client.create_scan("products", { "mode": "count", "ann": {"field": "vector", "vector": [0.12, -0.3, 0.88], "radius": 0.25}, }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", Ann: hevlayer.AnnScan{Field: "vector", Vector: []float64{0.12, -0.3, 0.88}, Radius: 0.25}, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", ann: { field: "vector", vector: [0.12, -0.3, 0.88], radius: 0.25 }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "ann": {"field": "vector", "vector": [0.12, -0.3, 0.88], "radius": 0.25} }' ``` ```json { "count": 980, "served_by": "origin", "approximate": true, "bounded": false, "timed_out": false, "shards_saturated": 0, "shards_total": 1, "threads": 1, "elapsed_ms": 51 } ``` ### Bounding ranked scans Ranked selectors fan out one turbopuffer query per shard, each capped at `top_k = 10_000`. `threads` bounds fan-out width: how many shard requests can run at once. `exhaustive` and `timeout_seconds` bound depth: what happens when a shard hits that cap and how long recursion can run. - `exhaustive: false` (default) — one scatter/gather. A saturated shard contributes its cap as a lower bound; the response carries `bounded: true` with `shards_saturated > 0`. - `exhaustive: true` — for BM25, recurse on each saturated shard via score-band pagination (`$score < last` with an `id` tiebreak) until every page is short or `timeout_seconds` elapses. ANN radius scans do not push `$dist` filters upstream; the gateway counts returned rows whose `$dist <= radius` and marks the shard exhausted when the first over-radius row appears. If the full page is still inside the radius, the shard remains `bounded`. The same `threads` value applies to the initial round and every exhaustive round over the remaining saturated shards. `bounded` and `approximate` are independent. `bounded` means a shard saturated and the count is a `>=` lower bound for the rows the index returned; `approximate` means the distance ball's membership is itself fuzzy. An `ann` count can be `bounded: false` yet still `approximate: true`. ## Sources Use origin for live store reads. Managed-cache and precomputed snapshot serving are documented in the full view. Origin filters use the selected store's supported filter grammar. ## Pinning and fan-out Query scatter/gather and the `threads` fan-out controls below. For Turbopuffer origin scans, namespaces observed with `pinning.status.ready_replicas > 0` default to 32 concurrent upstream requests, capped at 512. Other namespaces default to 8, capped at 32. Missing, failed, or older-than-two-minute observations use the unpinned limits. A request's `threads` remains bounded by the applicable maximum and active shard count. These controls do not enable pinning. # Search Source: https://hevlayer.com/docs/ce/api/search import Edition from "../../../components/docs/Edition.astro"; import Preview from "../../../components/docs/Preview.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; `POST /v2/namespaces/{namespace}/search` takes a query string and returns ranked, scored rows. In one request, inside one namespace, the gateway: 1. **Embeds** `query` from the schema's `embed:` attribute, through that attribute's [serving preference](/docs/ce/api/embed). 2. **Expands text legs** over every `full_text_search` attribute: one BM25 leg per attribute, then fuzzy legs per token, under a 16-leg budget. 3. **Adds the ANN leg** over the derived vector column. 4. **Scatters** every leg across the namespace's shards under one stable-read cut, with `filters` replicated to every leg. 5. **Fuses** with reciprocal rank fusion (`rank_constant` 60), deduplicating by id. 6. **Cuts** the fused list to `pool` candidates (the L1 stage). 7. **Reranks** the pool with [Jev](https://docs.typesafe.ai): one yes/no relevance question per document, a calibrated probability back. 8. **Prunes** below `rerank.threshold` and returns `top_k` rows with the probability as `score`. [`/query`](/docs/ce/api/query) is unchanged and stays the turbopuffer-shaped wire. `/search` is Layer-native: text in, scored rows out. The namespace must declare an `embed:` attribute and at least one `full_text_search` attribute, and the gateway must hold a reranker key (see [Provider key](#provider-key)). ```python response = await client.search_namespace("papers", { "query": "does vitamin D improve bone density in older adults", "top_k": 10, }) ``` ```go response, err := client.SearchNamespace(ctx, "papers", &hevlayer.SearchRequest{ Query: "does vitamin D improve bone density in older adults", TopK: 10, }) ``` ```typescript const response = await client.searchNamespace("papers", { query: "does vitamin D improve bone density in older adults", top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/papers/search" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "does vitamin D improve bone density in older adults", "top_k": 10}' ``` ## Request Only `query` is required. Unknown fields are rejected with `422`. ```json { "query": "does vitamin D improve bone density in older adults", "top_k": 10, "filters": ["year", "Gte", 2015], "include_attributes": ["title", "text", "year"], "pool": 50, "embed": { "attribute": "text" }, "text": { "fuzziness": "auto", "stopwords": "en" }, "rerank": { "provider": "jev", "threshold": 0.0, "attributes": ["title", "text"], "docs_per_call": 30, "max_chars": 2000, "question": "generic-1", "required": false }, "explain": false } ``` | Field | Default | Meaning | | --- | --- | --- | | `query` | required | The search text. Embedded for the ANN leg, ranked as-is by the BM25 legs, tokenized for the fuzzy legs. | | `top_k` | `10` | Rows returned. `1..100`, and at most `pool`. | | `filters` | none | Filter expression in the [query](/docs/ce/api/query) grammar. A hard filter, replicated to every leg. | | `include_attributes` | `true` | `true`, `false`, or a list of attribute names to return under each row's `attributes`. Reserved `_hevlayer_*` attributes are returned only when named; vector columns are never returned. | | `pool` | `50` | Candidates handed to the reranker after the L1 cut. `50..200`. Also sets per-leg depth: `clamp(5 × pool, 50, 200)`. | | `embed.attribute` | the only one | Which `embed:` attribute to embed `query` with. Required when the schema declares several. | | `text.fuzziness` | `"auto"` | `"auto"` or `0..2`, as in [`HybridText`](/docs/ce/api/query#hybrid-text-fusion). | | `text.stopwords` | `"en"` | `"en"`, `false`, or a list. Suppresses fuzzy legs for stop words; BM25 legs are unaffected. | | `rerank` | on | `false` disables the stage. Otherwise an object; every key is optional. | | `rerank.provider` | `"jev"` | The only provider today. | | `rerank.threshold` | `0.0` | Drop rows whose probability is below this, before `top_k`. `0.1` is the recommended prune: on BEIR shortlists rows under `0.1` were judged relevant about 0.5% of the time. | | `rerank.attributes` | every full-text attribute | Attributes whose text the reranker reads. An attribute named `title` goes first, then schema order. `_hevlayer_*` names are rejected. | | `rerank.docs_per_call` | `30` | Documents per provider call, `1..50`. Calls run concurrently. | | `rerank.max_chars` | `2000` | Per-document character budget for reranker text, `1..20000`. | | `rerank.question` | `"generic-1"` | Named question version. `generic-1` is the only one. | | `rerank.required` | `false` | `true` turns a provider failure into `503` instead of a degrade. | | `explain` | `false` | `true` adds the L1 feature vector to every row. | | `collapse` | none | `{by, expansion?, missing?}`: one result per group, such as per document. See [Collapse](#collapse). | There is no pagination: a reranked page does not compose with a fused cursor. Ask for a larger `top_k`, up to `pool`. ## Response ```json { "rows": [ { "id": "PMC5461234", "score": 0.93, "attributes": { "title": "...", "text": "...", "year": 2019 } } ], "routing": { "route": "fused", "policy": "v1", "tokens": 10, "executed": true, "advisory": true }, "plan": { "executed": false, "reason": "unconfigured" }, "hybrid": { "tokens": ["vitamin", "improve", "bone", "density", "older", "adults"], "tokens_dropped": 0, "stopwords": "en", "stopwords_dropped": ["does", "in"], "fuzziness": "auto", "rank_constant": 60, "per_leg_limit": 200, "legs": [ { "label": "ann", "kind": "ann", "attribute": "text", "rows": 200 }, { "label": "bm25:text", "kind": "bm25", "attribute": "text", "rows": 143 }, { "label": "bm25:title", "kind": "bm25", "attribute": "title", "rows": 61 }, { "label": "fuzzy:text:vitamin", "kind": "fuzzy", "attribute": "text", "rows": 88 } ], "dropped_legs": 0, "surfaced": false }, "rerank": { "provider": "jev", "model": "jev-1.13.0", "question": "generic-1", "executed": true, "pool": 50, "calls": 2, "docs_per_call": 30, "threshold": 0.0, "pruned": 0, "input_tokens": 41200, "latency_ms": 231 }, "performance": { "embedding_tokens": 17, "embedding_ms": 4, "legs_ms": 88, "fuse_ms": 1, "l1_ms": 0, "rerank_ms": 231, "total_ms": 340 } } ``` Every response carries the four echo blocks: `routing`, `plan`, `hybrid`, `rerank`. | Field | Meaning | | --- | --- | | `rows[].score` | The reranker's probability in `[0, 1]` when `rerank.executed` is `true`. It is absolute: comparable across rows, legs, shards and requests, so you can threshold on it. When the stage did not execute, `score` is the RRF sum and is only an ordering. The two are never mixed in one response. | | `rows[].attributes` | The attributes `include_attributes` asked for. Text the gateway fetched only for the reranker is stripped. | | `routing` | The [query router](/docs/ce/api/query#query-routing)'s decision for this input. `advisory: true`: `/search` runs every leg regardless, and reports the route for the UI and the history. | | `plan` | The planner stage. Not configured in this release: always `{"executed": false, "reason": "unconfigured"}`. | | `hybrid.legs` | One entry per leg that ran, in fusion order: `label`, `kind` (`ann`, `bm25`, `fuzzy`), the `attribute` it ran over, and the `rows` it returned after the shard merge. | | `hybrid.dropped_legs` | Legs the 16-leg budget, or the store, did not run. | | `hybrid.surfaced` | `true` when every text leg returned nothing and the fuzzy legs were re-run ordered by edit distance, as in the [`HybridText` fallback](/docs/ce/api/query#surfacing-fallback). | | `hybrid.threads` | Shard fan-out width. Present on sharded namespaces only. | | `hybrid.fuzziness_clamped` | Present and `true` only when a store forced the effective `fuzziness` to `0`. | | `rerank.executed` | `false` with a `reason` when the stage was disabled (`disabled`), had nothing to score (`empty_pool`), or the provider failed (`provider_error`, `timeout`, `rate_limited`), or the call never got an in-flight slot (`queue_timeout`). | | `rerank.pool` | Candidates scored. | | `rerank.calls` | Provider calls made. | | `rerank.pruned` | Rows dropped by `threshold`. | | `rerank.input_tokens` | Provider-reported input tokens across all calls. | | `performance` | Milliseconds per stage, plus the embed wire's token count. | The response also carries `traceparent` and, when a stable-read cut applied, [`x-layer-stable-as-of`](/docs/ce/api/response-headers). ### Leg budget A request runs at most 16 legs. The budget is spent in this order: 1. The ANN leg. 2. One BM25 leg per full-text attribute, over the whole `query`, in schema order. 3. Fuzzy legs from the [`HybridText` tokenizer policy](/docs/ce/api/query#tokenization), one per token, round-robin across attributes, until the budget is spent. A namespace with many full-text attributes spends its budget on BM25 breadth rather than fuzzy depth. Whatever did not fit is counted in `hybrid.dropped_legs`. ### Explain With `explain: true` each row gains an `explain` object: the feature vector the L1 stage saw and each term's contribution to the L1 score. ```json { "id": "PMC5461234", "score": 0.93, "attributes": { "title": "..." }, "explain": { "features": { "rrf_sum": 0.0481, "age_seconds": 86400 }, "contributions": { "rrf_sum": 0.0481, "age_seconds": 0.0 }, "l1_score": 0.0481, "legs": [ { "label": "ann", "rank": 3, "score": 0.18 }, { "label": "bm25:text", "rank": 1, "score": 14.2 } ] } } ``` The L1 stage in this release is the identity: it keeps RRF order and cuts to `pool`, so every contribution other than `rrf_sum` is `0`. An absent feature is absent, not `0`. | Feature | Present | | --- | --- | | `rrf_sum` | Always. | | `age_seconds` | When the row carries its write stamp. That is turbopuffer-backed namespaces; it is absent on hev search and pgvector. | | `fetch_count_30d` | Reserved. This release keeps no fetch counter, so it is never returned. | ## Collapse `collapse` returns `top_k` groups, such as documents, instead of `top_k` pages: ```json { "query": "kitchen remodel", "top_k": 10, "collapse": { "by": "document" } } ``` The reranker scores pages first, then the gateway groups the reranked pages, so a group ranks by its best page's score. `rows` keeps each group's best page; `groups` lists every reranked page of each group, best first. To have pages to group, the pool grows to `top_k × expansion` (default `expansion` is `10`), up to the 200-page ceiling. That is the same single retrieval, with a deeper pool and more pages to rerank: no query runs per group or per page. `collapse.read` is the pool size and `collapse.exhausted` is `true` when retrieval returned fewer pages than the pool, so there are no more groups to find. `by`, `expansion` and `missing` mean what they do in [collapse on a ranked query](/docs/ce/api/query#collapse). Name the grouping attribute in `include_attributes` to see it on each page. ## Provider key The reranker runs on your TypeSafe account. Set `TYPESAFE_API_KEY` on the gateway. With the stage on and no key, the request fails with `422 rerank_unconfigured`: a missing key is never a silent fused-only response. | Variable | Default | Meaning | | --- | --- | --- | | `TYPESAFE_API_KEY` | unset | Gateway-wide reranker key. | | `LAYER_RERANK_PROVIDER` | `jev` | Reranker provider. | | `LAYER_RERANK_MODEL` | `jev-latest` | Model name sent to the provider. The response echoes the model that answered. | | `LAYER_RERANK_MAX_INFLIGHT` | `6` | Provider calls in flight across the whole gateway. | | `LAYER_RERANK_TIMEOUT_MS` | `3000` | Per-call deadline, retries included. It starts once the call holds an in-flight slot. Time queued for a slot is separate, bounded by the same value, and reported as `queue_timeout`. | ## Degrade If the provider errors, times out, or keeps answering `429`, or a call waits out its queue allowance for an in-flight slot, the response is still `200`: rows in fused order, `score` as the RRF sum, `rerank.executed: false` with a `reason`, and the header `x-layer-warning: rerank_degraded`. Set `rerank.required: true` to get `503 rerank_unavailable` instead. The `503` message names the provider and its HTTP status; the provider's response body is never relayed. Calls are all-or-nothing: if one of several fails, no row keeps a probability. ## Errors | Status | `error` | When | | --- | --- | --- | | `404` | `not_found` | The namespace does not exist. | | `422` | `embed_attribute_missing` | The schema declares no `embed:` attribute. | | `422` | `embed_attribute_invalid` | `embed.attribute` names an attribute without `embed:`, or the schema declares several and none is named. | | `422` | `full_text_attribute_missing` | The schema has no `full_text_search` attribute. | | `422` | `rerank_unconfigured` | The rerank stage is on and no key is set. | | `422` | `UnsupportedByStore` | The store cannot serve an ANN or a BM25 leg. See [Stores](#stores). | | `422` | `validation_error` | `top_k > pool`, `pool` out of range, unknown provider or question, unknown field, a query that yields no tokens. | | `503` | `rerank_unavailable` | The provider failed and `rerank.required` is `true`. | ## Stores `/search` executes on turbopuffer, where reranker text is returned by the store and billed as returned bytes. A store that cannot serve an ANN or a BM25 leg answers `422 UnsupportedByStore`. On a pgvector-backed namespace `/search` runs the BM25 + dense subset: one BM25 leg per full-text attribute and no fuzzy legs. The fuzzy legs are counted in `hybrid.dropped_legs`. ## Data leaves the environment Candidate text is sent to TypeSafe for the rerank, and `query` is sent to the embedding provider when the `embed:` attribute serves natively on turbopuffer rather than locally. `rerank: false` removes the first. Reserved `_hevlayer_*` attributes are never a text leg and never reranker input. ## History and metrics Each request writes one [search history](/docs/pro/api/search-history) entry with `raw_query` taken from the body, so no `x-hevlayer-search-query` header is needed. Metrics: `hevlayer_search_total`, `hevlayer_search_stage_seconds`, `hevlayer_rerank_documents_total`, `hevlayer_rerank_pruned_total`, `hevlayer_rerank_tokens_total`, `hevlayer_rerank_degraded_total`. # Write Source: https://hevlayer.com/docs/ce/api/write import Edition from "../../../components/docs/Edition.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Writes are wire-compatible with the upstream `POST /v2/namespaces/{ns}` endpoint and forwarded as-is after validation. The Layer API includes this compatible subset and the gateway operations documented below; backend support is called out at each feature. On a Postgres-backed namespace, `[]string`, `[]int`, `[]uint`, `[]float` and `[]bool` attributes are stored as native Postgres arrays, in element order, and filterable ones are indexed for [array filters](/docs/ce/api/query#rank-expressions). An undeclared array attribute takes its type from its elements, so declare an attribute whose first write is an empty array. `[]uuid` and `[]datetime` return `422 UnsupportedByStore` with `feature: "schema.type"`. On a Postgres-backed namespace, patches and filtered writes run in the write's single transaction, in turbopuffer's order: `delete_by_filter`, then `patch_by_filter`, then upserts, `patch_rows` / `patch_columns`, and deletes. - A patch writes only the attributes it names; `null` clears one. A patch to an id that does not exist is ignored and creates no row. - A patched attribute not yet in the schema is declared by the `schema` in the same write or inferred from the value, as on an upsert. - Unlike turbopuffer, a client vector attribute can be patched; it is validated like an upserted vector. - An attribute Layer embeds on write, and its vector, cannot be patched: Layer cannot recompute the embedding from a patch, so the write is rejected with a `validation_error`. Upsert the full row instead. - `delete_by_filter` and `patch_by_filter` take the query filter syntax. Past 5,000,000 matching rows for a delete or 50,000 for a patch, the write is a `400` unless `delete_by_filter_allow_partial` / `patch_by_filter_allow_partial` is set, in which case the first rows are written and `rows_remaining` is `true`. - `rows_patched` and `rows_deleted` count the rows written; `rows_affected` is their sum with `rows_upserted`. - A write any part of which is rejected, including a `schema` change beside it, has no effects. For a nested payload that is only returned with a row, declare an explicit `{"type":"json","filterable":false}` attribute. For example: ```json { "schema": {"links": {"type": "json", "filterable": false}}, "upsert_rows": [{ "id": "job-52", "kind": "job", "project_record_id": "project-52", "links": [ {"label": "Job folder", "system": "SharePoint", "url": "https://example.test/jobs/52"}, {"label": "Accounting", "system": "QuickBooks", "url": "https://example.test/accounts/52"} ] }] } ``` Query, search and collapse preserve that JSON value when it is included in the returned attributes, including MCP structured results. JSON attributes cannot be filtered, sorted or ranked; the declaration must set `filterable` to `false`. Types are not inferred from nested objects. This is a Postgres extension; turbopuffer's schema does not accept nested JSON attributes. Keep separately searchable links as rows with `kind: "link"` and query them with both `project_record_id` and `kind` equality filters. On a Postgres-backed namespace, conditions use the query filter syntax and are evaluated against each targeted row's stored value in the write's transaction: - `upsert_condition`: an existing row is replaced only if it passes. A new id is always inserted. A `$ref_new` value reads the row being written, where a missing attribute is `null`. `["id", "Eq", null]` inserts without overwriting, and this condition keeps only newer versions: ```json ["version", "Lt", {"$ref_new": "version"}] ``` - `patch_condition`: applies to `patch_rows` and `patch_columns`. A row is patched only if it passes; `$ref_new` reads the row as patched. - `delete_condition`: a row is deleted only if it passes. `$ref_new` is `null` for every attribute, and a missing id is skipped. - `rows_affected` counts only the rows whose condition passed. - A condition on an undeclared attribute, or a `$ref_new` whose type differs from the compared attribute, is a `400` validation error. The whole write is rejected with no effects. Layer stamps every row-producing write with `_hevlayer_upserted_at` for supported stores. The stamp is what holds the [read watermark](/docs/ce/api/query); the full set of reserved attributes Layer manages on a row lives in the [document model](/docs/ce/document-model). To branch or copy a namespace, see [Branching](/docs/ce/api/upstream-features#branching). ## Upsert and delete `POST /v2/namespaces/{namespace}` accepts a JSON body. The first schema write creates the namespace. With the [common client](/docs/ce/api/introduction#install): ```python await client.write_namespace("preview_docs", { "distance_metric": "cosine_distance", "schema": { "vector": {"type": "[3]f32"}, "body": {"type": "string", "full_text_search": True}, "category": {"type": "string"}, "year": {"type": "int"}, }, "upsert_rows": [{"id": "atlas", "vector": [1, 0, 0], "body": "moon geology", "category": "space", "year": 2026}], }) ``` ```go _, err := client.WriteNamespace(ctx, "preview_docs", hevlayer.TurbopufferWriteRequest{ "distance_metric": "cosine_distance", "schema": map[string]any{ "vector": map[string]any{"type": "[3]f32"}, "body": map[string]any{"type": "string", "full_text_search": true}, "category": map[string]any{"type": "string"}, "year": map[string]any{"type": "int"}, }, "upsert_rows": []map[string]any{{"id": "atlas", "vector": []float64{1, 0, 0}, "body": "moon geology", "category": "space", "year": 2026}}, }) ``` ```typescript await client.writeNamespace("preview_docs", { distance_metric: "cosine_distance", schema: { vector: { type: "[3]f32" }, body: { type: "string", full_text_search: true }, category: { type: "string" }, year: { type: "int" }, }, upsert_rows: [{ id: "atlas", vector: [1, 0, 0], body: "moon geology", category: "space", year: 2026 }], }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"distance_metric":"cosine_distance","schema":{"vector":{"type":"[3]f32"},"body":{"type":"string","full_text_search":true},"category":{"type":"string"},"year":{"type":"int"}},"upsert_rows":[{"id":"atlas","vector":[1,0,0],"body":"moon geology","category":"space","year":2026}]}' ``` Delete by string ID using the same route: ```python await client.write_namespace("preview_docs", {"deletes": ["atlas"]}) ``` ```typescript await client.writeNamespace("preview_docs", { deletes: ["atlas"] }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' -d '{"deletes":["atlas"]}' ``` ## Patches, conditions and column writes On other stores, write-shape support follows the backend notes above. Use `patch_rows` to modify named fields without replacing a complete document only when the selected store supports patches. ## Status Layer validates the body before forwarding and can fail independently of the backing store, so the write path carries a few statuses a plain proxy wouldn't: - **200 OK** — applied to the selected store. Gateway bookkeeping depends on the backend. - **422 Unprocessable Entity** — Layer rejected the body before forwarding: no recognized native write operation, a reserved `_hevlayer_*` attribute name, a removed custom-write key, or a schema type the configured store cannot represent. The body is a Layer error (`{ "error": "validation_error", … }` or `{ "error": "UnsupportedByStore", … }`), not a store one. - **Upstream passthrough** — any non-2xx the backing store returns is relayed verbatim. - **502 Bad Gateway** — Layer could not reach the backing store (`{ "error": "upstream_error", … }`); the write did not apply. The passthrough case includes a failed conditional write (`upsert_condition`, `patch_condition`, `delete_condition`) — turbopuffer's error body comes back untouched. # Turbopuffer passthrough Source: https://hevlayer.com/docs/ce/api/upstream-features import Edition from "../../../components/docs/Edition.astro"; import PostgresWarning from "../../../components/docs/PostgresWarning.astro"; import ShardingCompare from "../../../components/docs/ShardingCompare.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; These Turbopuffer features pass through Layer unchanged when the store is Turbopuffer, in Community Edition and Pro. Layer forwards the request and returns Turbopuffer's response, errors included, so Turbopuffer's documentation is the reference for each one. Each entry below says how to reach the feature through the gateway and what Layer adds, if anything. Branching, sharding, pinning, and recall. | Feature | Request through Layer | Layer adds | | --- | --- | --- | | [Branching](#branching) | `branch_from_namespace` or `copy_from_namespace` on a write | Source read check, embedding profiles, blobs and lineage follow the branch | | [Sharding](#sharding) | `sharding.num_shards` on the first write | Nothing. Not the same as Layer's shards | | [Pinning](#pinning) | `PATCH /v1/namespaces/{ns}/metadata` | Wider scan fan-out on pinned namespaces | | [Recall](#recall) | `POST /v1/namespaces/{ns}/_debug/recall` | Nothing | We checked each request against a gateway on Turbopuffer on 2026-09-27. ## Branching A branch is an instant copy-on-write clone of a namespace. The source and the branch are independent afterwards, with no merge or diff. ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products-staging" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"branch_from_namespace": "products"}' ``` The value is the source namespace's name, or the object `{"source_namespace": "products"}` that turbopuffer-go sends. Layer accepts both and forwards the body unchanged, so the Layer client can send either compatible request shape. `copy_from_namespace` takes the same forms and makes a full, independent copy instead of a copy-on-write clone. The request rules: - **Branch or copy, nothing else.** A body that combines either key with documents (`upsert_rows`, `upsert_columns`, `patch_rows`, `patch_columns`, `deletes`, `delete_by_filter`, `patch_by_filter`), with `schema`, or with the other key gets `400` before anything reaches the store. Branch first, then write. On a store that cannot branch, the store's `422` comes first. - **The destination must be empty.** Turbopuffer enforces this, and its error comes back unchanged. - **The key must be able to read the source.** A branch or copy needs write scope on the target and read scope on the source. Without read on the source the gateway returns `403` naming the source. No key is minted or widened for a branch: a key reaches the branch when its namespace globs match the branch's name, so name branches into a family the key covers (`wt-*`). - **One store.** Source and target must resolve to the same VectorStore, or the gateway returns `422 BranchAcrossStores` naming both stores. To give a family of branches its own store, declare one Index for the family (see below). After Turbopuffer accepts the branch, Layer carries the state it keeps outside the namespace: | State | On a branch | | --- | --- | | Rows, schema, `_hevlayer_*` attributes | Cloned by Turbopuffer. | | Embedding profiles for gateway-served `embed` attributes | Copied, so the branch embeds query text like its source. | | Blobs | A blob set held in turbopuffer is branched too, so the branch owns its bytes. Blobs held in S3 are read from the source's S3 prefix. Either way, rows keep their `blob://products/…` references, and those resolve on the branch. | | Cache and history | Start empty. Any cache, snapshots or history left under the target's name by a deleted namespace are cleared. | | Write-triggered UDFs and Pipelines | Not run. A branch is not a row write. | | Index config | Follows by name, not from the source. | | Pinning, `read_only` | Turbopuffer's rules: `read_only` is inherited, pinning is not. | If a step after the branch fails, the gateway deletes the new branch and returns the error, so a branch is never half made. Layer records the lineage (`products-staging ← products`) in its object store; without `S3_BUCKET` no lineage is recorded, and blob references that name the source namespace are skipped by [cache warm](/docs/pro/api/warm-cache). S3 cannot branch, so a blob in S3 ([the default](/docs/ce/api/blobs#where-the-bytes-live) on turbopuffer with `S3_BUCKET` set, or too large for the store) is read from the source's S3 prefix. Layer never deletes blobs today, so that read stays valid. **Index config for branches.** An Index configures the namespaces its `spec.backend.namespace` names. A trailing `*` makes it a pattern: one Index with `namespace: wt-*` configures every branch named `wt-…`, including its `storeRef`, facets and blob reference attributes. An exact name wins over a pattern, and two overlapping patterns are rejected. A branch with no matching Index runs on gateway defaults. A copy (`copy_from_namespace`) follows the same rules and copies the same state. A copy with `source_api_key` or `source_region` reads a namespace in another organization or region; Turbopuffer authorizes it with the key in the body, the gateway checks only write on the target, and `blob://` references to the source do not resolve in the copy. Postgres and hev search have no native branch. They return `422 UnsupportedByStore: pgvector: branch_from_namespace` (or `search:`), and the gateway never emulates a branch with a copy. Check before branching with `GET /v2/namespaces/{ns}/capabilities`, whose `branch_from_namespace` and `copy_from_namespace` rows say what the namespace's store supports, or see the store capability matrix. Upstream: [turbopuffer.com/docs/branching](https://turbopuffer.com/docs/branching) ## Sharding Turbopuffer can split one namespace across several shards. Set the shard count on the namespace's first write: ```json { "sharding": {"num_shards": 2}, "upsert_rows": [{"id": 1, "vector": [0.1, 0.2, 0.3]}] } ``` `GET /v1/namespaces/{ns}/metadata` then reports `"sharding": {"num_shards": 2}`. The count is fixed after creation: resending the same value on a later write is accepted, and a different value returns Turbopuffer's `400`. This isn't Layer's sharding, and a namespace written through Layer has both kinds of field in its metadata: Upstream: [turbopuffer.com/docs/sharding](https://turbopuffer.com/docs/sharding) ## Pinning Pinning keeps dedicated warm replicas of a namespace, billed separately by Turbopuffer. Turn it on with a metadata patch: ```bash curl -X PATCH "$LAYER_GATEWAY_URL/v1/namespaces/products/metadata" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"pinning": true}' ``` `{"pinning": null}` turns it off. Turbopuffer can refuse a pin when a region has no capacity, and that `400` comes back unchanged. Readiness appears in `GET /v1/namespaces/{ns}/metadata` under `pinning.status.ready_replicas`. Layer reads pinning state to widen origin [scan fan-out](/docs/ce/api/scans#pinning-and-fan-out) on pinned namespaces. Upstream: [turbopuffer.com/docs/pinning](https://turbopuffer.com/docs/pinning) ## Recall The recall endpoint samples ANN queries against a namespace and compares them with exhaustive search, to measure how accurate the index is. ```bash curl -X POST "$LAYER_GATEWAY_URL/v1/namespaces/products/_debug/recall" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"num": 3, "top_k": 2}' ``` ```json {"avg_recall": 1.0, "avg_exhaustive_count": 2.0, "avg_ann_count": 2.0} ``` Turbopuffer bills the samples as queries. Upstream: [turbopuffer.com/docs/recall](https://turbopuffer.com/docs/recall) # Vector stores Source: https://hevlayer.com/docs/ce/api/vectorstores import Edition from "../../../components/docs/Edition.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import Callout from "../../../components/docs/Callout.astro"; ## VectorStores `GET /v2/vectorstores` lists standalone store connections without exposing credential values. `GET /v2/vectorstores/{name}` reads one connection. ```sh curl --fail-with-body http://localhost:8080/v2/vectorstores \ -H "Authorization: Bearer $TURBOPUFFER_API_KEY" ``` The list response has a `vectorstores` array. Entries identify the store name, kind, default selection, endpoint, and inbound authentication mode. Configure connections through [standalone configuration](https://github.com/hev/layer/blob/main/docs/setup.md). For a keyed store, send its configured bearer token. The local Compose Postgres selection requires no token.