Retrievable Glue
The query builder is the standalone store. This is how that store becomes part of a turn. A TurnRunner doesn't know what a vector database is — it knows the four Retrievable callbacks of its retrieval contract (fetch, store, mutate, delete). createVectorRetrievableCallbacks() is the adapter between the two: hand it a store and a collection, get back the four callbacks, drop them into your TurnRunnerConfig. The agent now retrieves from your vector index without a line of glue code you had to write.
One call, four callbacks
import { createVectorRetrievableCallbacks } from '@nhtio/adk/batteries/vector'
const {
fetchRetrievablesCallback,
storeRetrievableCallback,
mutateRetrievableCallback,
deleteRetrievableCallback,
} = createVectorRetrievableCallbacks({
store: vs, // a CallableVectorStore
collection: 'docs',
trustTier: 'first-party', // required — see below
topK: 5,
})
// drop straight into the TurnRunner retrieval surface
const runner = new TurnRunner({
// …
fetchRetrievablesCallback,
storeRetrievableCallback,
mutateRetrievableCallback,
deleteRetrievableCallback,
// …
})That's the whole integration. What each callback does:
| Callback | Does |
|---|---|
fetchRetrievablesCallback | Derives a query from the turn, searches the collection, maps hits → Retrievable[] |
storeRetrievableCallback | Retrievable → upsert (the store encodes the document text) |
mutateRetrievableCallback | Same as store — an upsert replaces |
deleteRetrievableCallback | Deletes the record by id |
trustTier is required, and never guessed
trustTier is the one option with no default, and that's a security decision, not an oversight. A trust tier is a deployer assertion about how much the runtime should trust a piece of content — it is never inferred from the data itself, because inferring "this is first-party because its source looks internal" is exactly the kind of guess an attacker games. So you state it:
// a flat tier for the whole collection
createVectorRetrievableCallbacks({ store: vs, collection: 'docs', trustTier: 'first-party' })
// or a function, if the collection mixes provenance
createVectorRetrievableCallbacks({
store: vs,
collection: 'mixed',
trustTier: (m) => (m.metadata?.source === 'internal' ? 'first-party' : 'third-party'),
})Even the function form is you writing the rule, in code you can read and test — not the battery sniffing a field and hoping. This mirrors the rule documented in Bring your own retrieval: the kit will not assign trust on your behalf.
How fetch builds its query
By default the glue retrieves against the last user message in the turn. It calls ctx.fetchMessages(), walks back to the most recent role: 'user' message, and uses its content as the query text; if there's no user message, it returns [] (nothing to search for, so nothing retrieved). Then it runs the builder you already know:
store(collection)
.nearText(queryText) // or .nearVector(vec) if deriveQuery returns a number[]
.whereRaw(filter) // if you passed a `filter` option
.select('id', 'document', 'metadata')
.limit(topK) // default 5…and maps each VectorMatch into a new Retrievable({ ...toRetrievable(m), trustTier }). Two hooks let you change the behaviour without rewriting the callback:
deriveQuery(ctx)— return astring(text, encoded by the store), anumber[](a precomputed query vector), orundefined(skip retrieval this turn). Override it to query from something other than the last user message — a summarized conversation, a rewritten query, a HyDE passage.toRetrievable(match)— map aVectorMatchto aRawRetrievable(minustrustTier, which the glue applies). The default pullsid,contentfromdocument,source/kindfrom metadata when present,score, andcreatedAt/updatedAt(from metadata or now). Override it when your metadata shape differs.
How store/mutate/delete work
Writing is the inverse, and it leans on the store's encoder:
// store / mutate: a Retrievable becomes an upsert with the document text and no vector —
// the store encodes the document (so the store needs an encoder or built-in encoding)
await vs(collection).upsert([{
id: r.id,
document: await r.contentString(),
metadata: { trustTier: r.trustTier, createdAt, updatedAt, source, kind },
}])
// delete: by id
await vs(collection).whereIn('id', [id]).delete()The glue carries no encoder of its own. It passes the document text through the builder and lets the store resolve it — via the store's encoder option or its built-in encoding. If the store has neither, a store/mutate call surfaces E_VECTOR_STORE_ENCODER_REQUIRED, same as a text upsert anywhere else (see Encoders). mutate is literally store — an upsert is a replace — so updating a Retrievable re-encodes and overwrites it.
Where this sits
This is the downstream seam named on the hub: the Embeddings battery (or any VectorEncoderFn) feeds vectors in; this glue feeds search results out, into the Retrievable callbacks that Bring your own retrieval describes. The store stays a general-purpose vector database; the glue is the thin, optional layer that makes it a turn's memory.
Where to go next
- Bring your own retrieval — the
Retrievableprimitive, trust tiers, and the retrieval pipeline these callbacks plug into. - Encoders: vectors or text — what the store needs for store/mutate to encode documents.
- The Ask ADK showcase — a full retrieval pipeline (rewrite, HyDE, rerank, abstain) built above a store like this.