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.
Given a query embedding, return the top k rows whose stored embeddings are closest to it.
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"
}'# query_768d is your query embedding - any list of 768 floats.
hits = db.vector_topk(
"shop.products",
query=query_768d,
k=10,
dim=768,
metric="cosine",
)
for hit in hits:
print(hit.id, hit.score)const hits = await db.vectorTopk("shop.products", {
query: query768d, // number[] of length 768
k: 10,
dim: 768,
metric: "cosine",
});
for (const hit of hits) {
console.log(hit.id, hit.score);
}hits, err := db.VectorTopK(ctx, "shop.products", originchain.VectorTopKRequest{
Query: query768d, // []float32 of length 768
K: 10,
Dim: 768,
Metric: "cosine",
})
if err != nil { /* handle */ }
for _, h := range hits {
fmt.Println(h.ID, h.Score)
}| 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. |
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.
- Wrong dim. If your query vector is 1536 floats but the table was set up for 768, you get a
400readingvector has 1536 dims but collection "shop.orders" expects 768. There is nodim_mismatchcode - 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
409vector_metric_mismatch, naming both metrics and a rebuild URL. If the collection declares a non-default[vector].distance, contradicting it is refused earlier, with a400. - 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.
Restrict the search to vectors whose metadata matches a filter. Useful for things like "find similar products but only in the shoes category".
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" }
}'hits = db.vector_topk(
"shop.products",
query=query_768d,
k=10,
dim=768,
metric="cosine",
filter={"category": "running-shoes"},
)const hits = await db.vectorTopk("shop.products", {
query: query768d,
k: 10,
dim: 768,
metric: "cosine",
filter: { category: "running-shoes" },
});hits, err := db.VectorTopK(ctx, "shop.products", originchain.VectorTopKRequest{
Query: query768d,
K: 10,
Dim: 768,
Metric: "cosine",
Filter: map[string]any{"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.
- Filtering on a field you didn't store. The filter looks at the
metadataobject 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.
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.
| 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.
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.
# 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"
}'hits = db.vector_topk(
"shop.products",
query=query_768d,
k=10,
dim=768,
metric="cosine",
mode="fast", # "fast" | "high_recall" (default)
)const hits = await db.vectorTopk("shop.products", {
query: query768d,
k: 10,
dim: 768,
metric: "cosine",
mode: "fast", // "fast" | "high_recall"
});hits, err := db.VectorTopK(ctx, "shop.products", originchain.VectorTopKRequest{
Query: query768d,
K: 10,
Dim: 768,
Metric: "cosine",
Mode: originchain.ModeFast, // ModeFast | ModeHighRecall
})| 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.
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.
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.
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 bodydb.vector_put(
"shop.orders",
id="01JTRX9KQ3YH8K2WMX0F5JZAB7",
embedding=[0.0124, -0.0883, 0.0451],
dim=3,
metric="cosine",
metadata={"status": "paid", "customer": "01JTRX1H4Q9P0N2WMX0F5JZ001"},
)
# The typed namespace infers dim from len(embedding) and sends no
# metric - use it when cosine (the default) is what you want:
db.vector.put(
"shop.orders",
"01JTRX9KQ3YH8K2WMX0F5JZAB7",
[0.0124, -0.0883, 0.0451],
metadata={"status": "paid"},
)await db.vectorPut("shop.orders", {
id: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
embedding: [0.0124, -0.0883, 0.0451],
dim: 3,
metric: "cosine",
metadata: { status: "paid", customer: "01JTRX1H4Q9P0N2WMX0F5JZ001" },
});err := db.VectorPut(ctx, "shop.orders", originchain.VectorPutRequest{
ID: "01JTRX9KQ3YH8K2WMX0F5JZAB7",
Embedding: []float32{0.0124, -0.0883, 0.0451},
Dim: 3,
Metric: "cosine",
Metadata: map[string]any{"status": "paid"},
})
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.
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" } }
]
}'# No SDK wraps bulk vector insert yet - call the endpoint directly.
import httpx
httpx.post(
f"https://{OC_HOST}/v1/tenants/{OC_TENANT}/vector/shop.orders/put_bulk",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={
"dim": 768,
"metric": "cosine",
"vectors": [
{"id": "01JTRX...AB7", "embedding": vec_a, "metadata": {"status": "paid"}},
{"id": "01JTRX...AB8", "embedding": vec_b, "metadata": {"status": "refunded"}},
],
},
)// No SDK wrapper for bulk vector insert yet - using fetch directly.
await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/vector/shop.orders/put_bulk`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
dim: 768,
metric: "cosine",
vectors: [
{ id: "01JTRX...AB7", embedding: vecA, metadata: { status: "paid" } },
{ id: "01JTRX...AB8", embedding: vecB, metadata: { status: "refunded" } },
],
}),
},
);// No SDK wrapper for bulk vector insert yet - using net/http directly.
body, _ := json.Marshal(map[string]any{
"dim": 768,
"metric": "cosine",
"vectors": []map[string]any{
{"id": "01JTRX...AB7", "embedding": vecA, "metadata": map[string]any{"status": "paid"}},
{"id": "01JTRX...AB8", "embedding": vecB, "metadata": map[string]any{"status": "refunded"}},
},
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/vector/shop.orders/put_bulk",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req){
"inserted": 2,
"elapsed_ms": 14
} - At most 100,000 vectors per call — over that the engine answers
413, not400. - An empty
vectorsarray is a200withinserted: 0and no write. - Duplicate ids inside one batch are last-writer-wins. The whole batch is one atomic write.
- Single insert returns
201 Createdwith an empty body — don't try to parse it.
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.
Query — top-k.
query, k and dim are required; everything else has a default. The field is k — there is no top_k alias.
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"
}'hits = db.vector_topk(
"shop.orders",
query=[0.0124, -0.0883, 0.0451],
k=5,
dim=3,
metric="cosine",
)
for h in hits:
print(h.id, h.score)const hits = await db.vectorTopk("shop.orders", {
query: [0.0124, -0.0883, 0.0451],
k: 5,
dim: 3,
metric: "cosine",
});
for (const h of hits) console.log(h.id, h.score);hits, err := db.VectorTopK(ctx, "shop.orders", originchain.VectorTopKRequest{
Query: []float32{0.0124, -0.0883, 0.0451},
K: 5,
Dim: 3,
Metric: "cosine",
})
for _, h := range hits {
fmt.Println(h.ID, h.Score)
}[
{ "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.
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 |
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"
}'# Only the legacy method carries `mode`; the typed
# db.vector.topk() namespace does not accept it.
hits = db.vector_topk(
"shop.orders",
query=query_768d,
k=10,
dim=768,
metric="cosine",
mode="fast",
)const hits = await db.vectorTopk("shop.orders", {
query: query768d,
k: 10,
dim: 768,
metric: "cosine",
mode: "fast",
});hits, err := db.VectorTopK(ctx, "shop.orders", originchain.VectorTopKRequest{
Query: query768d,
K: 10,
Dim: 768,
Metric: "cosine",
Mode: originchain.ModeFast,
})- Omitting
modegives youhigh_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". modeapplies to the default graph index only. Onivfandivf_pqqueries it is accepted and then ignored — those paths have no beam width. Usenprobethere 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.
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.
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.
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" }
}'hits = db.vector.topk(
"shop.orders",
query_768d,
k=10,
metric="cosine",
filter={"status": "paid"},
)const hits = await db.vectorTopk("shop.orders", {
query: query768d,
k: 10,
dim: 768,
metric: "cosine",
filter: { status: "paid" },
});hits, err := db.VectorTopK(ctx, "shop.orders", originchain.VectorTopKRequest{
Query: query768d,
K: 10,
Dim: 768,
Metric: "cosine",
Filter: map[string]any{"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.
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.
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.
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.
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.
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" }'# No SDK wraps the preset index build - call the endpoint directly.
import httpx
r = httpx.post(
f"https://{OC_HOST}/v1/tenants/{OC_TENANT}/vector/shop.orders/create-ivf-pq-index",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={"preset": "compressed"},
timeout=None, # training reads the whole corpus
)
print(r.json()["pq_m"], r.json()["partitions"])// No SDK wrapper for the preset index build - using fetch directly.
const r = await fetch(
`https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}/vector/shop.orders/create-ivf-pq-index`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ preset: "compressed" }),
},
);
const cfg = await r.json();
console.log(cfg.pq_m, cfg.partitions);// No SDK wrapper for the preset index build - using net/http directly.
body, _ := json.Marshal(map[string]any{"preset": "compressed"})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/vector/shop.orders/create-ivf-pq-index",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req){
"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.
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.
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.
compressedwhen the corpus no longer fits comfortably in memory and you want the smallest possible resident footprint.high_recallwhen 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
}' nprobedefaults toceil(sqrt(partitions)). Higher visits more cells: better recall, more work.- Valid range is
1to 256.0is a400(nprobe must be >= 1); above 256 is a400naming the cap. nprobeis ignored on the default graph index — it only means something forivfandivf_pq.- In the Python SDK
nprobeis available on the typeddb.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.
Plain ivf (without quantization) is driven by four separate endpoints, wrapped by the Python SDK only:
POST …/train-and-install-centroids—db.vector.train_and_install_centroids(table, partitions=…). Same four-per-partition minimum.POST …/install-centroids—db.vector.install_centroids(table, centroids)for centroids you trained elsewhere.GET …/centroids—db.vector.centroids(table), a truncated preview.GET …/ivf-rebalance-status—db.vector.rebalance_status(table), reporting skew and whether a rebalance isnone,recommendedorrequired.
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.
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.
Delete.
# 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"] }'db.vector.delete("shop.orders", "01JTRX9KQ3YH8K2WMX0F5JZAB7")
out = db.vector.delete_bulk("shop.orders", ["01JTRX...AB7", "01JTRX...AB8"])
print(out.deleted_count, out.missing_count)const out = await db.vectorDelete("shop.orders", "01JTRX9KQ3YH8K2WMX0F5JZAB7");
console.log(out.deleted);
// Bulk delete is not wrapped in the TypeScript SDK - POST /delete-bulk directly.// The Go SDK has no vector delete method - call the endpoint directly.
req, _ := http.NewRequestWithContext(ctx, "DELETE",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+
"/vector/shop.orders/01JTRX9KQ3YH8K2WMX0F5JZAB7", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, err := http.DefaultClient.Do(req)- Single delete is idempotent: deleting an id that isn't there is a
200with{ "deleted": false }, never a 404. - Bulk delete caps at 10,000 ids and returns
{ deleted_count, missing_count }. - Both accept an optional
indexselector (hnswby default) — it must match the index the vector was written under. - Vector ids are capped at 1024 characters and may not be empty.
Limits and gotchas.
| Limit | Value |
|---|---|
Max k per query | 4096 |
| Max vectors per bulk insert | 100,000 |
| Max ids per bulk delete | 10,000 |
Max nprobe | 256 |
| Max IVF partitions | 65,536 |
Max PQ subspaces (pq_m) | 64 |
| Vectors sampled for index training | 1,000,000 |
| Max vector id length | 1024 chars |
| Dimensionality | > 0, no upper bound |
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.
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.
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.
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.
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.