OriginChainDB docs
reference · vector

Vector search

Vector search finds rows whose embedding (a list of numbers from a model) is closest to a query embedding. It's how semantic search, RAG, recommendation systems, and image-similarity all work under the hood.

For how to save a vector, see Insert → vector. For how to declare vector columns on a schema, see Schemas → vector fields. All examples assume you have a client set up - see Quickstart.

1. Top-k search.

what this does

Given a query embedding, return the top k rows whose stored embeddings are closest to it.

POST /v1/tenants/:t/vector/:table/topk
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.products/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [0.011, -0.082, 0.046, /* ... 768 floats ... */],
    "k":      10,
    "dim":    768,
    "metric": "cosine"
  }'
what each field means
Field Type Required What it is
query float[] yes The query embedding. Length must match the column's dim.
k int yes How many results to return. Typical values: 10, 50, 100.
dim int yes The vector's length. Must match the table's configured dimension.
metric string no How "closeness" is measured. Must match what was used at insert time. See distance metric.
filter object no Metadata filter. See filter by metadata.
mode string no "high_recall" (default) or "fast". See speed vs recall.
what you get back

An array of { id, score } objects, ordered from closest to farthest. The id is the row's primary key (so you can look up the full row); score tells you how close the match is.

[
  { "id": "sku-9281", "score": 0.92 },
  { "id": "sku-4017", "score": 0.88 },
  { "id": "sku-3320", "score": 0.85 },
  ...
]

Score ordering depends on the metric. With cosine and dot, higher is closer. With L2, lower is closer. Results always come back in correct ranking order - you do not need to sort yourself.

common mistakes
  • Wrong dim. If your query vector is 1536 floats but the table was set up for 768, you get a 400 reading vector has 1536 dims but collection "shop.orders" expects 768. There is no dim_mismatch code - the body carries the message, not a token.
  • Wrong metric. Query with a different metric than the table was written under and you get a 409 vector_metric_mismatch, naming both metrics and a rebuild URL. If the collection declares a non-default [vector].distance, contradicting it is refused earlier, with a 400.
  • Empty results from a brand-new table. Not an index lag. There is no build step for the default index - the embedding and the updated graph ship in one batch, so a vector is queryable as soon as the put returns. Fewer than k hits means fewer than k vectors match; retrying will not change it.

2. Filter by metadata.

what this does

Restrict the search to vectors whose metadata matches a filter. Useful for things like "find similar products but only in the shoes category".

POST /v1/tenants/:t/vector/:table/topk
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.products/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [/* 768 floats */],
    "k":      10,
    "dim":    768,
    "metric": "cosine",
    "filter": { "category": "running-shoes" }
  }'

Filters use exact equality on metadata fields you stored at insert time. The filter is applied during the search, not after - so a highly selective filter (e.g., only 1% of rows match) is still fast.

common mistakes
  • Filtering on a field you didn't store. The filter looks at the metadata object you passed at insert time - not at the row's other columns. If you want to filter on a column, include it in metadata.
  • Range filters. Only exact equality is supported today (category = "shoes"). For range filters (price < 100), filter the result in your app after the search returns.
  • Very selective filters returning fewer than k hits. If only 5 rows match your filter, you get 5 hits even if you asked for 50. That's not a bug - it's all there is.

3. Distance metric.

what this does

Picks the math used to compare two vectors. You send the metric on each request - on the write and on the search - and the two have to use the same one. A collection can pin it once with [vector].distance on the schema, which then fills in an omitted metric and refuses one that contradicts it.

pick by your model
Metric Use it when
cosine Default for text. Use this with OpenAI, Cohere, Voyage, BGE, E5, and most other text embedding models. Looks at the angle between two vectors - magnitude doesn't matter.
dot Use when your vectors are already unit-normalised (length 1). Slightly cheaper than cosine for the same result. Some image-embedding pipelines emit normalised vectors.
l2 Use when the absolute distance between vectors matters - some image-feature pipelines, certain audio embeddings, and a few specialty research models.
manhattan Same as L2 but uses absolute differences instead of squared ones. More forgiving of one or two big-difference dimensions. Niche.

Not sure? Pick cosine. It works for 90% of text embeddings.

4. Speed vs recall.

what this does

Approximate nearest-neighbor search trades a small amount of accuracy for huge speed gains. The mode field picks where on that trade-off you want to land.

