Query & Fetch
This is Layer’s query API. Layer reports its own metadata in
x-layer-* response headers.
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
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.
response = await client.query_namespace("products", {
"nearest_to_id": ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"],
"top_k": 10,
"include_attributes": ["title", "category"],
})response, err := client.QueryNamespace(ctx, "products", &hevlayer.QueryRequest{
NearestToID: []string{"asin-B08N5WRWNW", "asin-B07PXGQC1Q"},
TopK: 10,
IncludeAttributes: []string{"title", "category"},
})const response = await client.queryNamespace("products", {
nearest_to_id: ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"],
top_k: 10,
include_attributes: ["title", "category"],
});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.
Rank expressions
After the write example:
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"],
})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"},
})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"],
});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"]}' text = await client.query_namespace("preview_docs", {
"rank_by": ["body", "BM25", "moon"], "top_k": 5,
"include_attributes": ["body"],
})const text = await client.queryNamespace("preview_docs", {
rank_by: ["body", "BM25", "moon"], top_k: 5, include_attributes: ["body"],
});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: ["<attribute>", "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
nullvalues 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]. Acursorreturns422 UnsupportedByStore. - Ordering by id or by an
int,uint,float, orboolattribute 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.
Attribute projection (include_attributes / exclude_attributes): supported
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"]].
Containsmatches rows whose array holds the value.ContainsAnymatches rows whose array shares at least one element with the list.- A missing,
nullor empty array contains nothing, soContainsAnywith an empty list matches no rows. EachNotform 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 andrank_byon an array attribute return422 UnsupportedByStore.
Contains / ContainsAny filters and their Not forms: supported
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.)
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 textbatch, 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},
},
})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 textcurl -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.
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.
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.
Type-ahead text queries
Use the last token as a literal prefix while the user is typing:
{
"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
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 rank operator (gateway dense + text RRF): approximate — HybridText with fuzziness: 0 only (BM25 + dense legs, gateway RRF); auto/1/2 fuzziness and cursor/temporal_filter return 422
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,
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.
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"],
})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"},
})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"],
});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:
["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 namespace — the same fan-out control as 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:<token>, and
semantic on routed fused queries.
Tokenization
The input string becomes tokens under a fixed, documented policy:
- Split on Unicode (UAX #29) word boundaries and lowercase, using
alyze— the code behind turbopuffer’s productionword_v4tokenizer. Punctuation-only tokens never survive the split. - Drop tokens shorter than 2 characters.
- Dedupe.
- Remove stop words under the
stopwordsoption (English by default). Removed tokens are reported instopwords_droppedand do not count against the cap, so on long queries the cap spends its legs on content tokens. - 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:
{
"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). 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. |
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: truecan require one upstream query per leg on unsharded namespaces so Layer can report per-leg ranks. - One consistency cut. Request-level
filtersare replicated to every leg, and the stable-read watermark predicate is injected into every leg from a single read — all legs see the same cut. Responses carryx-layer-stable-as-ofas 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
Real search boxes receive both "timout" and "why do pods lose their connection during deploys". The first wants
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.
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"],
})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"},
})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"],
});# 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 as hybrid text fusion:
| Tokens | Route | Runs |
|---|---|---|
| ≤ 2 | hybrid_text | The 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_<attr> 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 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:
{
"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. |
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.
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"]
}'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"]])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:
{ "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:
{ "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
{
"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.
cursorreturns400withcollapse. Raisetop_korexpansioninstead. - Multi-query bodies.
queriesreturns400withcollapse. - Across namespaces. Federated queries don’t collapse.
Counting matches
To count how many rows match a full-text or vector query, use
scan 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.