search — AI semantic and hybrid retrieval
| Subpath | @noy-db/hub (root export — no separate subpath) |
| Factory | withSearch() |
| Layer | with-lookup |
This service is in preview: the API on this page is usable but may still change before it stabilizes.
withSearch() factory. This page: matching meaning — semantic retrieval via an embeddings encoder you configure per collection, similarTo(), and hybrid lexical⊕semantic fusion. The other page: matching words — full-text search() and indexed lexical retrieve(). Opting in once enables both, but semantic modes additionally require the encoder (a text→vector function you supply).What it does
Semantic search finds records by meaning instead of exact words: "overdue invoice" can match a record that says "payment past due" even though no word overlaps. It works through embeddings — you configure an embeddings option on a collection with a function that turns text into a vector (a fixed-length array of numbers), call it your encoder. noy-db runs that encoder over each record as you write it, encrypts the resulting vector under the collection's data-encryption key (DEK) — the same per-collection key that encrypts every record body — and stores it in a reserved _vec side-car, one per record. To search, noy-db encodes your query the same way and ranks stored vectors by cosine similarity (how closely two vectors point in the same direction) against the query vector, entirely after decrypting in memory — the storage backend never sees a vector or the text it came from.
This page also covers hybrid retrieval: fusing this semantic ranking with the keyword-based ranking from Search into one result list, and the within option for intersecting a retrieve call with a structured query.
0.3.0-pre.13 a _vec sidecar is keyed by
<collection>/<recordId>, not a bare record id — the store bucket stays the literal '_vec',
but the row id is composite. Before this, two collections that happened to share a record id
shared one sidecar, so a write in one collection could clobber or delete the other's vector, and a
similarTo() / cold semantic retrieve() that met a foreign same-id row threw an uncaught
TamperedError. This was never a confidentiality leak — each collection has its own DEK and
AES-GCM auth-tag verification turns a wrong-key decrypt into a throw, never wrong plaintext — but
it was an id-collision bug (clobber + crash). See migration below if
you already populated embeddings before 0.3.0-pre.13.Key properties:
- Zero-knowledge — the store sees only ciphertext per
_vec/<id>record; vector values and source text never leave the trusted tier (the part of the stack that holds decryption keys and plaintext). - No bundled model — the
encodefunction is yours to supply, calling any local or remote embedding API. noy-db ships no model. - Brute-force cosine — every query scores every stored vector; there is no approximate-nearest-neighbour index (HNSW/IVF) yet, so this is best suited to collections up to roughly the tens of thousands of records.
- Encrypted-local only — a backend that received plaintext vectors (a managed vector database) could have those vectors inverted back toward the source text, defeating zero-knowledge. That integration path does not exist yet; vectors stay encrypted-local.
Prerequisites
searchStrategy: withSearch()— semantic and hybrid retrieval are part of the same opt-in as lexical search; one factory enables both.- An
embeddingsconfig on the collection — your encoder function plus matchingdimandmodel. The encoder is a part you supply; whatever runs inside it sees plaintext, so an on-device encoder keeps the zero-knowledge guarantee end-to-end while a hosted API adds that provider as a reader. - For
mode: 'hybrid': the sameembeddingsconfig must be present, orretrieve()throws. - For
withinintersection: eager mode (the default) — lazy collections are unsupported there.
Opt in
Semantic and hybrid retrieval are part of the same opt-in search capability as lexical retrieve(): pass searchStrategy: withSearch() to createNoydb(), then declare embeddings on the collection you want to search semantically.
import { createNoydb, withSearch } from '@noy-db/hub'
const db = await createNoydb({
user: 'alice',
secret: 'alice-secret-2026',
searchStrategy: withSearch(),
})
const vault = await db.openVault('books')
interface Doc { id: string; text: string }
const docs = vault.collection<Doc>('docs', {
embeddings: {
source: 'text', // field path(s) to embed
encode: async (text) => myEmbeddingApi(text), // your encoder — see "Supplying an encode function" below
dim: 1536, // expected vector length
model: 'text-embedding-3-small', // version tag, checked on every retrieval
},
})
sourceis a field path or array of field paths; values are joined with a space before encoding.encodeis async; if it throws, the write that triggered it fails.dimis enforced on every write — anencode()result of the wrong length throwsEmbeddingDimMismatchError.- CRDT collections are unsupported: declaring both
embeddingsandcrdton the same collection throws immediately. Use a non-CRDT collection for semantic search. - A record whose source field is blank or missing yields a near-zero vector — it is stored and retrievable but scores low on every query.
Supplying an encode function
encode is a strategic seam. An embedding is how modern AI turns meaning into numbers: a model trained on vast amounts of text maps each string to a point in vector space where "payment past due" lands near "overdue invoice". Embedding quality is one of the fastest-moving parts of AI — so noy-db bundles no model and makes encode a hook you own. You can drop in a better model the day it ships, match cost, latency, and quality to your app, and (by running it on-device) get AI-grade semantic search without giving up zero-knowledge. The database stays a small, model-agnostic vector store; the intelligence is yours to choose and upgrade.In practice, encode receives the record's source text and must return a Float32Array of length dim; set the collection's dim and model to match the encoder you pick (each tab notes both). The one zero-knowledge caveat: whatever you call inside encode sees your text as plaintext, so an on-device encoder keeps the guarantee end-to-end, while a hosted API adds that provider as a party that can read your data.
// On-device, nothing leaves the machine — the zero-knowledge-preserving choice.
import { pipeline } from '@xenova/transformers'
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
async function myEmbeddingApi(text: string): Promise<Float32Array> {
const out = await extractor(text, { pooling: 'mean', normalize: true })
return new Float32Array(out.data)
}
// collection config → dim: 384, model: 'all-MiniLM-L6-v2'
// Hosted API — sees your text in plaintext. Matches the dim/model in the config above.
async function myEmbeddingApi(text: string): Promise<Float32Array> {
const res = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'text-embedding-3-small', input: text }),
})
const { data } = await res.json()
return new Float32Array(data[0].embedding)
}
// collection config → dim: 1536, model: 'text-embedding-3-small'
// Anthropic ships no embeddings endpoint — Claude is generative-only. Its
// recommended embeddings provider is Voyage AI. Hosted, so it sees plaintext.
async function myEmbeddingApi(text: string): Promise<Float32Array> {
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'voyage-3', input: text }),
})
const { data } = await res.json()
return new Float32Array(data[0].embedding)
}
// collection config → dim: 1024, model: 'voyage-3'
// A local Ollama server — on-device, larger models than run in-browser.
async function myEmbeddingApi(text: string): Promise<Float32Array> {
const res = await fetch('http://localhost:11434/api/embeddings', {
method: 'POST',
body: JSON.stringify({ model: 'nomic-embed-text', prompt: text }),
})
const { embedding } = await res.json()
return new Float32Array(embedding)
}
// collection config → dim: 768, model: 'nomic-embed-text'
Without searchStrategy: withSearch(), every method below (retrieve, similarTo, and the write-time embedding compute) throws SearchNotEnabledError telling you to opt in.
Usage
Semantic retrieval — retrieve(query, { mode: 'semantic' })
const hits = await docs.retrieve('overdue invoice', { mode: 'semantic' })
[
{ "id": "inv-0231", "score": 0.912, "rank": 1, "field": "(vector)", "snippet": "" },
{ "id": "inv-0198", "score": 0.887, "rank": 2, "field": "(vector)", "snippet": "" },
{ "id": "inv-0104", "score": 0.845, "rank": 3, "field": "(vector)", "snippet": "" }
]
score is cosine similarity (higher is closer). field is always the literal string '(vector)' for a semantic hit — there's no matched field span to report, unlike a lexical hit. Options:
// Limit to top 5 (defaults to 10 when omitted):
const top5 = await docs.retrieve('overdue invoice', { mode: 'semantic', limit: 5 })
// → same shape as the `hits` array above, truncated to at most 5 entries
// Only hits above a cosine-similarity floor:
const strong = await docs.retrieve('quarterly report', { mode: 'semantic', minScore: 0.8 })
[
{ "id": "rpt-Q2-2026", "score": 0.913, "rank": 1, "field": "(vector)", "snippet": "" }
]
Hits with score < 0.8 are dropped entirely, not just ranked lower — that's why strong can come back shorter than an unfiltered call even when more records exist.
RetrieveHit<T> shape
interface RetrieveHit<T> {
readonly id: string
readonly score: number // cosine similarity for semantic hits; RRF score for hybrid hits (see below)
readonly rank: number // 1-based
readonly field: string // '(vector)' for a semantic-only hit
readonly snippet: string // '' for a semantic-only hit — there's no text span to extract
readonly locale?: string // set only on lexical hits sourced from an i18n/dictKey field
readonly record?: T // present only when includeRecord: true
}
collection.similarTo(vector, opts?) — bring your own vector
Use this when you've already encoded the query yourself (or you're comparing against a vector that didn't come from a text query at all).
const qVec = await myEncoder('quarterly report')
const hits = await docs.similarTo(qVec, { k: 10 })
[
{ "id": "rep-Q1", "score": 0.934, "rank": 1, "field": "(vector)", "snippet": "" },
{ "id": "rep-Q4-prior", "score": 0.79, "rank": 2, "field": "(vector)", "snippet": "" }
]
Options: k (default 10, how many hits to return), minScore, includeRecord.
query().similarTo().where() chaining — semantic search and a structured filter in one fluent expression — does not exist yet. Intersect two result sets manually instead:
const vecHits = await docs.similarTo(qVec, { k: 20 })
const active = new Set((await docs.query().where('status', '==', 'active').run()).map((r) => r.id))
const filtered = vecHits.filter((h) => active.has(h.id))
// → the same RetrieveHit objects from vecHits, minus any whose id isn't in `active`;
// rank/score are left as similarTo() computed them, not re-numbered
Hybrid retrieval — retrieve(query, { mode: 'hybrid' })
mode: 'hybrid' fuses the lexical ranking from Search's retrieve() with the semantic ranking above, using Reciprocal Rank Fusion (RRF): run both rankings, then score each id by summing 1 / (k + rank) across every list it appears in (k defaults to 60), sort descending by that sum, and re-assign 1-based rank. A document that appears in both lists earns a contribution from each and typically outranks a document that only appears in one — even one that ranked higher within its own list. That's the benefit of hybrid retrieval: exact-token matches and semantic-proximity matches combine without needing to normalize BM25 scores against cosine scores.
const hits = await docs.retrieve('overdue invoice', { mode: 'hybrid' })
[
{ "id": "inv-0231", "score": 0.0328, "rank": 1, "field": "title", "snippet": "…$4,200 overdue invoice for…" },
{ "id": "inv-0104", "score": 0.0164, "rank": 2, "field": "(vector)", "snippet": "" }
]
score here is the RRF score, not BM25 or cosine — with two fused lists it tops out at 2/61 ≈ 0.033, so don't compare it to the scores from a plain lexical or semantic call. When the same id has a hit in both lists, the lexical hit's field/snippet/locale win (they're more informative than the '(vector)' placeholder); record is taken from whichever hit carries it.
Requires embeddings configured on the collection — without it, retrieve(query, { mode: 'hybrid' }) throws. Hybrid retrieval adds no new store artifacts: it's pure in-memory computation over the lexical index, the vector set, and the eager cache, so it carries the same zero-knowledge guarantee as the lexical and semantic paths individually.
within — retrieve intersected with a structured query
within intersects a retrieve() result with a collection.query().where(...) predicate, and works with every mode ('lexical', 'semantic', 'hybrid').
const hits = await docs.retrieve('overdue invoice', {
mode: 'hybrid',
within: docs.query().where('status', '==', 'open'),
})
[
{ "id": "inv-0231", "score": 0.0328, "rank": 1, "field": "title", "snippet": "…$4,200 overdue invoice for…" }
]
within matches ids by reference identity against the same in-memory snapshot retrieve() already reads — no extra store reads. It requires eager mode (the default). Supported query operators: where, filter, and, or, and projection .join(). crossJoin is unsupported inside within (it produces new row objects that break id recovery) and throws a clear error if used.
limit is applied per-mode, before the within intersection — so { limit: 10, within } can return fewer than 10 hits even if more matching records exist beyond that cutoff. If the within query matches nothing, the result is an empty array.
fuseRetrieval(lists, opts?) — the fusion primitive standalone
retrieve({ mode: 'hybrid' }) calls this internally with [lexicalHits, semanticHits], but it's also exported directly for merging any set of pre-ranked RetrieveHit lists — for example, combining retrieve() results from multiple vaults.
import { fuseRetrieval } from '@noy-db/hub'
// also available from '@noy-db/hub/cargo'
const fused = fuseRetrieval([lexicalHits, semanticHits])
// → same shape as the mode: 'hybrid' result above — RRF-scored, re-ranked 1-based
const top5 = fuseRetrieval([listA, listB], { k: 60, limit: 5 })
// → up to 5 hits, sorted by descending RRF score
fuseRetrieval is pure — no I/O, no store access — and arity-generic: it accepts two or more lists, so it works equally for fusing modalities (lexical + semantic) or fusing per-source result sets from an external caller.
FuseOptions:
| Option | Default | Meaning |
|---|---|---|
strategy | 'rrf' | The only supported strategy today |
k | 60 | RRF constant; larger flattens the rank weighting |
limit | — | Truncate the fused output to the top N |
API reference
EmbeddingDescriptor
interface EmbeddingDescriptor {
readonly source: string | readonly string[]
readonly encode: (text: string) => Promise<Float32Array>
readonly dim: number
readonly model: string
}
Passed as the embeddings collection option (see Opt in).
RetrieveOptions<T>
| Option | Type | Meaning |
|---|---|---|
mode | 'lexical' | 'semantic' | 'hybrid' | Retrieval strategy; defaults to 'lexical' |
limit | number | Max hits to return |
minScore | number | Semantic-mode-only cosine floor |
includeRecord | boolean | Attach the decrypted record to each hit |
within | Query<T> | Intersect hits with a structured query (see above) |
collection.similarTo(vector, opts?)
similarTo(vector: Float32Array, opts?: { k?: number; minScore?: number; includeRecord?: boolean }): Promise<RetrieveHit<T>[]>
Raw-vector kNN over the decrypted, in-memory vector set. k defaults to 10.
collection.rebuildEmbeddings()
rebuildEmbeddings(): Promise<{ rebuilt: number; skipped: number }>
Force-re-derives every eligible record's _vec sidecar in one idempotent, resumable pass. Skips
(never refuses) elevated records, tombstones, and delete-markers. See
Rebuilding vectors.
Model-version guard
Every stored vector carries the model string from the descriptor at the time it was written. On retrieval, if any stored vector's model differs from the current descriptor's model, noy-db throws EmbeddingModelMismatchError — changing encoders silently would otherwise make cosine similarity meaningless across mismatched vector spaces.
Recovery: re-derive vectors across a whole collection in one pass with
rebuildEmbeddings() (below), or re-derive one at a time
by re-putting the affected records (each put() triggers a fresh encode() and a new _vec
write).
Rebuilding vectors — rebuildEmbeddings()
collection.rebuildEmbeddings() force-re-derives every eligible record's _vec sidecar in one
pass, without waiting for organic writes. It walks every live record, calls your encode() on each,
and re-persists the sidecar. Introduced in 0.3.0-pre.13 (#788) as the opt-in bulk closer for the
recall gap the collection-namespacing change left open (see migration).
const { rebuilt, skipped } = await docs.rebuildEmbeddings()
// → { rebuilt: 128, skipped: 3 }
- Opt-in and idempotent. Each id's re-derive is independent, so it is safe to re-run: a partial failure (e.g. a store error mid-walk) leaves the already-rebuilt sidecars in place, and re-running completes the rest.
- Elevated records are skipped, not refused. A record elevated above tier 0 is supposed to
have no
_vecsidecar — re-deriving one would write searchable plaintext-derived data above tier 0 — so each elevated record is counted inskippedand the walk continues. Tombstones, delete-marker rows, and a raw read racing a concurrent delete all decode tonulland are likewise skipped. This mirrors the tier-0 read gate described on tiers. - Gated like every other search method. A collection that never declared
embeddingsreturns{ rebuilt: 0, skipped: 0 }without touching the strategy; a collection that declaredembeddingsbut was created withoutsearchStrategy: withSearch()throwsSearchNotEnabledError, matchingput().
Migrating existing vectors
The collection-namespacing change (0.3.0-pre.13, #726) has no dual-read fallback — a legacy
bare-id _vec/<id> row is irreducibly ambiguous about which collection it belongs to once ids
collide, so reading one would be unsound. Instead the change fails safe toward not-found: a
legacy sidecar becomes unreachable ciphertext residue until its owning record is rewritten, never
surfacing under the wrong record.
If you populated embeddings before 0.3.0-pre.13, expect a recall gap for
un-rewritten records until you close it one of two ways:
- Organic self-heal.
embedOnWritere-derives and re-persists the sidecar on everyput(), so any record written after upgrading heals for free — sidecars are a pure function of live plaintext plus model, always re-derivable. - Bulk close. Call
rebuildEmbeddings()once per collection to re-derive every live record's sidecar immediately rather than waiting for writes.
forget() teardown
vault.forget(subject) crypto-shreds matching records and also drops each affected record's _vec/<id> side-car (best-effort, with any failures reported as residue on the returned ForgetResult, the same pattern used for the lexical and structured indexes). The in-memory vector set is marked dirty so the next retrieval reloads from the store without the forgotten vectors. This matters because a vector can be inverted back toward its source text (embedding inversion), so it's treated as sensitive as the record itself and must not survive an erasure.
Limits & gotchas
| Situation | Behaviour |
|---|---|
encode() returns the wrong length | EmbeddingDimMismatchError at write time |
Stored vector's model ≠ descriptor's model | EmbeddingModelMismatchError on retrieval; re-derive via rebuildEmbeddings() or re-put affected records |
Legacy bare-id _vec sidecar after the namespacing change | Unreachable residue (fails safe to not-found) until the record is rewritten or rebuildEmbeddings() runs |
Collection has no embeddings config | similarTo() and retrieve({ mode: 'semantic' }) throw |
Collection has no embeddings config, hybrid mode | retrieve({ mode: 'hybrid' }) throws |
searchStrategy: withSearch() not passed to createNoydb() | Every method on this page throws SearchNotEnabledError |
| Vector set marked dirty after a write | Rebuilt lazily on the next retrieve/similarTo call |
| Collection scale | Cosine scoring is brute-force (no HNSW/IVF); comfortable up to roughly tens of thousands of vectors |
crdt: true + embeddings on the same collection | Throws at collection construction |
crossJoin inside a within query | Throws — cross-join rows break id recovery |
See also
- Search — the lexical (
L0/L1) retrieval this page's hybrid mode fuses with, and theretrieve()options shared across all three modes. - Enclave — the DEK/keyring model that encrypts every
_vecside-car. - Forget — the erasure path whose teardown this page's
forget()section describes. - Proven by showcases
124-semantic-retrieveand125-hybrid-retrieve.