This tutorial builds a question-answering route on top of a vector index. The route searches a namespace for relevant documents, fetches the original text, asks a model to answer using that text, and returns the answer with source citations.
The agent itself is a plain typed function. The route owns HTTP. The vector index lives in Agentuity vector storage.
What you build
- a
POST /api/knowledge/indexroute that adds documents to a vector namespace - a
POST /api/knowledgeroute that answers a question with retrieved context and source IDs - one shared agent function that both routes can reuse
Install
npm install hono @agentuity/aigateway @agentuity/vector valibot zod@agentuity/vector and @agentuity/aigateway read Agentuity project credentials from the environment, so run the route with agentuity dev or a linked Agentuity project.
Index Documents
Indexing accepts a list of documents, calls vector.upsert() once per document, and returns the count. Upserts are idempotent: the same id overwrites the existing vector.
import { VectorClient } from '@agentuity/vector';
import * as v from 'valibot';
const documentSchema = v.object({
id: v.string(),
title: v.string(),
content: v.string(),
category: v.optional(v.string()),
});
const indexInputSchema = v.object({
documents: v.array(documentSchema),
});
type IndexInput = v.InferOutput<typeof indexInputSchema>;
const VECTOR_NAMESPACE = 'knowledge-base';
const vector = new VectorClient();
export async function indexDocuments(body: unknown): Promise<{ indexed: number }> {
const input = v.parse(indexInputSchema, body);
for (const doc of input.documents) {
await vector.upsert(VECTOR_NAMESPACE, {
key: doc.id,
document: `${doc.title}\n\n${doc.content}`,
metadata: {
title: doc.title,
category: doc.category ?? 'general',
indexedAt: new Date().toISOString(),
},
});
}
return { indexed: input.documents.length };
}
export type { IndexInput };document is the searchable text. Vector storage generates the embeddings server-side. Pass embeddings directly only when you already have vectors from another model.
Answer Questions
The answer function searches the namespace, fetches the original text for the matches with getMany(), and asks the model to write an answer that cites which source it used. The model output is validated by the schema, so the route can return a typed object straight to the client.
import { VectorClient } from '@agentuity/vector';
import { AIGatewayClient } from '@agentuity/aigateway';
import { z } from 'zod';
type KnowledgeMetadata = {
readonly title: string;
readonly category: string;
readonly indexedAt: string;
} & Record<string, unknown>;
const answerSchema = z.object({
answer: z.string().describe('Answer in plain prose, citing sources with [1], [2], etc.'),
citedIndexes: z
.array(z.number().int().min(1))
.describe('1-based indexes of the sources the answer used'),
});
type AnswerOutput = z.infer<typeof answerSchema>;
const VECTOR_NAMESPACE = 'knowledge-base';
const SEARCH_LIMIT = 5;
const MIN_SIMILARITY = 0.7;
const vector = new VectorClient();
const gateway = new AIGatewayClient();
interface AnswerResult {
readonly answer: string;
readonly sources: ReadonlyArray<{
readonly id: string;
readonly title: string;
readonly relevance: number;
}>;
readonly confidence: number;
}
export async function answerQuestion(question: string): Promise<AnswerResult> {
const matches = await vector.search<KnowledgeMetadata>(VECTOR_NAMESPACE, {
query: question,
limit: SEARCH_LIMIT,
similarity: MIN_SIMILARITY,
});
if (matches.length === 0) {
return {
answer: "I couldn't find relevant information to answer that question.",
sources: [],
confidence: 0,
};
}
// search() returns ids and metadata, but not the original document text
// getMany() returns a Map keyed by the same `key`, with `document` populated
const documents = await vector.getMany<KnowledgeMetadata>(
VECTOR_NAMESPACE,
...matches.map((match) => match.key)
);
const context = matches
.map((match, i) => {
const stored = documents.get(match.key);
const document = stored?.document ?? '';
return `[${i + 1}] ${document}`;
})
.join('\n\n');
const model = 'anthropic/claude-opus-4-8';
const { data } = await gateway.completeStructured({
model,
messages: [
{
role: 'system',
content:
'Answer using only the provided context. If the context is insufficient, say so. Cite sources with [1], [2], etc.',
},
{
role: 'user',
content: `Context:\n${context}\n\nQuestion: ${question}`,
},
],
response_schema: { name: 'rag_answer', schema: answerSchema },
});
const output = answerSchema.parse(data);
return {
answer: output.answer,
sources: matches.map((match, i) => ({
id: match.key,
title: match.metadata?.title ?? `Document ${i + 1}`,
relevance: match.similarity,
})),
confidence:
matches.reduce((sum, match) => sum + match.similarity, 0) / matches.length,
};
}
export type { AnswerOutput };A few details worth calling out:
vector.search()returnsid,key,similarity, optionalmetadata, and optionalexpiresAt. It does not return the original document text.vector.getMany()returns aMap, with each entry shaped like a search result plusdocumentandembeddings.- The metadata generic must satisfy
Record<string, unknown>. Use atypeintersection (& Record<string, unknown>) so the generic accepts an interface that has known fields. - The structured output schema's
.describe()strings are passed to the model and reduce drift on enum or numeric fields.
Wire the Routes
The Hono app pulls both functions together. Validation happens at the route boundary; the agent functions take typed input.
import { Hono } from 'hono';
import { indexDocuments } from './lib/index-documents';
import { answerQuestion } from './lib/answer-question';
import * as v from 'valibot';
const askSchema = v.object({
question: v.string(),
});
const app = new Hono();
app.post('/api/knowledge/index', async (c) => {
const body: unknown = await c.req.json();
const result = await indexDocuments(body);
return c.json(result);
});
app.post('/api/knowledge', async (c) => {
const body: unknown = await c.req.json();
const { question } = v.parse(askSchema, body);
const result = await answerQuestion(question);
return c.json(result);
});
export default app;Try It
Run the app locally:
agentuity devIndex a few documents:
curl -X POST http://localhost:3500/api/knowledge/index \
-H 'content-type: application/json' \
-d '{
"documents": [
{
"id": "doc-1",
"title": "Getting Started",
"content": "Agentuity ships service clients you can call from any framework. Start with `agentuity create`."
},
{
"id": "doc-2",
"title": "Storage Options",
"content": "Agentuity exposes key-value, vector, object, and durable stream storage as direct clients."
}
]
}'Ask a question:
curl -X POST http://localhost:3500/api/knowledge \
-H 'content-type: application/json' \
-d '{ "question": "What kinds of storage does Agentuity offer?" }'The response includes the model answer, the source IDs the model cited, and an aggregate confidence score derived from match similarity.
Variations
-
Filter results before retrieval. Search broadly, then narrow the result set before calling
getMany().const matches = await vector.search<KnowledgeMetadata>('knowledge-base', { query: question, }); const pricingMatches = matches.filter( (match) => match.metadata?.category === 'pricing' ); const documents = await vector.getMany<KnowledgeMetadata>( 'knowledge-base', ...pricingMatches.map((match) => match.key) ); -
Stream the answer. Use
AIGatewayClient.streamRequest()and return aResponsewhose body is the stream. See Chat and Streaming. -
Run indexing in the background. Move
indexDocumentsbehind a queue when documents arrive faster than the route should handle them. See Background Work. -
Track answer quality. Feed the answer and retrieved context to a judge model to grade whether the answer was grounded. See Agents for the agent-as-function pattern.
Next Steps
- Vector Storage: metadata generics, TTL behavior, and search options
- Agents: the plain-function pattern for model-backed work
- Product Search: another vector pattern, with metadata filters and recommendations