Skip to content
3 min read · 656 words

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

typescript
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:

CallbackDoes
fetchRetrievablesCallbackDerives a query from the turn, searches the collection, maps hits → Retrievable[]
storeRetrievableCallbackRetrievable → upsert (the store encodes the document text)
mutateRetrievableCallbackSame as store — an upsert replaces
deleteRetrievableCallbackDeletes 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:

typescript
// 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:

typescript
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 a string (text, encoded by the store), a number[] (a precomputed query vector), or undefined (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 a VectorMatch to a RawRetrievable (minus trustTier, which the glue applies). The default pulls id, content from document, source/kind from metadata when present, score, and createdAt/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:

typescript
// 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