POST /v1/tenants/:t/vector/:table/topk
# Add "mode" to the topk body. "high_recall" (default) or "fast".
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.products/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [/* 768 floats */],
    "k":      10,
    "dim":    768,
    "metric": "cosine",
    "mode":   "fast"
  }'
two modes
Mode What you get Use when
high_recall (default) ~96% of the truly-closest vectors. Higher latency. Product search, similar-customer lookup, anywhere first-pass accuracy matters.
fast ~70% of the truly-closest vectors. ~3x faster. RAG with a re-ranker, hot dashboards, anywhere latency dominates.

"Recall" means: of the truly closest vectors that brute force would return, how many did the index find? Both modes return ranking-correct results - the difference is whether the absolute top-K is occasionally missed.

5. Index choice.

what this does

OriginChainDB supports several different index types for vector data. Most users should stick with the default (HNSW). The other types are for very large or memory-constrained corpora.

Index Pick when
HNSW (default) Best accuracy, and the right choice for almost everyone. The engine puts it comfortably at around a million vectors per table at the default search width.
IVF Above ~10M vectors. Cheaper memory at the cost of a small recall hit. See IVF reference.
IVF-PQ For the largest tables, or when memory is the constraint. Compresses vectors 64×-768×. See IVF-PQ reference.
Binary quantization When you need 32× memory savings and can tolerate the recall hit. See Quantization reference.
Sparse vectors For models like SPLADE / uniCOIL that emit sparse vectors instead of dense ones.

The index is picked per request, not on the schema - index is a field on both the write and the query body, and the manifest has no index key. See Index kinds for the accepted values.

6. Examples.

Every operation below is shown in cURL, Python, TypeScript and Go. Where an SDK does not wrap an endpoint the tab says so and shows the raw call instead.

6.1

Insert vectors.

id, embedding and dim are required. metadata is a free-form JSON object stored alongside the vector — it is what filtering reads later, so put anything you may want to narrow on in there at write time.

Writes are indexed eagerly and atomically: the embedding and the updated graph ship in one batch, so the index can never lag the data across a crash. There is no "build the index" step for the default index — a vector is queryable as soon as the call returns.

single insert
POST /v1/tenants/:tenant/vector/:table/put
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/put" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id":        "01JTRX9KQ3YH8K2WMX0F5JZAB7",
    "embedding": [0.0124, -0.0883, 0.0451],
    "dim":       3,
    "metric":    "cosine",
    "metadata":  { "status": "paid", "customer": "01JTRX1H4Q9P0N2WMX0F5JZ001" }
  }'
# → 201 Created, empty body
bulk insert

Bulk is the right shape for ingest: it builds the graph in one pass and writes one log frame for the whole batch. dim, metric, quantization and index live on the envelope; each item carries only id, embedding and metadata. The body-size limit is lifted on this route only.

POST /v1/tenants/:tenant/vector/:table/put_bulk
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/put_bulk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "dim":    768,
    "metric": "cosine",
    "vectors": [
      { "id": "01JTRX9KQ3YH8K2WMX0F5JZAB7", "embedding": [/* 768 floats */],
        "metadata": { "status": "paid" } },
      { "id": "01JTRX9KQ3YH8K2WMX0F5JZAB8", "embedding": [/* 768 floats */],
        "metadata": { "status": "refunded" } }
    ]
  }'
response
{
  "inserted":   2,
  "elapsed_ms": 14
}
  • At most 100,000 vectors per call — over that the engine answers 413, not 400.
  • An empty vectors array is a 200 with inserted: 0 and no write.
  • Duplicate ids inside one batch are last-writer-wins. The whole batch is one atomic write.
  • Single insert returns 201 Created with an empty body — don't try to parse it.
sparse vectors

Learned-sparse embeddings go to POST /vector/:table/put_sparse with { id, indices, values, dim, metadata? }, and are queried with POST /vector/:table/topk_sparse. indices and values must be the same length, every index must be inside dim, and non-finite values are refused. No SDK wraps the sparse endpoints today.

6.2

Query — top-k.

query, k and dim are required; everything else has a default. The field is k — there is no top_k alias.

POST /v1/tenants/:tenant/vector/:table/topk
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [0.0124, -0.0883, 0.0451],
    "k":      5,
    "dim":    3,
    "metric": "cosine"
  }'
