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

# When the index has no good match

> Require every query term and return a retrieval status that the AI agent states first, since a near miss looks like an answer and an empty result leads to a guess.

URL: https://narsil.sondelali.com/docs/agent-weak-matches
Section: AI agents
Version: 0.3 (@delali/narsil@0.3.0, latest)

Imagine a grower who asks a seed supplier's AI agent what to use against downy mildew on courgette leaves, and who gets advice about watering at the root and thinning the crowded leaves. That advice is for powdery mildew, which is a different disease with a different remedy. The growing notes contain a note on powdery mildew on courgettes and none on downy mildew, while the question has three words in common with the powdery mildew note.

The authors of a 2026 benchmark measure what a model does when a tool returns a value that the model cannot use. Under a deployment-style system prompt, 14.10% of the responses in their benchmark are dishonest, which the authors define as asserting a value that has no support in the payload, or declining while citing a fabricated limit. The figure rises to 45.3% when the tool reports success and returns an empty, stale, or truncated value, and it falls to 0.87% when the authors add a single instruction to the prompt, asking the model to state a retrieval status before it answers ([arXiv 2609.14758](https://arxiv.org/abs/2609.14758)).

Because the model here is a scripted mock, you get the same answers below every time you execute the files. Swap in a model string when you want a real model to write the wording. Each code block below holds one whole file.

## A note with some of the same words

`termMatch` defaults to `any`, under which a document matches when it contains any one of the query terms. The analyser drops the stop words from the grower's question, which leaves four terms, and the courgette note contains three of them.

<CodeGroup defaultTab="growing.ts">

```ts title="growing.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type GrowingNote, growingModel, QUESTION } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('growing_notes', {
  schema: { crop: 'enum', section: 'string', advice: 'string' },
  language: 'english',
})

await narsil.insertBatch('growing_notes', [
  {
    id: 'courgette-powdery-mildew',
    crop: 'courgette',
    section: 'Powdery mildew on courgettes',
    advice:
      'Powdery mildew shows as a white dust on courgette leaves from midsummer. Water at the root in the morning, thin the crowded leaves for airflow, and pick the fruit young so the plant keeps growing.',
  },
  {
    id: 'tomato-blight',
    crop: 'tomato',
    section: 'Late blight on tomatoes',
    advice:
      'Late blight browns the tomato leaf from the tip and blackens the stem within days. Lift and burn the affected plants, keep the remaining foliage dry, and sow a blight-resistant variety next season.',
  },
  {
    id: 'broad-bean-rust',
    crop: 'broad bean',
    section: 'Chocolate spot on broad beans',
    advice:
      'Chocolate spot marks broad bean leaves with brown flecks in a wet spring. Space the rows wider, stake the plants upright, and harvest the lower pods first.',
  },
  {
    id: 'carrot-fly',
    crop: 'carrot',
    section: 'Carrot fly',
    advice:
      'Carrot fly larvae tunnel into the root and leave rusty scars. Sow after the first flight in June, cover the bed with fine mesh, and sow thinly so that no thinning releases the scent.',
  },
])

const growingTools = {
  searchNotes: tool({
    description: 'Search the growing notes by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<GrowingNote[]> => {
      const found = await narsil.query<GrowingNote>('growing_notes', {
        term: text,
        document: { include: ['section', 'advice'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askGrowingAgent = async (): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: growingModel(),
    instructions: 'Answer the grower from the growing notes.',
    tools: growingTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  return reply.text
}

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

await narsil.shutdown()
```

```json result open
{
  "question": "What do I use for downy mildew on courgette leaves?",
  "growerHeard": "Powdery mildew shows as a white dust on courgette leaves from midsummer. Water at the root in the morning, thin the crowded leaves for airflow, and pick the fruit young so the plant keeps growing. That comes from the 'Powdery mildew on courgettes' note."
}
```

```ts title="mock-model.ts"
import { MockLanguageModelV4 } from 'ai/test'

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

export const QUESTION = 'What do I use for downy mildew on courgette leaves?'

export const REMEMBERED_TREATMENT =
  'Spray a copper-based fungicide every ten days and remove the worst leaves as you go.'

export interface GrowingNote {
  section: string
  advice: string
}

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

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

const everythingReturnedBy = (prompt: unknown, toolName: string): unknown[] => {
  const outputs: 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) outputs.push(part.output?.value)
    }
  }
  return outputs
}

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 growingModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchNotes') as GrowingNote[][]
      if (searches.length === 0) return callTool('searchNotes', { text: QUESTION })
      const found = searches[0]
      if (found.length === 0) return say(REMEMBERED_TREATMENT)
      return say(`${found[0].advice} That comes from the '${found[0].section}' note.`)
    },
  })
```

</CodeGroup>

The grower receives advice from the supplier's own notes, in the supplier's own wording, for the wrong disease. The answer looks like a full match, because the tool drops the score before it returns the note.

<TermCoverageDiagram
  question="What do I use for downy mildew on courgette leaves?"
  droppedTerms={['what', 'do', 'I', 'use', 'for', 'on']}
  notes={['courgette', 'tomato', 'broad bean', 'carrot']}
  terms={[
    { term: 'downy', inNotes: [] },
    { term: 'mildew', inNotes: ['courgette'] },
    { term: 'courgette', inNotes: ['courgette'] },
    { term: 'leaves', inNotes: ['courgette', 'broad bean', 'carrot'] },
  ]}
  caption="Each dot marks a note that contains the term. Three notes contain at least one term and match under 'any', while none matches under 'all', because no note contains downy, the one word that separates the grower's disease from powdery mildew."
/>

## Require every term, and watch what the model does with an empty result

Under `termMatch: 'all'`, a document matches only when it contains every query term. The word downy is absent from every note, so the strict query matches nothing. `suggest` with the prefix 'down' also returns an empty list, because no term in the dictionary starts with those letters.

The tool now returns an empty array, and the model answers from its own training about mildew.

<CodeGroup defaultTab="growing.ts">

```ts title="growing.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type GrowingNote, growingModel, QUESTION } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('growing_notes', {
  schema: { crop: 'enum', section: 'string', advice: 'string' },
  language: 'english',
})

await narsil.insertBatch('growing_notes', [
  {
    id: 'courgette-powdery-mildew',
    crop: 'courgette',
    section: 'Powdery mildew on courgettes',
    advice:
      'Powdery mildew shows as a white dust on courgette leaves from midsummer. Water at the root in the morning, thin the crowded leaves for airflow, and pick the fruit young so the plant keeps growing.',
  },
  {
    id: 'tomato-blight',
    crop: 'tomato',
    section: 'Late blight on tomatoes',
    advice:
      'Late blight browns the tomato leaf from the tip and blackens the stem within days. Lift and burn the affected plants, keep the remaining foliage dry, and sow a blight-resistant variety next season.',
  },
  {
    id: 'broad-bean-rust',
    crop: 'broad bean',
    section: 'Chocolate spot on broad beans',
    advice:
      'Chocolate spot marks broad bean leaves with brown flecks in a wet spring. Space the rows wider, stake the plants upright, and harvest the lower pods first.',
  },
  {
    id: 'carrot-fly',
    crop: 'carrot',
    section: 'Carrot fly',
    advice:
      'Carrot fly larvae tunnel into the root and leave rusty scars. Sow after the first flight in June, cover the bed with fine mesh, and sow thinly so that no thinning releases the scent.',
  },
])

const growingTools = {
  searchNotes: tool({
    description: 'Search the growing notes by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<GrowingNote[]> => {
      const found = await narsil.query<GrowingNote>('growing_notes', {
        term: text,
        termMatch: 'all', // [!code ++]
        document: { include: ['section', 'advice'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askGrowingAgent = async (): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: growingModel(),
    instructions: 'Answer the grower from the growing notes.',
    tools: growingTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  return reply.text
}

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

await narsil.shutdown()
```

```json result open
{
  "question": "What do I use for downy mildew on courgette leaves?",
  "growerHeard": "Spray a copper-based fungicide every ten days and remove the worst leaves as you go."
}
```

</CodeGroup>

The answer no longer contains the wrong note, and it is worse. A copper fungicide is plausible advice for a mildew; however, the supplier publishes no such advice. If the grower follows it, they are following a recommendation that appears in none of the supplier's notes. The tool reports success and returns nothing usable, which is the case where the authors above measure 45.3%.

## Return a status, and the nearest note with it

The model's new instruction is to open its answer with `retrievalStatus`, the same one-sentence change with which the authors lower dishonesty from 14.10% to 0.87%. The tool sets that field from the result of the strict query, which leaves the model no say in its value.

`preflight` counts the documents that a loose query would match without building any hits, and the tool then fetches the nearest note only when that count is above zero. When the count is zero, the tool returns the status alone, and the model tells the grower that the notes contain nothing on that disease.

<CodeGroup defaultTab="growing.ts">

```ts title="growing.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type GrowingNote, growingModel, QUESTION } from './mock-model.ts' // [!code --]
import { growingModel, type NoteSearch, QUESTION } from './mock-model.ts' // [!code ++]

const narsil = await createNarsil()

await narsil.createIndex('growing_notes', {
  schema: { crop: 'enum', section: 'string', advice: 'string' },
  language: 'english',
})

await narsil.insertBatch('growing_notes', [
  {
    id: 'courgette-powdery-mildew',
    crop: 'courgette',
    section: 'Powdery mildew on courgettes',
    advice:
      'Powdery mildew shows as a white dust on courgette leaves from midsummer. Water at the root in the morning, thin the crowded leaves for airflow, and pick the fruit young so the plant keeps growing.',
  },
  {
    id: 'tomato-blight',
    crop: 'tomato',
    section: 'Late blight on tomatoes',
    advice:
      'Late blight browns the tomato leaf from the tip and blackens the stem within days. Lift and burn the affected plants, keep the remaining foliage dry, and sow a blight-resistant variety next season.',
  },
  {
    id: 'broad-bean-rust',
    crop: 'broad bean',
    section: 'Chocolate spot on broad beans',
    advice:
      'Chocolate spot marks broad bean leaves with brown flecks in a wet spring. Space the rows wider, stake the plants upright, and harvest the lower pods first.',
  },
  {
    id: 'carrot-fly',
    crop: 'carrot',
    section: 'Carrot fly',
    advice:
      'Carrot fly larvae tunnel into the root and leave rusty scars. Sow after the first flight in June, cover the bed with fine mesh, and sow thinly so that no thinning releases the scent.',
  },
])

const growingTools = {
  searchNotes: tool({
    description: 'Search the growing notes by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<GrowingNote[]> => { // [!code --]
      const found = await narsil.query<GrowingNote>('growing_notes', { // [!code --]
    execute: async ({ text }: { text: string }): Promise<NoteSearch> => { // [!code ++:2]
      const matched = await narsil.query<{ section: string; advice: string }>('growing_notes', {
        term: text,
        termMatch: 'all',
        document: { include: ['section', 'advice'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document) // [!code --]
      if (matched.hits.length > 0) { // [!code ++:15]
        return { retrievalStatus: 'matched', notes: matched.hits.map((hit) => hit.document) }
      }
      const loose = await narsil.preflight('growing_notes', { term: text })
      if (loose.count === 0) return { retrievalStatus: 'nothing matched', notes: [] }
      const nearest = await narsil.query<{ section: string }>('growing_notes', {
        term: text,
        document: { include: ['section'] },
        limit: 1,
      })
      return {
        retrievalStatus: 'nothing matched',
        notes: [],
        nearest: { section: nearest.hits[0].document.section, looseMatches: loose.count },
      }
    },
  }),
}

const askGrowingAgent = async (): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: growingModel(),
    instructions: 'Answer the grower from the growing notes.', // [!code --]
    instructions: // [!code ++:2]
      'Answer the grower from the growing notes, and open the answer with the retrievalStatus that the tool returned.',
    tools: growingTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  return reply.text
}

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

await narsil.shutdown()
```

```json result open
{
  "question": "What do I use for downy mildew on courgette leaves?",
  "growerHeard": "retrievalStatus: nothing matched. No note covers downy mildew on courgettes, and the nearest of 3 partial matches is 'Powdery mildew on courgettes', which is a different disease."
}
```

```ts title="mock-model.ts"
import { MockLanguageModelV4 } from 'ai/test'

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

export const QUESTION = 'What do I use for downy mildew on courgette leaves?'

export const REMEMBERED_TREATMENT =
  'Spray a copper-based fungicide every ten days and remove the worst leaves as you go.'

export interface GrowingNote {
  section: string
  advice: string
}

export interface NoteSearch { // [!code ++:5]
  retrievalStatus: 'matched' | 'nothing matched'
  notes: GrowingNote[]
  nearest?: { section: string; looseMatches: number }
}

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

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

const everythingReturnedBy = (prompt: unknown, toolName: string): unknown[] => {
  const outputs: 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) outputs.push(part.output?.value)
    }
  }
  return outputs
}

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 growingModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchNotes') as GrowingNote[][] // [!code --]
      const searches = everythingReturnedBy(prompt, 'searchNotes') as NoteSearch[] // [!code ++]
      if (searches.length === 0) return callTool('searchNotes', { text: QUESTION })
      const found = searches[0] // [!code --]
      if (found.length === 0) return say(REMEMBERED_TREATMENT) // [!code --]
      return say(`${found[0].advice} That comes from the '${found[0].section}' note.`) // [!code --]
      const search = searches[0] // [!code ++:11]
      if (search.retrievalStatus === 'matched') {
        const note = search.notes[0]
        return say(`${note.advice} That comes from the '${note.section}' note.`)
      }
      if (search.nearest === undefined) {
        return say('retrievalStatus: nothing matched. The growing notes cover no disease by that name.')
      }
      return say(
        `retrievalStatus: nothing matched. No note covers downy mildew on courgettes, and the nearest of ${search.nearest.looseMatches} partial matches is '${search.nearest.section}', which is a different disease.`
      )
    },
  })
```

</CodeGroup>

The grower now learns three facts, which are that the notes contain nothing on their disease, that one note has some of the same words, and that this note is about a different disease. `REMEMBERED_TREATMENT` is still in the mock; however, the answer never contains it, because after 'nothing matched' the model has no way to present that treatment as the supplier's advice.

You can also set `minScore`, although a floor on the score is harder to choose. The engine ranks with BM25, whose scores have no fixed upper bound, so a floor of 2.0 chosen for this index and this query could reject every hit on a longer corpus or a shorter question. Measure the scores on your own index before you set a floor, and use `termMatch` when the words of the question are enough to decide the match.

## Where to go next

- Read [Full-text search](/docs/full-text-search) for `termMatch`, `minScore`, the typo tolerance, and `suggest`.
- For a question whose words appear in no note at all, where keyword search returns nothing under any term policy, see [Search by wording and by meaning](/docs/agent-hybrid-retrieval).
- To attach a reference to each hit that the tool does return, follow [Cite the passage behind an answer](/docs/agent-grounded-answers).
