experimental

search — AI semantic and hybrid retrieval

Semantic and hybrid retrieval via `withSearch()`: an `embeddings` encoder per collection, `retrieve()`/`similarTo()` by meaning, and lexical⊕semantic fusion.
Subpath@noy-db/hub (root export — no separate subpath)
FactorywithSearch()
Layerwith-lookup

This service is in preview: the API on this page is usable but may still change before it stabilizes.

Two pages, one subsystem. This page and search — full-text are facets of a single retrieval engine behind the same 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.

Sidecars are collection-namespaced. Since 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 encode function 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 embeddings config on the collection — your encoder function plus matching dim and model. 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 same embeddings config must be present, or retrieve() throws.
  • For within intersection: 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
  },
})
  • source is a field path or array of field paths; values are joined with a space before encoding.
  • encode is async; if it throws, the write that triggered it fails.
  • dim is enforced on every write — an encode() result of the wrong length throws EmbeddingDimMismatchError.
  • CRDT collections are unsupported: declaring both embeddings and crdt on 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

Why 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'

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:

OptionDefaultMeaning
strategy'rrf'The only supported strategy today
k60RRF 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>

OptionTypeMeaning
mode'lexical' | 'semantic' | 'hybrid'Retrieval strategy; defaults to 'lexical'
limitnumberMax hits to return
minScorenumberSemantic-mode-only cosine floor
includeRecordbooleanAttach the decrypted record to each hit
withinQuery<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 _vec sidecar — re-deriving one would write searchable plaintext-derived data above tier 0 — so each elevated record is counted in skipped and the walk continues. Tombstones, delete-marker rows, and a raw read racing a concurrent delete all decode to null and are likewise skipped. This mirrors the tier-0 read gate described on tiers.
  • Gated like every other search method. A collection that never declared embeddings returns { rebuilt: 0, skipped: 0 } without touching the strategy; a collection that declared embeddings but was created without searchStrategy: withSearch() throws SearchNotEnabledError, matching put().

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. embedOnWrite re-derives and re-persists the sidecar on every put(), 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

SituationBehaviour
encode() returns the wrong lengthEmbeddingDimMismatchError at write time
Stored vector's model ≠ descriptor's modelEmbeddingModelMismatchError on retrieval; re-derive via rebuildEmbeddings() or re-put affected records
Legacy bare-id _vec sidecar after the namespacing changeUnreachable residue (fails safe to not-found) until the record is rewritten or rebuildEmbeddings() runs
Collection has no embeddings configsimilarTo() and retrieve({ mode: 'semantic' }) throw
Collection has no embeddings config, hybrid moderetrieve({ mode: 'hybrid' }) throws
searchStrategy: withSearch() not passed to createNoydb()Every method on this page throws SearchNotEnabledError
Vector set marked dirty after a writeRebuilt lazily on the next retrieve/similarTo call
Collection scaleCosine scoring is brute-force (no HNSW/IVF); comfortable up to roughly tens of thousands of vectors
crdt: true + embeddings on the same collectionThrows at collection construction
crossJoin inside a within queryThrows — cross-join rows break id recovery

See also

  • Search — the lexical (L0/L1) retrieval this page's hybrid mode fuses with, and the retrieve() options shared across all three modes.
  • Enclave — the DEK/keyring model that encrypts every _vec side-car.
  • Forget — the erasure path whose teardown this page's forget() section describes.
  • Proven by showcases 124-semantic-retrieve and 125-hybrid-retrieve.