response
[
  { "id": "01JTRX9KQ3YH8K2WMX0F5JZAB7", "score": 0.9421 },
  { "id": "01JTRX9KQ3YH8K2WMX0F5JZAB8", "score": 0.9187 },
  { "id": "01JTRX9KQ3YH8K2WMX0F5JZAB9", "score": 0.8804 }
]

A bare JSON array, not an envelope — no hits wrapper and no total count. Sorted by score descending, and larger always means closer: for l2 and manhattan the distance is returned negated so the ordering convention holds across every metric.

k: 0 returns [] without touching storage. The ceiling on k is 4096 by default; above it you get a 400.

6.3

Query modes — fast vs high_recall.

mode is the one recall/latency knob exposed on the API. It sets the search beam width and nothing else — the build-time graph parameters are unaffected.

mode Beam width Measured (100k vectors, 128-dim)
"high_recall" 1200 Default. recall@10 ≈ 0.96, p99 ≈ 109 ms
"fast" 300 recall@10 ≈ 0.69, p99 ≈ 37 ms
"mode": "fast"
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [/* 768 floats */],
    "k":      10,
    "dim":    768,
    "metric": "cosine",
    "mode":   "fast"
  }'
  • Omitting mode gives you high_recall. That is the safe default and the right one for first-pass retrieval.
  • The value is case-sensitive. An unknown value is a hard 400: unknown `mode` "FAST": expected one of "fast", "high_recall".
  • mode applies to the default graph index only. On ivf and ivf_pq queries it is accepted and then ignored — those paths have no beam width. Use nprobe there instead.
  • The recall figures above are the published measurements for one corpus shape. Treat them as a guide to the trade-off, not an SLA for your data.
seeing what a query actually did

POST /vector/:table/topk_explain takes the same body and returns { hits, config }, where config reports the resolved metric, dim, k, ef_search, m and beam_width. Useful for confirming a mode actually took effect. It always runs the graph index, whatever index you pass.

6.4

Metadata filtering.

filter is a flat map of strict equalities against the metadata you stored at write time. Multiple keys are ANDed. There are no operators — no ranges, no $in, no nesting, no negation.

filtered top-k
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [/* 768 floats */],
    "k":      10,
    "dim":    768,
    "metric": "cosine",
    "filter": { "status": "paid" }
  }'

Values are compared with strict JSON equality, so 1 does not match "1" and true does not match "true". A record that is missing a key named in the filter is rejected, not treated as null.

a filtered query can return fewer than k hits

On the default graph index, filtering is a post-filter: the engine searches for k × 4 candidates and then drops the ones that don't match. If your filter is highly selective, most of the over-fetched set is discarded and you get back fewer than k results — even when more matching vectors exist. Ask for a larger k than you need when you filter narrowly.

filter is silently ignored on ivf_pq

A filter sent with "index": "ivf_pq" is not applied, and the request still returns 200 with unfiltered results. This is the sharpest edge on this page: filter and IVF-PQ do not compose today. Filter on the default index, or apply the predicate yourself after the hits come back. On "index": "ivf" the filter is honoured, and applied inside each cell scan before scoring.

6.5

Index kinds.

index appears on both the write and the query body, and the two must agree — a vector written under one index kind is not visible to a query using another. Omitting it everywhere is the common and correct choice.

index What it is
"hnsw" The default. A navigable small-world graph, built incrementally on every insert. Build parameters are fixed: 16 neighbours per node (32 at the base layer) and a construction beam of 200. No setup, no training, no minimum corpus. This is what you want unless you have measured a reason otherwise.
"ivf" Inverted file: vectors are assigned to the nearest of K centroids, and a query scans only nprobe of those cells. Requires centroids to be installed or trained first. Filters are applied inside the cell scan.
"ivf_pq" Inverted file plus product quantization — each vector is stored as a short code instead of full floats. This is the memory-footprint option for large corpora, and the one the presets build.

Approximate, not exact.

All three index kinds are approximate nearest-neighbour structures: they trade a small amount of recall for a large amount of speed, and none of them guarantees that the true nearest neighbour is in the result set. That is the deal ANN makes, and it is almost always the right one — an exhaustive scan is exact but linear in corpus size, which stops being viable long before a million vectors. If exactness genuinely matters for a small collection, raise k and re-rank the candidates yourself with your own distance function.

6.6

Building an IVF-PQ index from a preset.

IVF-PQ has a lot of knobs — partition count, subspace count, code width. Rather than expose them, the engine ships exactly two named presets and derives every parameter from your corpus. preset is the only required field.

