> Complete content index: https://sirannon.sondelali.com/llms.txt

# Retrieval with Narsil

> Answer a question asked in a person's own words by filling a Narsil index from an AI agent's notes and keeping it in step with Sirannon's change feed, and keep one agent's search out of another agent's notes.

URL: https://sirannon.sondelali.com/docs/agent-retrieval
Section: AI agents
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

Consider a student who asks a university's advising agent how to get help paying for a term abroad. The agent answers that it has no note on the subject, although the table already holds the note that student needs, filed under a subject nobody phrased that way.

A lookup by key works where you already know which row you need, while a person asking in their own words names no subject the AI agent ever stored. Full-text search covers the rest, because it scores each note by the words in the note itself. [Narsil](https://narsil.sondelali.com) holds that index while Sirannon stays the system of record, and the change feed is what keeps the two in step.

One index over every agent's notes returns hits to whichever agent queries it. The authors of a paper on multitenant retrieval put the cause plainly: retrieval systems rank documents 'by relevance ... not by authorization', which lets a query from one tenant surface another tenant's confidential data whenever that data scores highest ([arXiv 2605.05287](https://arxiv.org/abs/2605.05287)). The filter in the last section keeps each agent's search inside its own notes.

The desk keeps its notes in one table, with `agent_id` on every row. `advising-agent` answers students about courses and exchanges, while `funding-agent` keeps notes on the money each student asks for. An index stays one store however you split the databases behind it, and the same filter is therefore the gate even where every agent has a file of its own. Each code block below holds one whole file.

## Ask in a student's own words

`recall` reads the notes filed under one subject, which is the lookup an agent makes where it already holds the subject's name. The student names none of the subjects on the table, and the query therefore matches no row.

`adviserModel` is a mock, which is why this file prints the answers below word for word on every run. Pass a model string in its place when you want a real model to phrase them.

<CodeGroup defaultTab="notes.ts">

```ts title="notes.ts"

const ADVISER = 'advising-agent'

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const db = await sirannon.open('student-services', './data/student-services.db')

await db.execute(`CREATE TABLE note (
  id INTEGER PRIMARY KEY,
  agent_id TEXT NOT NULL,
  subject TEXT NOT NULL,
  body TEXT NOT NULL
)`)

const NOTES: string[][] = [
  [
    ADVISER,
    'term abroad',
    'A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs',
  ],
  [ADVISER, 'Kraków exchange', 'The Kraków exchange opens in the spring semester only'],
  ['funding-agent', 'Ngozi Adeyemi', 'Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad'],
]

await db.executeBatch('INSERT INTO note (agent_id, subject, body) VALUES (?, ?, ?)', NOTES)

const adviserTools = {
  recall: tool({
    description: 'Read the notes this agent holds under one subject.',
    inputSchema: z.object({ subject: z.string() }),
    execute: async ({ subject }: { subject: string }): Promise<Note[]> =>
      db.query<Note>('SELECT subject, body FROM note WHERE agent_id = ? AND subject = ?', [ADVISER, subject]),
  }),
}

const askAdviser = async (): Promise<string> => {
  const adviser = new ToolLoopAgent({
    model: adviserModel(),
    instructions: 'Answer the student from the notes this agent holds.',
    tools: adviserTools,
  })
  const reply = await adviser.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, adviserAnswered: await askAdviser() }, null, 2))

await sirannon.shutdown()
```

```json result open
{
  "question": "How do I get help paying for a term abroad?",
  "adviserAnswered": "I hold no note under 'help paying for a term abroad'."
}
```

```ts title="mock-model.ts"

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export const QUESTION = 'How do I get help paying for a term abroad?'
export const SUBJECT_ASKED = 'help paying for a term abroad'

export interface Note {
  subject: string
  body: string
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const answered = (prompt: unknown, toolName: string): unknown => {
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) return part.output?.value
    }
  }
  return undefined
}

const callTool = (toolName: string, input: Record<string, unknown>) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

export const adviserModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const held = answered(prompt, 'recall') as Note[] | undefined
      if (held === undefined) return callTool('recall', { subject: SUBJECT_ASKED })
      if (held.length === 0) return say(`I hold no note under '${SUBJECT_ASKED}'.`)
      return say(`Here is what I hold: ${held.map((note) => note.body).join('; and ')}.`)
    },
  })
```

</CodeGroup>

The note the student needs is already on the table, filed under the subject 'term abroad'. The agent asks for 'help paying for a term abroad', which is the student's own phrasing, and an equality test on the subject column matches only the string somebody filed the note under.

## Search the notes by what they say

`createNarsil` starts a search engine inside this process, and `createIndex` declares the three fields in each document.

The three notes reach the table before anything watches it, and no change event covers a write made that early. One `insertBatch` over a read of the table therefore fills the index with what is already there. `db.watch` records every change from that point, and the subscription applies each one to the index with a single call per kind of event.

Sirannon calls that subscriber without waiting for it. An async callback that returns its promise therefore sends a rejection to `onError`, and `onError` is where that rejection stops.

`searchNotes` puts the student's question to that index, and the [full-text search page](https://narsil.sondelali.com/docs/full-text-search) covers the scoring, the stemming, and the fuzzy matching behind it.

<CodeGroup defaultTab="notes.ts">

```ts title="notes.ts"

import { createNarsil } from '@delali/narsil' // [!code ++]

const ADVISER = 'advising-agent'

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const db = await sirannon.open('student-services', './data/student-services.db')

await db.execute(`CREATE TABLE note (
  id INTEGER PRIMARY KEY,
  agent_id TEXT NOT NULL,
  subject TEXT NOT NULL,
  body TEXT NOT NULL
)`)

const NOTES: string[][] = [
  [
    ADVISER,
    'term abroad',
    'A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs',
  ],
  [ADVISER, 'Kraków exchange', 'The Kraków exchange opens in the spring semester only'],
  ['funding-agent', 'Ngozi Adeyemi', 'Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad'],
]

await db.executeBatch('INSERT INTO note (agent_id, subject, body) VALUES (?, ?, ?)', NOTES)

// [!code ++:35]
interface NoteRow {
  id: number
  agent_id: string
  subject: string
  body: string
}

const asDocument = (row: NoteRow) => ({
  id: String(row.id),
  agentId: row.agent_id,
  subject: row.subject,
  body: row.body,
})

const narsil = await createNarsil()

await narsil.createIndex('student_notes', {
  schema: { agentId: 'string', subject: 'string', body: 'string' },
  language: 'english',
})

const onTheTable = await db.query<NoteRow>('SELECT id, agent_id, subject, body FROM note ORDER BY id')
await narsil.insertBatch('student_notes', onTheTable.map(asDocument))

await db.watch('note')

db.on('note').subscribe<NoteRow>(
  async (event) => {
    if (event.type === 'insert') await narsil.insert('student_notes', asDocument(event.row))
    else if (event.type === 'update') await narsil.update('student_notes', String(event.row.id), asDocument(event.row))
    else if (event.oldRow !== undefined) await narsil.remove('student_notes', String(event.oldRow.id))
  },
  { onError: (error) => console.error('index write failed', error.message) }
)

const adviserTools = {
  recall: tool({
    description: 'Read the notes this agent holds under one subject.',
    inputSchema: z.object({ subject: z.string() }),
    execute: async ({ subject }: { subject: string }): Promise<Note[]> =>
      db.query<Note>('SELECT subject, body FROM note WHERE agent_id = ? AND subject = ?', [ADVISER, subject]),
// [!code ++:8]
  }),
  searchNotes: tool({
    description: 'Search the notes by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<Note[]> => {
      const found = await narsil.query<Note>('student_notes', { term: text, limit: 3 })
      return found.hits.map((hit) => ({ subject: hit.document.subject, body: hit.document.body }))
    },
  }),
}

const askAdviser = async (): Promise<string> => {
  const adviser = new ToolLoopAgent({
    model: adviserModel(),
    instructions: 'Answer the student from the notes this agent holds.',
    tools: adviserTools,
  })
  const reply = await adviser.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, adviserAnswered: await askAdviser() }, null, 2))

await sirannon.shutdown()
```

```json result open
{
  "question": "How do I get help paying for a term abroad?",
  "adviserAnswered": "Here is what I hold: A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs; and Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad."
}
```

```ts title="mock-model.ts"

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export const QUESTION = 'How do I get help paying for a term abroad?'
export const SUBJECT_ASKED = 'help paying for a term abroad'

export interface Note {
  subject: string
  body: string
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const answered = (prompt: unknown, toolName: string): unknown => {
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) return part.output?.value
    }
  }
  return undefined
}

const callTool = (toolName: string, input: Record<string, unknown>) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

export const adviserModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const held = answered(prompt, 'recall') as Note[] | undefined
      if (held === undefined) return callTool('recall', { subject: SUBJECT_ASKED })
// [!code --:2]
      if (held.length === 0) return say(`I hold no note under '${SUBJECT_ASKED}'.`)
      return say(`Here is what I hold: ${held.map((note) => note.body).join('; and ')}.`)
// [!code ++:6]
      if (held.length > 0) return say(`Here is what I hold: ${held.map((note) => note.body).join('; and ')}.`)

      const found = answered(prompt, 'searchNotes') as Note[] | undefined
      if (found === undefined) return callTool('searchNotes', { text: QUESTION })
      if (found.length === 0) return say(`I hold no note about ${SUBJECT_ASKED}.`)
      return say(`Here is what I hold: ${found.map((note) => note.body).join('; and ')}.`)
    },
  })
```

</CodeGroup>

The agent answers the student now, and that answer includes `funding-agent`'s note about the hardship bursary Ngozi Adeyemi asked for. Nothing about that query is adversarial, since the note scores well for holding the words the student uses.

<RetrievalGateDiagram
  total={3}
  stages={[
    {
      label: 'the index',
      documents: 3,
      detail: 'Two notes belong to advising-agent, and one belongs to funding-agent.',
    },
    {
      label: 'filters',
      documents: 2,
      detail: 'The condition on agentId admits the notes this agent owns, before Narsil scores anything.',
    },
    {
      label: 'term',
      documents: 1,
      detail: 'Scoring ranks what is left, and one of the two notes holds the words the student uses.',
    },
  ]}
  caption="Narsil narrows the candidates by the filter and then scores what remains, which leaves a dropped note out of the ranking altogether."
/>

## Keep one agent out of another's notes

Every document holds an `agentId`, and a condition on that field narrows the candidates before any scoring happens. Narsil applies `filters` inside the query, which the [filters page](https://narsil.sondelali.com/docs/filters-facets-and-pagination) covers along with facets and paging.

Take the value from the identity your server authenticated. An agent free to supply its own `agentId` would be choosing whose notes it reads. Fill that field the way the server fills `customerId` in [AI agent tools](/docs/agent-tools).

<CodeGroup defaultTab="notes.ts">

```ts title="notes.ts"

const ADVISER = 'advising-agent'

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const db = await sirannon.open('student-services', './data/student-services.db')

await db.execute(`CREATE TABLE note (
  id INTEGER PRIMARY KEY,
  agent_id TEXT NOT NULL,
  subject TEXT NOT NULL,
  body TEXT NOT NULL
)`)

const NOTES: string[][] = [
  [
    ADVISER,
    'term abroad',
    'A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs',
  ],
  [ADVISER, 'Kraków exchange', 'The Kraków exchange opens in the spring semester only'],
  ['funding-agent', 'Ngozi Adeyemi', 'Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad'],
]

await db.executeBatch('INSERT INTO note (agent_id, subject, body) VALUES (?, ?, ?)', NOTES)

interface NoteRow {
  id: number
  agent_id: string
  subject: string
  body: string
}

const asDocument = (row: NoteRow) => ({
  id: String(row.id),
  agentId: row.agent_id,
  subject: row.subject,
  body: row.body,
})

const narsil = await createNarsil()

await narsil.createIndex('student_notes', {
  schema: { agentId: 'string', subject: 'string', body: 'string' },
  language: 'english',
})

const onTheTable = await db.query<NoteRow>('SELECT id, agent_id, subject, body FROM note ORDER BY id')
await narsil.insertBatch('student_notes', onTheTable.map(asDocument))

await db.watch('note')

db.on('note').subscribe<NoteRow>(
  async (event) => {
    if (event.type === 'insert') await narsil.insert('student_notes', asDocument(event.row))
    else if (event.type === 'update') await narsil.update('student_notes', String(event.row.id), asDocument(event.row))
    else if (event.oldRow !== undefined) await narsil.remove('student_notes', String(event.oldRow.id))
  },
  { onError: (error) => console.error('index write failed', error.message) }
)

const adviserTools = {
  recall: tool({
    description: 'Read the notes this agent holds under one subject.',
    inputSchema: z.object({ subject: z.string() }),
    execute: async ({ subject }: { subject: string }): Promise<Note[]> =>
      db.query<Note>('SELECT subject, body FROM note WHERE agent_id = ? AND subject = ?', [ADVISER, subject]),
  }),
  searchNotes: tool({
    description: 'Search the notes by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<Note[]> => {
      const found = await narsil.query<Note>('student_notes', { term: text, limit: 3 }) // [!code --]
// [!code ++:5]
      const found = await narsil.query<Note>('student_notes', {
        term: text,
        filters: { fields: { agentId: { eq: ADVISER } } },
        limit: 3,
      })
      return found.hits.map((hit) => ({ subject: hit.document.subject, body: hit.document.body }))
    },
  }),
}

const askAdviser = async (): Promise<string> => {
  const adviser = new ToolLoopAgent({
    model: adviserModel(),
    instructions: 'Answer the student from the notes this agent holds.',
    tools: adviserTools,
  })
  const reply = await adviser.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, adviserAnswered: await askAdviser() }, null, 2))

await sirannon.shutdown()
```

```json result open
{
  "question": "How do I get help paying for a term abroad?",
  "adviserAnswered": "Here is what I hold: A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs."
}
```

</CodeGroup>

The agent now answers with the advising note alone. One index and one term produce both runs, and a single field condition separates them.

## Where to go next

Keyword search matches the words a note holds. Where a question shares no word at all with the note a person needs, hybrid mode puts a vector search beside the keyword search and fuses the two rankings. [Hybrid search](https://narsil.sondelali.com/docs/hybrid-search) covers that mode, along with the [embedding adapters](https://narsil.sondelali.com/docs/embedding-adapters) that encode the text. A process that stops between the commit and the index write leaves the index behind the table, and [subscription resumption](/docs/subscription-resumption) covers resuming the feed from the sequence you indexed last. Where several agents query one index, [the Narsil HTTP server](https://narsil.sondelali.com/docs/http-server) puts it behind a port of its own.
