Skip to content
4 min read · 727 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.

    The match id becomes the fence nonce — keep it a non-path-shaped UUID

    A retrievable's id is rendered into its trust-envelope tag name (<retrieved_<id> source="…">). If your vector store's ids are path- or slug-shaped (e.g. chunk-assembly-events-9), small models copy them as citations (/assembly/events-9) and a doc-path validator rejects them → re-cite loop. Upsert documents with crypto.randomUUID() ids and keep the citable path in metadata.source. See the envelope footgun.

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 ​