API Reference
All request and response bodies are JSON. The base URL defaults to http://localhost:3000.
Authentication
When FIRNFLOW_API_KEY is configured, every protected endpoint requires Authorization: Bearer <token>. Two tiers exist:
- Read/write (
FIRNFLOW_API_KEY) —upsert,query,list,warmup,GET /ns/{namespace}, andGET /operations/{id}. - Admin (
FIRNFLOW_ADMIN_API_KEY):delete,index,fts-index,scalar-index,attributes,compact. The admin key also satisfies read/write routes. If no admin key is configured, the read/write key authorises admin routes too (single-key fallback).
/health is always public. /metrics is public unless FIRNFLOW_METRICS_TOKEN is set, in which case the same Bearer header is required. Token comparisons are constant-time (subtle::ConstantTimeEq). See configuration for the full env-var list, including the optional FIRNFLOW_RATE_LIMIT_RPS / FIRNFLOW_PREAUTH_IP_LIMIT_RPS rate-limit knobs.
Rejection responses on protected endpoints:
401 Unauthorized— header missing, malformed, or token unknown. IncludesWWW-Authenticate: Bearer realm="firnflow".403 Forbidden— valid token but the route requires admin scope and only the read/write key was presented.429 Too Many Requests— rejected by either rate limiter; includesRetry-Afterseconds.
Namespaces
Every data operation is scoped to a namespace. Namespace names must be lowercase alphanumeric with hyphens, and no longer than 64 characters. Each namespace maps to an isolated object-storage prefix under the configured FIRNFLOW_STORAGE_URI — for example s3://bucket/namespace/ or gs://bucket/tenants/acme/namespace/.
Valid: my-project, embeddings-v2, prod-search.
Invalid: My_Project (uppercase and underscores), a-very-long-name-that-exceeds-the-sixty-four-character-limit-imposed-by-firn.
Endpoints
Returns 200 OK with body ok. Use this for load balancer health checks and container readiness probes.
Example
curl http://localhost:3000/health
Response
ok
Returns all Prometheus metrics in text exposition format (text/plain; version=0.0.4). See the monitoring guide for the full metric list and PromQL examples.
Example
curl http://localhost:3000/metrics
Inserts or updates rows in the namespace's Lance table, keyed by id. The vector dimension and the vector kind (single-vector or multivector) are inferred from the first upsert and enforced on subsequent calls. After a successful write, all cached query results for this namespace are invalidated.
Upsert is latest-write-wins: re-sending a row whose id already exists replaces the stored row in full (vector, text, attribute values, and the _ingested_at timestamp), so replaying a request and updating a document are both safe. Replacing in full means a row re-sent without its attributes comes back without them. A row with a new id is inserted. Because _ingested_at is rewritten on update, it reflects the most recent write to a row rather than its first insert. Duplicate ids within a single request are rejected with 400.
The first write to a namespace builds a BTree index on id automatically, so the per-batch merge that matches incoming ids against stored rows uses the index instead of scanning every data file. For a large first load, see the recommended load/compact/index recipe in the README; for namespaces created before this behaviour existed, build the index once via POST /ns/{namespace}/scalar-index with {"column": "id"}.
/query and /list can still surface them until those ids are cleared (delete and re-ingest, or wait on the dedupe pass tracked in issue #68). Namespaces written only by this release forward are unaffected.
Each row carries one of two vector payload shapes, depending on the namespace's kind:
- Single-vector namespaces:
vector: float32[]— one dense vector of lengthdim. - Multivector namespaces:
vectors: float32[][]— a non-empty list of equal-length inner vectors. Used for ColBERT / ColPali / ColQwen2 late-interaction retrieval.
At most one of the two fields may be set on a row. The first row of the first upsert into a fresh namespace fixes the kind for its lifetime; payloads in the wrong shape return 400.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
rows | array | Yes | List of rows to insert |
rows[].id | u64 | Yes | Caller-assigned row identifier, unique within the namespace. Re-using an id replaces the stored row (latest-write-wins). Duplicate ids within a single request return 400. |
rows[].vector | float32[] | One of | Single dense vector (single-vector namespaces). Length must match the namespace dimension. |
rows[].vectors | float32[][] | One of | Bag of equal-length sub-vectors (multivector namespaces). Each inner vector must match the namespace's inner sub-vector dimension; outer list length is the per-row sub-vector count. |
rows[].text | string | No | Text payload for full-text search |
rows[].attributes | object | No | Values for the namespace's declared metadata columns, as a flat object of scalars. Every name must already be declared via POST /ns/{namespace}/attributes; an undeclared name returns 400, whether or not it carries a value, so clearing a value through a misspelled column is an error rather than a write that reports success and does nothing. An omitted or null value writes a null. An integer sent to a float column is widened, unless it is too large for a float to hold exactly, in which case it is rejected rather than rounded; an integer literal outside the signed 64-bit range is rejected outright, and has to be written as a float if the approximation is what the caller wants. |
Response (200)
| Field | Type | Description |
|---|---|---|
upserted | integer | Number of rows accepted |
Examples
Single-vector upsert:
curl -X POST http://localhost:3000/ns/demo/upsert \
-H 'Content-Type: application/json' \
-d '{
"rows": [
{"id": 1, "vector": [1.0, 0.0, 0.0, 0.0], "text": "hello world"},
{"id": 2, "vector": [0.0, 1.0, 0.0, 0.0]}
]
}'
{"upserted": 2}
Multivector upsert:
curl -X POST http://localhost:3000/ns/demo-mv/upsert \
-H 'Content-Type: application/json' \
-d '{
"rows": [
{"id": 1, "vectors": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]]},
{"id": 2, "vectors": [[0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.0, 0.0]]}
]
}'
Errors
400: invalid namespace name; vector dimension mismatch; payload shape mismatch (e.g.vectoron a multivector namespace or vice versa); bothvectorandvectorsset on the same row; empty inner sub-vector; mixed inner sub-vector dim within one row; an undeclared attribute name, a value whose type does not match its declared column, or an attribute value that is not a scalar500— object-storage write failure (client gets generic error; full details logged server-side)
The bulk first-load path. The body is an Arrow IPC stream, not JSON: binary and columnar, so embeddings avoid JSON's ~3x decimal-text inflation, and the route is not bound by FIRNFLOW_MAX_BODY_BYTES (a whole corpus can stream in one request). The entire stream is appended in a single Lance commit, which is what keeps throughput flat on a large load where many small /upsert commits would not.
Insert-only. Rows are appended, not merged: a repeated id creates a second row. Use /import for first-loads or known-new ids, and /upsert for idempotent updates. Build indexes after the load (see the README "Loading data at scale" recipe).
Request
Content-Type: application/vnd.apache.arrow.stream (application/octet-stream is also accepted). The stream's Arrow schema must carry:
id—UInt64, non-null.- exactly one of
vector(FixedSizeList<Float32, dim>, single-vector) orvectors(List<FixedSizeList<Float32, dim>>, multivector). TheFixedSizeListchild field must be nameditem(the default for e.g. PyArrow). - optional
text—Utf8. - optionally, any of the metadata columns declared on this namespace through
POST /ns/{namespace}/attributes, each under its declared name and Arrow type:stringasUtf8,intasInt64,floatasFloat64,boolasBoolean. A declared column the stream leaves out is null for every row it writes. Types must match exactly: unlike the JSON path, which widens an integer into afloatcolumn because JSON has a single number type, Arrow carries the type the caller chose, so anInt64array for afloatcolumn is a400rather than a conversion. _ingested_atmust not be present; the server sets it.- any other column is rejected — a misspelled name is a
400, not silently dropped. Vector floats must be non-null (no null child values inside a vector or sub-vector), and multivector rows must have at least one sub-vector.
On a fresh namespace the stream fixes the vector kind and dimension; on an existing one they must match. Because declaring a column needs a namespace that already exists, a namespace's very first write cannot carry attribute values: the order is import, declare, then import again with values, the same as on the JSON path. Schema problems are rejected with 400 before any work starts; row-level problems (null id, empty multivector row) and a truncated/malformed stream surface as a failed operation after the 202.
Response (202)
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "import",
"namespace": "demo",
"status": "running"
}
Poll GET /operations/{operation_id} for completion. Tuning: FIRNFLOW_IMPORT_MAX_BYTES caps a single import body spooled to disk (default 8 GiB; 0 disables, returns 413 when exceeded), and FIRNFLOW_IMPORT_TMP_DIR sets the spool directory.
Example
# write an Arrow IPC stream file (e.g. with pyarrow's RecordBatchStreamWriter), then:
curl -X POST http://localhost:3000/ns/demo/import \
-H 'Content-Type: application/vnd.apache.arrow.stream' \
--data-binary @rows.arrows
Errors
400— body is not a valid Arrow IPC stream, or the schema is missing/extra/mistyped (see Request)413— body exceedsFIRNFLOW_IMPORT_MAX_BYTES415— wrongContent-Type
Declares the scalar metadata columns a namespace carries. Out of the box a query filter can only reference id and _ingested_at; declared columns are the caller's own fields (section, tenant, language, category, status) and are what makes filtered retrieval useful.
Columns are declared rather than inferred from the rows that carry them. A column whose first value is 2024 looks like an integer until someone sends 2024.5, and a column whose first value is absent has no type at all, so inference defers a schema decision to a write that fails later on data that looks fine.
The namespace must already exist, the same precondition the index builders have: a namespace is created by its first write, so the order is write, declare, then write again with values. Unlike the index builders this endpoint is synchronous, because adding the columns is a single metadata commit that writes nulls into existing rows rather than a pass over the data.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
attributes | array | Yes | Columns to declare; at least one |
attributes[].name | string | Yes | Column name, matching [a-z][a-z0-9_]* up to 64 characters. Lowercase because the predicate dialect is SQL, which lowercases an unquoted identifier while parsing, so a column named Section could only be reached from a filter as "Section" with the quotes. id, vector, vectors, text, and any leading-underscore name are reserved by the engine. |
attributes[].type | string | Yes | One of string, int, float, bool |
A namespace holds at most 32 attribute columns.
Response (200)
| Field | Type | Description |
|---|---|---|
attributes | array | Every column on the namespace after the call, including ones declared earlier, in table order |
Example
curl -X POST http://localhost:3000/ns/demo/attributes \
-H 'Content-Type: application/json' \
-d '{"attributes": [
{"name": "section", "type": "string"},
{"name": "year", "type": "int"},
{"name": "archived","type": "bool"}
]}'
{
"attributes": [
{"name": "section", "type": "string"},
{"name": "year", "type": "int"},
{"name": "archived", "type": "bool"}
]
}
Status codes
| Code | When |
|---|---|
| 200 | Success, including a declaration that added nothing |
| 400 | Namespace has no data yet; illegal or reserved name; unknown type; duplicate name in one request; a column redeclared with a different type; more than 32 columns |
Re-declaring a column with the type it already has is accepted and commits nothing, so a client can send its whole intended schema on every startup without churning the table version the result cache keys on. Re-declaring one with a different type is rejected: the values already stored under the old type would have to be reinterpreted or dropped, which is not something to do behind a call that otherwise only adds. There is no drop or rename.
Every column is nullable. Rows written before a column was declared hold a null in it, and because /upsert replaces a matched row in full, re-sending a row without its attributes clears them.
There is no schema read on the write path to check a name against, so a value for a column that has not been declared is rejected at the write rather than dropped. That applies to a JSON null too: it writes the same null an omitted name would, but naming a column that does not exist is still an error.
POST /ns/{namespace}/import accepts declared columns in its Arrow schema, under the declared name and the Arrow type the declared type maps to. A column the stream omits is null for the rows it writes, and the types must match exactly, with none of the integer-to-float widening the JSON path does.
Declaring needs a namespace that already exists, so the first load into a new namespace still lands with nulls in every metadata column. Declare after that first write and the next import carries values.
Queries the namespace through the cache-aside path. On a cache hit, the result is returned from RAM or NVMe without re-running the search; once the namespace handle is warm a hit makes no object-storage access, though the first query to a namespace in a process reads the table manifest once to form the version-based cache key. On a miss, the query runs against the configured backend via LanceDB and the result is cached.
Query modes
The query mode is determined by which fields are present. The vector field uses vector: float32[] for single-vector namespaces and vectors: float32[][] for multivector namespaces:
| Mode | Vector field | text | Description |
|---|---|---|---|
| Single-vector | vector set | absent | Nearest-neighbour search (L2 distance by default) |
| Multivector | vectors set | absent | Late-interaction MaxSim search against a multivector namespace (cosine distance; an IVF_PQ index is what makes this tractable on real corpora — un-indexed queries fall back to a brute-force scan) |
| FTS | none | set | BM25 full-text search. Requires an FTS index; see the note below. |
| Hybrid | vector or vectors set | set | Both, fused via Reciprocal Rank Fusion (RRF) |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
vector | float32[] | One of | Single-vector query payload. Length must match the namespace dimension. |
vectors | float32[][] | One of | Multivector query payload — a bag of equal-length sub-vectors. Each inner vector must match the namespace's inner sub-vector dimension. |
k | integer | Yes | Number of results to return |
nprobes | integer | No | IVF partitions to probe (default 20). Higher values trade latency for recall. |
text | string | No* | Text query for full-text or hybrid search |
filter | string | No | DataFusion SQL predicate applied before vector ranking or FTS scoring. Uses the same predicate dialect as /list. A predicate can reference id, _ingested_at, and any metadata column the namespace has declared, so id > 1000, a time window over _ingested_at, and section = 'warnings' AND year >= 2024 are all valid. A name that is not a column on this namespace returns 400. For vector search this is a prefilter: the response contains up to k neighbours satisfying the predicate, not a post-filtered top-k. Malformed predicates return 400. A filtered request is cached by the exact text of its predicate, so a predicate calling a function whose result can change between two identical requests (now(), current_timestamp, current_date, random()) bypasses the result cache and always runs against the backend, correct but without the cache's speedup. The predicate is planned rather than pattern-matched, so a column named now still caches normally. To filter on recency and keep the cache, pass a fixed bound computed by the client instead of now(). |
include_vector | bool | No | Whether result rows carry the stored vector (default true). Set false when you only need id/score/text — the vector column is dropped from the returned rows, so the response and the cached result shrink, and the object-storage read for those rows' vectors is avoided. At dim=1536 each hit carries ~6 KiB of raw vector data, so a 100-hit response drops by roughly 600 KiB. This is response projection, not a scan optimisation: Lance still reads whatever it needs to score the query, so an unindexed flat scan reads the vectors regardless. |
semantic_cache | object | No | Opt-in semantic-cache controls. See Semantic cache below. Omitting the field preserves the legacy exact-cache-only behaviour. |
exact | bool | No | When true, bypass the IVF_PQ vector index and scan all rows for exact nearest-neighbour results (default false). The main use case is recall measurement: run the same query twice — once with exact: true as the ground truth and once without — then compare the two result sets to compute recall@k. Exact queries always run against the backend; the result cache and the semantic sidecar are both bypassed. Two combinations return 400: exact: true alongside nprobes (they describe different execution plans), and exact: true without any vector field (there is no vector index to bypass on an FTS-only query). |
refine_factor | uint32 | No | Post-index refinement factor. When set to N, the server fetches N * k candidates from the IVF_PQ index then re-scores them against the original full-precision stored vectors, keeping the true top-k from the re-scored set. Higher values improve recall at the cost of more storage reads and higher latency. Cannot be combined with exact: true (returns 400), since exact mode bypasses the index entirely. Participates in the exact result cache key, so refine_factor: 10 and no refinement are treated as separate queries and cached independently. Omitting the field or passing null disables refinement. |
* At least one of vector, vectors, or text must be present. Setting both vector and vectors on the same request returns 400.
text needs a BM25 index firstBoth the FTS and hybrid modes score text through the BM25 index, so on a namespace that has rows but no such index they return 400 naming the index and the endpoint that builds it. Build one with POST /ns/{namespace}/fts-index. Building the index is the only fix; the request cannot succeed on a retry. A hybrid request fails outright rather than quietly falling back to vector-only ranking, so a missing index never shows up as a silent change in result quality.
A namespace that has never been written is the exception: it has no table to index, so every query shape against it returns 200 with an empty result list. A brand-new deployment therefore looks healthy until the first document lands, which is when the missing index starts to matter.
Semantic cache
An optional semantic_cache block lets the server reuse a previous query's result when the new query vector is very close to a recent one against the same namespace generation. The exact result cache is still consulted first; only when it misses does the semantic layer get a chance to short-circuit. Eligible requests are single-vector only in v1; multivector, FTS, hybrid, and filtered requests with the field enabled return 400.
| Field | Type | Required | Description |
|---|---|---|---|
semantic_cache.enabled | bool | Yes (within the block) | When true, an exact-cache miss may be answered from a cached near-duplicate query. When false (or the whole block is omitted), only exact hits short-circuit. |
semantic_cache.min_similarity | float32 | No | Cosine-similarity floor for a semantic hit. Must lie in (0.0, 1.0]. Omitting picks the server default (0.995) — deliberately strict because a semantic hit is an approximate top-k, not the exact one. |
Behaviour:
- The
semantic_cacheblock does not change the exact-cache key. Togglingenabledon and off does not split otherwise-identical entries. - k, nprobes, and include_vector must match between the incoming request and the cached candidate; mismatches are skipped. A vector-light cached result is never used to answer a request that wants vectors back, or vice versa.
- Any committed change drops the semantic layer alongside the exact cache: writes, deletes, compactions, and index builds. An index build invalidates too, because it is a Lance commit that advances the table version the cache keys on, even though the rows are unchanged.
- Behaviour is observable via three Prometheus counters:
firnflow_semantic_cache_hits_total{namespace},firnflow_semantic_cache_misses_total{namespace}, andfirnflow_semantic_cache_rejections_total{namespace, reason}(with bounded reason labels:unsupported_query_shape,empty_index).
curl -X POST http://localhost:3000/ns/demo/query \
-H 'Content-Type: application/json' \
-d '{
"vector": [0.10, 0.21, 0.29, 0.40],
"k": 10,
"semantic_cache": {"enabled": true, "min_similarity": 0.995}
}'
Response (200)
| Field | Type | Description |
|---|---|---|
query_id | string | Deterministic hash of the query parameters (the cache key) |
results | array | Ordered list of matching rows |
results[].id | u64 | Row identifier |
results[].score | float32 | Distance (vector / multivector), BM25 score (FTS), or relevance score (hybrid) |
results[].vector | float32[]? | The stored vector for single-vector hits. null when the request set include_vector: false, and always null for multivector hits — the bag is intentionally not echoed. |
results[].text | string? | The stored text (null if none) |
results[].ingested_at_micros | i64? | Server-side microsecond timestamp the row was written — the same value /list reports. null on namespaces created before the timestamp column existed. |
results[].attributes | object | The row's declared metadata values as bare JSON scalars, e.g. {"section": "warnings", "year": 2024}. A column the row left null is left out of the object, and the field itself is omitted when the row has no values, so a namespace with no declared columns keeps the response shape it had before. |
Examples
Single-vector search:
curl -X POST http://localhost:3000/ns/demo/query \
-H 'Content-Type: application/json' \
-d '{"vector": [1.0, 0.0, 0.0, 0.0], "k": 5}'
Multivector search:
curl -X POST http://localhost:3000/ns/demo-mv/query \
-H 'Content-Type: application/json' \
-d '{"vectors": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]], "k": 5}'
Full-text search:
curl -X POST http://localhost:3000/ns/demo/query \
-H 'Content-Type: application/json' \
-d '{"text": "search query terms", "k": 10}'
Metadata-filtered search:
curl -X POST http://localhost:3000/ns/demo/query \
-H 'Content-Type: application/json' \
-d '{
"vector": [1.0, 0.0, 0.0, 0.0],
"k": 20,
"filter": "section = '"'"'warnings'"'"' AND year >= 2024"
}'
Hybrid search (single-vector + FTS):
curl -X POST http://localhost:3000/ns/demo/query \
-H 'Content-Type: application/json' \
-d '{
"vector": [1.0, 0.0, 0.0, 0.0],
"text": "search terms",
"k": 10,
"nprobes": 40
}'
_ingested_at
Returns rows in ingest order for "recent content" flows (landing pages, new-uploads feeds, back-catalogue browsing). Results are ordered by the reserved _ingested_at system column (microsecond server-side timestamp of the most recent write; re-upserting a row advances it).
This endpoint intentionally bypasses the foyer cache. Pagination tails would push hot query results out of RAM and NVMe without an offsetting hit rate, so /list goes directly to the Lance dataset.
Query parameters
| Param | Default | Description |
|---|---|---|
order_by | _ingested_at | V1 supports the reserved system column only; other values return 400. User-column ordering will follow scalar-index support. Build the BTree via POST /ns/{namespace}/scalar-index to remove the full-fragment scan cost on large namespaces. |
order | desc | asc or desc. |
limit | 50 | Rows per page. Hard-capped at 500. |
cursor | none | Opaque token from the previous response's next_cursor. Value-based on (_ingested_at, id), so rows appended during pagination are handled cleanly. Note that re-upserting a row advances its _ingested_at, which can move it across the cursor boundary mid-pagination, so an updated row may be skipped or repeated across pages. Format is implementation-defined — do not parse or construct by hand. |
Response (200)
| Field | Type | Description |
|---|---|---|
rows | array | Rows in the requested order |
rows[].id | u64 | Row identifier |
rows[].vector | float32[] | The stored vector |
rows[].text | string? | The stored text (null if none) |
rows[].ingested_at_micros | i64 | Server-side microsecond timestamp of the most recent write to the row (a re-upsert of the same id advances it) |
rows[].attributes | object | The row's declared metadata values as bare JSON scalars, on the same terms as the /query field of the same name |
next_cursor | string? | Pass verbatim as ?cursor= on the next call; null on the final page |
Status codes
| Code | When |
|---|---|
| 200 | Success |
| 400 | Invalid order_by, malformed cursor, or limit over 500 |
| 501 | Namespace's Lance table pre-dates the _ingested_at column; recreate the namespace to enable the endpoint |
Examples
First page, newest first:
curl 'http://localhost:3000/ns/demo/list?limit=50'
Next page via cursor:
curl 'http://localhost:3000/ns/demo/list?limit=50&cursor=0006012a9abb9c800000000000000007'
Returns operational metadata for a namespace: its vector shape, live row count, fragment count, which index kinds are built, and the current Lance table version. Useful for dashboards and for deciding when to compact or build an index, without querying the data or inspecting raw objects.
Like /list, this bypasses the foyer cache; it is namespace state, not a query result. It never creates a table, so a namespace that has never been written returns 404.
Response (200)
| Field | Type | Description |
|---|---|---|
namespace | string | Namespace name |
kind | string | single or multivector |
vector_dim | integer | Vector dimension (inner sub-vector width for multivector namespaces) |
row_count | integer | Live row count. Upsert is keyed by id (latest-write-wins), so this is the count of distinct live ids |
fragment_count | integer | Number of Lance data fragments. A growing count is the cue to compact |
has_vector_index | bool | Whether an IVF_PQ / HNSW vector index is built |
has_fts_index | bool | Whether a BM25 full-text index is built |
has_scalar_index | bool | Whether a BTree / bitmap / label-list scalar index is built |
table_version | u64 | Current Lance table version; advances on every commit |
attributes | array | Metadata columns declared on this namespace, each {"name", "type"}, in table order. These are the names a query filter can reference beyond id and _ingested_at. |
Status codes
| Code | When |
|---|---|
| 200 | Success |
| 404 | Namespace has no data yet (no Lance table exists) |
Example
curl http://localhost:3000/ns/demo
{
"namespace": "demo",
"kind": "single",
"vector_dim": 1536,
"row_count": 100000,
"fragment_count": 3,
"has_vector_index": true,
"has_fts_index": false,
"has_scalar_index": false,
"table_version": 42,
"attributes": [
{"name": "section", "type": "string"},
{"name": "year", "type": "int"}
]
}
Removes every object under the namespace's prefix and evicts all cached query results. This is irreversible.
Response (200)
| Field | Type | Description |
|---|---|---|
objects_deleted | integer | Number of objects removed from the backend |
Example
curl -X DELETE http://localhost:3000/ns/demo
{"objects_deleted": 12}
Accepts a list of queries and runs them in a background task to populate the cache. Returns 202 Accepted immediately. Useful for warming the cache after a deployment or before expected traffic. Query objects use the same schema as /query; include semantic_cache: {"enabled": true} on eligible single-vector warmup queries if you also want to seed the semantic sidecar.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
queries | QueryRequest[] | Yes | List of query objects (same schema as /query) |
Response (202)
| Field | Type | Description |
|---|---|---|
operation_id | string | Opaque handle; poll GET /operations/{operation_id} |
kind | string | warmup |
namespace | string | Namespace the work targets |
status | string | Lifecycle state at acceptance (running) |
queued | integer | Number of queries submitted for background execution |
Example
curl -X POST http://localhost:3000/ns/demo/warmup \
-H 'Content-Type: application/json' \
-d '{
"queries": [
{"vector": [1.0, 0.0, 0.0, 0.0], "k": 5},
{"vector": [0.0, 1.0, 0.0, 0.0], "k": 5}
]
}'
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "warmup",
"namespace": "demo",
"status": "running",
"queued": 2
}
firnflow_cache_misses_total metric to track how many warmup queries have completed. For semantic warmup, also watch firnflow_semantic_cache_rejections_total{reason="empty_index"} at startup and firnflow_semantic_cache_hits_total once near-duplicate traffic starts. Failures inside the background task are logged server-side but do not affect the HTTP response.
Builds an IVF_PQ (Inverted File with Product Quantisation) index on the namespace's vector column. Returns 202 Accepted and builds in the background. Building an index dramatically reduces cold query latency (25x speedup on AWS S3).
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
kind | string | Yes | - | Index type. Only "ivf_pq" is supported. |
num_partitions | u32 | No | sqrt(row_count) | Number of IVF partitions |
num_sub_vectors | u32 | No | dim / 16 | Number of PQ sub-vectors (must divide dimension evenly) |
num_bits | u32 | No | 8 | PQ codebook bit width. Accepted values: 4 or 8. Setting 4 halves the per-vector index storage cost at the cost of some recall; 4-bit additionally requires num_sub_vectors to be even. |
Response (202)
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "index",
"namespace": "demo",
"status": "running"
}
Example
curl -X POST http://localhost:3000/ns/demo/index \
-H 'Content-Type: application/json' \
-d '{"kind": "ivf_pq"}'
firnflow_index_build_duration_seconds to track completion. Queries against the namespace continue to work during the build using linear scan.
Builds a BM25 full-text search index on the namespace's text column. Required before any query carrying text will run at all. Without it, FTS and hybrid queries on a namespace that has rows return 400 rather than an empty result set. Returns 202 Accepted.
Full-text queries still find rows written after the index was built, so there is no need to rebuild after every write. The index itself does not cover them. Lance scans the data files it does not yet cover and merges those scores in with the indexed ones. Compaction folds the stragglers in, and until it does they cost a scan, so a namespace under steady writes still wants periodic POST /ns/{namespace}/compact for latency. The BTree on id behaves differently: there, rows written after the build are simply not covered until a compaction folds them in.
Request body
No request body required.
Response (202)
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "fts_index",
"namespace": "demo",
"status": "running"
}
Example
curl -X POST http://localhost:3000/ns/demo/fts-index
text field before building an FTS index.
Builds a BTree scalar index on a namespace column. On _ingested_at (the default) the index lets /list cursor pages do an index range scan instead of a full-fragment scan, and the leading ORDER BY _ingested_at short-circuits the in-memory sort step. On id it speeds up the per-batch merge-insert lookup the write path runs (see /upsert). Returns 202 Accepted.
Request body
Optional. {"column": "id"} indexes the row id; an empty body defaults to _ingested_at. Valid columns are id, _ingested_at, and any metadata column the namespace has declared; any other value returns 400 before the background build starts. On a metadata column the index is what stops a filter over it scanning the matching rows, so it is worth building on any column a workload filters by regularly. A namespace's first write already builds the id index automatically, so this is the maintenance path for namespaces created before that behaviour existed.
Response (202)
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "scalar_index",
"namespace": "demo",
"status": "running"
}
Example
curl -X POST http://localhost:3000/ns/demo/scalar-index
# index the row id (write-path maintenance path)
curl -X POST http://localhost:3000/ns/demo/scalar-index \
-H 'Content-Type: application/json' \
-d '{"column": "id"}'
# index a declared metadata column so filters over it use the index
curl -X POST http://localhost:3000/ns/demo/scalar-index \
-H 'Content-Type: application/json' \
-d '{"column": "section"}'
POST /compact incrementally absorbs new rows into the existing BTree, so a separate rebuild is not needed after compaction. Operators monitor firnflow_index_build_duration_seconds{kind="scalar"} for completion.
Merges small Lance data fragments into fewer, larger files to reduce object-storage round-trips on cold queries. Returns 202 Accepted. Also invalidates the cache for this namespace, since file offsets change after compaction.
Request body
No request body required.
Response (202)
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "compact",
"namespace": "demo",
"status": "running"
}
Example
curl -X POST http://localhost:3000/ns/demo/compact
fragments_removed and fragments_added when the compaction completes.
Returns the current state of background work started by an async endpoint (warmup, index, fts-index, scalar-index, compact). Each of those returns an operation_id in its 202; poll this endpoint to wait for completion instead of inferring it from metrics.
Records are kept in memory and bounded: the most recent completed operations are retained and running operations are never evicted, so an unknown or long-since-completed id returns 404.
Response (200)
| Field | Type | Description |
|---|---|---|
operation_id | string | The opaque id you polled |
kind | string | warmup, index, fts_index, scalar_index, or compact |
namespace | string | Namespace the work targets |
status | string | running, succeeded, or failed |
started_at_ms | integer | Milliseconds since the Unix epoch when work began |
finished_at_ms | integer? | Completion time in epoch milliseconds; null while running |
error | string? | Concise failure message; null unless failed |
Status codes
| Code | When |
|---|---|
| 200 | Operation found |
| 404 | Unknown id, or its record has aged out of the bounded registry |
Example
curl http://localhost:3000/operations/op_018f3a2b-7c4d-7e1f-9abc-1234567890ab
{
"operation_id": "op_018f3a2b-7c4d-7e1f-9abc-1234567890ab",
"kind": "scalar_index",
"namespace": "demo",
"status": "succeeded",
"started_at_ms": 1779580800123,
"finished_at_ms": 1779580802456,
"error": null
}
Error responses
All errors return a JSON body with an error field.
| Status | Cause | Example |
|---|---|---|
400 |
Invalid namespace name, dimension mismatch, empty query, unsupported index kind | {"error": "invalid namespace: must be lowercase alphanumeric and hyphens, max 64 chars"} |
401 |
Missing, malformed, or unknown Authorization: Bearer header. Only emitted when an API key is configured. Includes WWW-Authenticate. |
{"error": "unauthorized"} |
403 |
Valid read/write key on an admin route while a separate admin key is configured. | {"error": "forbidden"} |
429 |
Rejected by the per-principal or pre-auth IP rate limiter. Response includes Retry-After in seconds. |
{"error": "rate limited"} |
500 |
Object-storage connectivity, cache failure, or internal error | {"error": "internal error"} |
On 500 errors, the full error details are logged server-side via tracing::error! but scrubbed from the client response to prevent leaking internal state.