Vector and hybrid search
Store embeddings with your documents and find the nearest, alone or narrowed by a text match and metadata filters.
Give documents a vector (an embedding) and SkimmerDB finds the nearest ones to a query vector. Make the embeddings yourself, as on this page, or let SkimmerDB make them from the text with your own OpenAI, Voyage, Gemini or Cohere key, or hosted: see Embeddings. A text match and metadata filters can narrow the candidates first (hybrid search): the answer is the nearest among the documents that pass. One copy of the data serves text, vectors and SQL, and vectors are as durable and as quick to become searchable as text.
Create a vector namespace
A namespace's vector settings are fixed for its life: dims (1 to 2,048, your embeddings'
length) and metric, cosine (the default; vectors are stored normalised) or euclidean.
B=https://api.skimmerdb.com/v1
curl -s $B/namespaces -H "Authorization: Bearer $KEY" \
-d '{"name": "notes", "vector": {"dims": 768, "metric": "cosine"}}'notes = db.create_namespace("notes", dims=768, exist_ok=True)const notes = await db.createNamespace("notes", { dims: 768, existOk: true });You can also skip this step: the first write of documents with vectors to a new name makes a namespace with vectors of the first vector's length and the cosine metric.
Note One embedding model per namespace. Switching models means a new namespace, written alongside the old one until it has caught up.
Write vectors
Put "vector": [...] in each document, or "vector_b64": base64 of little-endian float32s,
smaller than JSON numbers. text may then be left out; a vector is optional per document.
curl -s $B/namespaces/notes/docs -H "Authorization: Bearer $KEY" -d '{"docs": [
{"id": "n-17", "text": "Webhook timeouts on the api team", "meta": {"team": "api"}, "vector": [0.12, -0.03, ...]}
]}'notes.write([{"id": "n-17", "text": "Webhook timeouts on the api team", "meta": {"team": "api"}, "vector": embedding}])await notes.write([{ id: "n-17", text: "Webhook timeouts on the api team", meta: { team: "api" }, vector: embedding }]);- A vector of the wrong length, or a vector written to a namespace without vectors, is a
422. GET .../docs/{id}returns the vector. APATCHkeeps it unless it gives a new one.
Search by vector
POST /v1/namespaces/{name}/search with "vector" (or "vector_b64"):
curl -s $B/namespaces/notes/search -H "Authorization: Bearer $KEY" \
-d '{"vector": [0.12, -0.03, ...], "limit": 10}'res = notes.search(vector=query_embedding, limit=10)
for hit in res.hits:
print(hit.id, hit.score)const res = await notes.search("", { vector: queryEmbedding, limit: 10 });
for (const hit of res.hits) console.log(hit.id, hit.score);The answer:
{"namespace": "notes", "hits": [{"id": "n-17", "score": 0.912, "meta": {"team": "api"}, "written_at": "..."}],
"docs": 1344643, "took_ms": 0.73, "scanned": 15232, "reranked": 64, "partial": false,
"temperature": "warm", "store_gets": 0, "store_bytes": 0}- Hits come nearest first.
scoreis the cosine similarity (cosine namespaces) or the euclidean distance (euclidean ones). Ties are broken by id. limitis 10 by default, at most 1,000.scannedandrerankedsay how many candidates were looked at and reranked.- Answers carry
cpu_msandtimed_outas text searches do, and the same time limits.
Hybrid: vectors with text and filters
Add q (with regex if you like) and filter to a vector query. Both narrow the candidates
before ranking: the answer is the nearest among the documents that contain the text and pass
the filter.
curl -s $B/namespaces/notes/search -H "Authorization: Bearer $KEY" \
-d '{"vector": [0.12, -0.03, ...], "q": "timeout", "filter": {"team": "api"}, "limit": 10}'res = notes.search("timeout", vector=query_embedding, filter={"team": "api"}, limit=10)const res = await notes.search("timeout", { vector: queryEmbedding, filter: { team: "api" }, limit: 10 });This is the combination agents usually want: meaning from the vector, exactness from the text (a name, an error code, an id), and scope from the filter (a user, a team, a time window).
- A hybrid query takes the text's candidates from the index, without reading documents, and ranks them; only the nearest are checked for the text.
- A text that most documents hold is checked on the nearest vectors instead.
- A top-
limitquery returnslimithits whenever that many documents qualify; otherwise the answer says"partial": true.
Accuracy
The defaults give recall@10 of about 0.97 on 1.3M 768-dimension vectors, in under a millisecond warm. Three knobs trade speed for accuracy:
| Option | Default | What it does |
|---|---|---|
nprobe | 48 | Clusters probed in the biggest index (smaller indexes are scaled to it). More: better recall, slower. |
rerank | 64 | Candidates reranked with fuller-precision vectors. |
full | false | true reranks with the vectors as written (float32) rather than their int8 copies. |
res = notes.search(vector=query_embedding, limit=10, nprobe=96, rerank=128)const res = await notes.search("", { vector: queryEmbedding, limit: 10, nprobe: 96, rerank: 128 });How it works
- Recent writes are searched exactly, by brute force.
- Compaction builds an IVF index per segment: k-means centroids over RaBitQ 1-bit codes,
with int8 rows for reranking. The float32 vectors as written are kept in object storage for
fullreranks. - Cold queries read the namespace's manifest and a small map of its indexes side by side, then the clusters a query probes (with their documents' ids and metadata) and the newest writes side by side: two rounds to object storage, without downloading the rest of the namespace. A cold query with a filter or text opens the namespace instead.
Next
- Search and filters for the text and filter syntax.
- Agent memory: a recipe that uses hybrid search.
- Performance and cost.