Guides Markdown

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. A PATCH keeps 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:

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. score is the cosine similarity (cosine namespaces) or the euclidean distance (euclidean ones). Ties are broken by id.
  • limit is 10 by default, at most 1,000.
  • scanned and reranked say how many candidates were looked at and reranked.
  • Answers carry cpu_ms and timed_out as 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-limit query returns limit hits 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:

OptionDefaultWhat it does
nprobe48Clusters probed in the biggest index (smaller indexes are scaled to it). More: better recall, slower.
rerank64Candidates reranked with fuller-precision vectors.
fullfalsetrue 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 full reranks.
  • 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