POST /v1/tenants/:tenant/vector/:table/create-ivf-pq-index
curl -X POST \
  "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/create-ivf-pq-index" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "preset": "compressed" }'
response — the config it chose
{
  "trained":              true,
  "installed":            true,
  "preset":               "compressed",
  "partitions":           1024,
  "pq_m":                 48,
  "pq_bits":              8,
  "keep_raw":             false,
  "dim":                  768,
  "training_corpus_size": 50000
}

The optional seed field (default 0) makes training deterministic. There are no other fields — you cannot override partitions, subspace count or code width.

build then query — no re-insert needed

This endpoint populates the index cells as part of the build, feeding your stored vectors back through the same write path an IVF-PQ insert would use. The index is queryable the moment the call returns — you do not have to re-insert your rows under index: "ivf_pq". This is specific to this preset endpoint: the lower-level centroid-install primitives deliberately do not write postings.

How each preset resolves.

Both presets pick the same partition count and the same 8-bit codes. They differ in how finely the vector is subdivided, and in whether the original vector is kept.

Parameter high_recall compressed
Target sub-vector width 8 16
pq_m (subspaces) The divisor of dim — capped at 64 — whose sub-vector width dim / m lands closest to the target above.
pq_bits 8 8
keep_raw true false
partitions 4 × √N rounded to the nearest power of two, clamped to [64, 65536]. Identical for both presets.

Because pq_m must divide dim and is capped at 64, the two presets converge at high dimensionality. Worked from the real formula:

dim high_recall pq_m compressed pq_m Code bytes / vector
128 16 8 16 B vs 8 B
768 64 48 64 B vs 48 B
1536 64 64 64 B vs 64 B

At 1536 dimensions the codes are identical — both presets hit the 64-subspace cap — and the only remaining difference is keep_raw. Since keep_raw stores an extra dim × 4 bytes per vector, at 1536 dimensions high_recall costs about 6.2 KB per vector against 64 bytes for compressed — roughly a 97× difference in footprint for the same search behaviour today.

what keep_raw does and does not buy you today

high_recall retains the full-precision vector so that a candidate set can later be re-scored exactly. The HTTP top-k path does not perform that re-rank today — an ivf_pq query scores against the quantized codes for both presets. So on the current API the two presets return comparable results, and high_recall's extra storage is buying future re-ranking rather than present accuracy. If footprint is why you are reaching for IVF-PQ at all, compressed is the honest choice.

Which to pick.

  • Neither, at small scale. Under a few hundred thousand vectors the default graph index is faster and more accurate, and needs no build step. IVF-PQ is a memory-footprint tool, not a speed tool.
  • compressed when the corpus no longer fits comfortably in memory and you want the smallest possible resident footprint.
  • high_recall when you want the raw vectors kept alongside the codes — for exact re-ranking you perform yourself, or to be ready for engine-side re-ranking without a rebuild.

Querying the built index.

Pass "index": "ivf_pq" and, optionally, nprobe — the number of cells to visit.

curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [/* 768 floats */],
    "k":      20,
    "dim":    768,
    "metric": "cosine",
    "index":  "ivf_pq",
    "nprobe": 16
  }'
  • nprobe defaults to ceil(sqrt(partitions)). Higher visits more cells: better recall, more work.
  • Valid range is 1 to 256. 0 is a 400 (nprobe must be >= 1); above 256 is a 400 naming the cap.
  • nprobe is ignored on the default graph index — it only means something for ivf and ivf_pq.
  • In the Python SDK nprobe is available on the typed db.vector.topk() namespace. No other SDK exposes it.

The minimum-corpus rule.

Training refuses to run on a corpus too small to populate its partitions: you need at least four vectors per partition. Because the partition count is floored at 64, that means a practical floor of 256 vectors — and far more once 4 × √N pushes the partition count up.

{
  "error": "not enough vectors to build a Compressed index: 1024 partitions
            need >=4*K = 4096 vectors, found 900"
}

Building on an empty table is a separate 400: no vectors stored for this table; put vectors before building an index. Note that the preset name is echoed in the error in its internal capitalised form (Compressed, HighRecall) rather than the wire form you sent.

the lower-level centroid endpoints

Plain ivf (without quantization) is driven by four separate endpoints, wrapped by the Python SDK only:

  • POST …/train-and-install-centroidsdb.vector.train_and_install_centroids(table, partitions=…). Same four-per-partition minimum.
  • POST …/install-centroidsdb.vector.install_centroids(table, centroids) for centroids you trained elsewhere.
  • GET …/centroidsdb.vector.centroids(table), a truncated preview.
  • GET …/ivf-rebalance-statusdb.vector.rebalance_status(table), reporting skew and whether a rebalance is none, recommended or required.

Unlike the preset endpoint, installing centroids does not write postings for existing rows — that path is "install once, then write". Querying an IVF index with no centroids installed returns 503, not 404, with the install URLs in the response body.

hybrid dense + sparse

POST /vector/:table/topk_hybrid runs a dense and a sparse query together and fuses the two rankings server-side with Reciprocal Rank Fusion. Fields: dense_query, dense_dim, sparse_query_indices, sparse_query_values, sparse_dim, k, plus optional dense_metric, dense_mode, rrf_k (default 60), candidates and filter. The returned score is a fused rank score, not a distance — it is not comparable to the scores from a single-mode query. No SDK wraps it.

6.7

Delete.

DELETE /vector/:table/:vec_id · POST /vector/:table/delete-bulk
# Single - idempotent, 200 with {"deleted": false} when the id is absent
curl -X DELETE \
  "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/01JTRX9KQ3YH8K2WMX0F5JZAB7" \
  -H "Authorization: Bearer $OC_TOKEN"

# Bulk - at most 10,000 ids per call
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/vector/shop.orders/delete-bulk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["01JTRX...AB7", "01JTRX...AB8"] }'
  • Single delete is idempotent: deleting an id that isn't there is a 200 with { "deleted": false }, never a 404.
  • Bulk delete caps at 10,000 ids and returns { deleted_count, missing_count }.
  • Both accept an optional index selector (hnsw by default) — it must match the index the vector was written under.
  • Vector ids are capped at 1024 characters and may not be empty.
6.8

Limits and gotchas.

Limit Value
Max k per query4096
Max vectors per bulk insert100,000
Max ids per bulk delete10,000
Max nprobe256
Max IVF partitions65,536
Max PQ subspaces (pq_m)64
Vectors sampled for index training1,000,000
Max vector id length1024 chars
Dimensionality> 0, no upper bound
metric and index are per-request - and only metric is checked

index is per-request and recorded nowhere. metric is per-request too, but it is checked: every HNSW graph is stamped with the metric it was built under, so writing with cosine and querying with l2 is refused with a 409 vector_metric_mismatch. An index built before that stamp shipped carries none and still serves the mismatch behind a 200, until the next write stamps it. Same for index: writing under the default graph index and then querying index: "ivf_pq" finds nothing at all, unless the preset build populated those cells. Pick one metric and one index kind per collection and hold them constant in your own code.

training and populate sample at most one million vectors

A preset build reads up to 1,000,000 vectors, both for training and for populating cells. On a collection larger than that, rows beyond the first million are not written into the IVF-PQ index by the build and will not be found by an ivf_pq query until they are written again under that index.

back-pressure and quota

Queries take a process-wide heavy-operation permit before touching storage; under memory pressure you get 429 with a Retry-After. Writes are checked against your vector quota and answer 402 when it is exhausted — a bulk insert is pre-checked for the whole batch, so it is all-or-nothing. Vector endpoints also require the vector capability to be enabled on the instance; without it the call fails with a 402 naming it. Vector search is included on every paid configuration at no extra charge, so this is a switch to flip, not a purchase.

quantization on the write path

Separately from IVF-PQ, a write can carry quantization: "none" (default), "scalar", "binary" or "pq". This shrinks the stored payload at some cost in precision. Note that under binary quantization cosine and dot become the same computation, and manhattan is evaluated on the l2 path — rank-equivalent, but the absolute scores differ from what you would expect.

6.9

Where this lives in the dashboard.

The query workbench has no vector mode. The LANG switcher offers SQL, Cypher, NL and Search — and that is the whole list. A nearest-neighbour query needs a query embedding, which is not something you can usefully type into a text editor.

What the dashboard does own is the index: on Data → Schema you can train and install a vector index over a table that already holds vectors. Running the search itself is always an API or SDK call.

Vector collections do appear in the workbench's schema rail under a vector tables heading with their vector count, so you can confirm what has been indexed without leaving the page. See Run queries from the dashboard for the rest.