Core concepts

Full-text search

Rank documents with BM25, choose which fields the engine searches, match through typos, require every word, mark the matches, and complete the word that a reader is typing.

Full-text search is what query does unless you choose another mode. The engine breaks the term into words, stems each word, and ranks every document that holds a match with . You build one file here, search.ts, in which you refine a single query step by step. At the end, you use two short files to score a query across partitions and to size a query before you send it. Each code block below holds a whole file, with green lines marking the lines that you add at each step and struck lines marking the lines that you remove.

Rank the documents that match

With no other option, the engine searches every text field in the schema, which here means name, summary, maker, and each entry in tags. document.include trims each hit to its name, so that the output holds the ranking and little else.

search.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const results = await narsil.query('bicycles', {
  term: 'commuter bicycle',
  document: { include: ['name'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()
[
  {
    "id": "urban-commuter",
    "score": 2.16,
    "document": {
      "name": "Gomes Urban commuter"
    }
  },
  {
    "id": "city-step",
    "score": 0.84,
    "document": {
      "name": "Gomes Step-through city bicycle"
    }
  },
  {
    "id": "folding-metro",
    "score": 0.84,
    "document": {
      "name": "Obi Metro folding bicycle"
    }
  },
  {
    "id": "cargo-hauler",
    "score": 0.07,
    "document": {
      "name": "Larsson Cargo hauler"
    }
  },
  {
    "id": "road-racer",
    "score": 0.07,
    "document": {
      "name": "Khoury Road racer"
    }
  },
  {
    "id": "gravel-explorer",
    "score": 0.07,
    "document": {
      "name": "Mori Gravel explorer"
    }
  }
]

Six bicycles match, because the engine admits a document that holds either word, and every summary holds the word bicycle. The urban commuter scores 2.16 since it holds commuter in its name, its summary, and its tags, while the step-through and folding bicycles hold that word in their tags alone and score 0.84. The other three score 0.07, since they hold only the word bicycle from the query and BM25 gives a word that every document holds almost no weight. The English analyser also drops such as the, so a query for the alone matches nothing.

Search the fields that matter, and weight one of them

fields limits the search to the fields that you name, while boost multiplies what one field adds to a score. Naming a number or enum field in fields fails with SEARCH_INVALID_FIELD, because those fields hold no words to search, and so does naming a field that the schema lacks.

search.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const results = await narsil.query('bicycles', {
  term: 'commuter bicycle',
  fields: ['name', 'summary'], 
  boost: { name: 4 },
  document: { include: ['name'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()
[
  {
    "id": "urban-commuter",
    "score": 3.63,
    "document": {
      "name": "Gomes Urban commuter"
    }
  },
  {
    "id": "city-step",
    "score": 0.35,
    "document": {
      "name": "Gomes Step-through city bicycle"
    }
  },
  {
    "id": "folding-metro",
    "score": 0.35,
    "document": {
      "name": "Obi Metro folding bicycle"
    }
  },
  {
    "id": "cargo-hauler",
    "score": 0.07,
    "document": {
      "name": "Larsson Cargo hauler"
    }
  },
  {
    "id": "road-racer",
    "score": 0.07,
    "document": {
      "name": "Khoury Road racer"
    }
  },
  {
    "id": "gravel-explorer",
    "score": 0.07,
    "document": {
      "name": "Mori Gravel explorer"
    }
  }
]

Without tags, the step-through and folding bicycles lose the word commuter, so they fall to what the word bicycle earns in their names, which the boost of 4 lifts to 0.35. The urban commuter holds commuter in its name as well, so the same boost raises it to 3.63.

Match a word through a typo

tolerance sets how many edits a word may be away from an indexed word and still match. It defaults to 0, so a query for comuter bicyle matches nothing until you raise it. prefixLength, which defaults to 2, sets how many leading characters have to match exactly before tolerance applies.

search.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const results = await narsil.query('bicycles', {
  term: 'commuter bicycle', 
  term: 'comuter bicyle', 
  tolerance: 1,
  fields: ['name', 'summary'],
  boost: { name: 4 },
  document: { include: ['name'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()
[
  {
    "id": "urban-commuter",
    "score": 3.63,
    "document": {
      "name": "Gomes Urban commuter"
    }
  },
  {
    "id": "city-step",
    "score": 0.35,
    "document": {
      "name": "Gomes Step-through city bicycle"
    }
  },
  {
    "id": "folding-metro",
    "score": 0.35,
    "document": {
      "name": "Obi Metro folding bicycle"
    }
  },
  {
    "id": "cargo-hauler",
    "score": 0.07,
    "document": {
      "name": "Larsson Cargo hauler"
    }
  },
  {
    "id": "road-racer",
    "score": 0.07,
    "document": {
      "name": "Khoury Road racer"
    }
  },
  {
    "id": "gravel-explorer",
    "score": 0.07,
    "document": {
      "name": "Mori Gravel explorer"
    }
  }
]

Both misspellings are one edit from the indexed words, so tolerance: 1 returns the ranking and the scores that the correct spelling earns. Where the misspelling falls inside the first two characters, you need to lower prefixLength as well. Bycicle, for example, holds a y where bicycle holds its second letter, so it matches nothing even at tolerance: 2 until you lower prefixLength to 1. exact: true turns fuzzy matching off, which makes the same misspelled query match nothing again.

Require every word, and mark it

With termMatch: 'all' the engine keeps a document only where that document holds every word of the query, while a number in place of 'all' sets how many of the words a document has to hold. With highlight, the engine returns each named field with the matched words wrapped in <mark> tags.

search.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const results = await narsil.query('bicycles', {
  term: 'comuter bicyle',
  tolerance: 1,
  termMatch: 'all', 
  fields: ['name', 'summary'],
  boost: { name: 4 },
  highlight: { fields: ['name', 'summary'] }, 
  document: { include: ['name'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()
[
  {
    "id": "urban-commuter",
    "score": 3.63,
    "document": {
      "name": "Gomes Urban commuter"
    },
    "highlights": {
      "name": {
        "snippet": "Gomes Urban <mark>commuter</mark>",
        "positions": [
          {
            "start": 12,
            "end": 20
          }
        ]
      },
      "summary": {
        "snippet": "A lightweight aluminium <mark>commuter</mark> <mark>bicycle</mark> with hydraulic disc brakes and mudguards fitted as standard.",
        "positions": [
          {
            "start": 24,
            "end": 32
          },
          {
            "start": 33,
            "end": 40
          }
        ]
      }
    }
  }
]

The urban commuter is the one bicycle whose name or summary holds both words, so it comes back alone. The highlighter marks commuter and bicycle, which are the words that comuter and bicyle matched through tolerance. Beside each snippet, positions holds the start and the end of every mark as character offsets into the original field, so that you can render the marks yourself. minScore drops every hit that scores below a floor; however, a BM25 score has no upper bound, so a floor that suits this query can reject every hit of another.

Complete the last word as the reader types

With prefix: true the engine treats the last word of the term as unfinished, so that the word matches every indexed word that starts with it, while the earlier words have to match in full. The engine expands the unfinished word to at most 50 indexed words, which it ranks below a word that matches in full.

search.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const results = await narsil.query('bicycles', {
  term: 'comuter bicyle', 
  term: 'gomes ste', 
  prefix: true,
  tolerance: 1,
  termMatch: 'all',
  fields: ['name', 'summary'],
  boost: { name: 4 },
  highlight: { fields: ['name', 'summary'] },
  document: { include: ['name'] },
})
 
console.log(JSON.stringify(results.hits, (key, value) => (typeof value === 'number' ? Number(value.toFixed(2)) : value), 2))
 
await narsil.shutdown()
[
  {
    "id": "city-step",
    "score": 7.48,
    "document": {
      "name": "Gomes Step-through city bicycle"
    },
    "highlights": {
      "name": {
        "snippet": "<mark>Gomes</mark> <mark>Step</mark>-through city bicycle",
        "positions": [
          {
            "start": 0,
            "end": 5
          },
          {
            "start": 6,
            "end": 10
          }
        ]
      },
      "summary": {
        "snippet": "A <mark>step</mark>-through city bicycle with a chain guard, a basket, and a hub dynamo.",
        "positions": [
          {
            "start": 2,
            "end": 6
          }
        ]
      }
    }
  }
]

The engine completes ste to step and to steel, although termMatch: 'all' still requires gomes as well, which leaves the step-through bicycle as the one answer. The highlighter marks the completed word along with the whole one, so a search box can show the reader why the bicycle matched. The engine applies tolerance to gomes and leaves the unfinished word alone.

Score the same way across partitions

An index that you split into partitions holds separate word statistics in each partition, which is why the scoring option on a query sets which statistics the engine uses to score it. Under local, the default, the engine scores each partition from its own statistics; under dfs it gathers the document frequencies from every partition before it scores; and under broadcast it sends the query to every partition and merges the answers. defaultScoring on the index sets the mode for a query that names none. In scoring.ts you split the same six bicycles across four partitions and ask one question in each mode.

scoring.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
  partitions: { maxPartitions: 4 },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const rankings: Record<string, string[]> = {}
 
for (const scoring of ['local', 'dfs', 'broadcast'] as const) {
  const results = await narsil.query('bicycles', { term: 'commuter bicycle', scoring, document: false })
  rankings[scoring] = results.hits.map((hit) => `${hit.id} ${hit.score?.toFixed(2)}`)
}
 
console.log(JSON.stringify(rankings, null, 2))
 
await narsil.shutdown()
{
  "local": [
    "city-step 1.24",
    "urban-commuter 1.15",
    "folding-metro 1.06",
    "gravel-explorer 0.17",
    "cargo-hauler 0.13",
    "road-racer 0.13"
  ],
  "dfs": [
    "urban-commuter 2.16",
    "city-step 0.84",
    "folding-metro 0.84",
    "cargo-hauler 0.07",
    "road-racer 0.07",
    "gravel-explorer 0.07"
  ],
  "broadcast": [
    "urban-commuter 2.16",
    "city-step 0.84",
    "folding-metro 0.84",
    "cargo-hauler 0.07",
    "road-racer 0.07",
    "gravel-explorer 0.07"
  ]
}

With local, the engine scores the word commuter in each partition against that partition's handful of documents, so the step-through bicycle overtakes the urban commuter at 1.24 against 1.15. Under dfs and broadcast, the engine reproduces the single-partition ranking and its scores exactly, because it then weighs every word against the whole index. The engine does the least work under local, since it skips the round that gathers statistics, so that mode suits an index whose words spread evenly across its partitions.

Size a query, then suggest words

preflight counts the documents that a query matches without building or ranking a hit, which lets you size a result before you ask for it. suggest completes a prefix to the words in the index, most widely held first, with a limit that defaults to 10 and stops at 100.

suggest.ts
import { createNarsil } from '@delali/narsil'
 
const narsil = await createNarsil()
 
await narsil.createIndex('bicycles', {
  schema: { name: 'string', summary: 'string', maker: 'string', tags: 'string[]' },
})
 
await narsil.insertBatch('bicycles', [
  {
    id: 'urban-commuter',
    name: 'Gomes Urban commuter',
    summary: 'A lightweight aluminium commuter bicycle with hydraulic disc brakes and mudguards fitted as standard.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'aluminium'],
  },
  {
    id: 'gravel-explorer',
    name: 'Mori Gravel explorer',
    summary: 'A steel gravel bicycle built for long unpaved rides, with clearance for 45 millimetre tyres.',
    maker: 'Mori Works',
    tags: ['gravel', 'steel'],
  },
  {
    id: 'folding-metro',
    name: 'Obi Metro folding bicycle',
    summary: 'A folding bicycle that fits under a train seat and unfolds in fifteen seconds.',
    maker: 'Obi Mobility',
    tags: ['folding', 'commuter'],
  },
  {
    id: 'cargo-hauler',
    name: 'Larsson Cargo hauler',
    summary: 'An electric cargo bicycle rated to carry two children and a week of shopping.',
    maker: 'Larsson Bikes',
    tags: ['cargo', 'electric'],
  },
  {
    id: 'road-racer',
    name: 'Khoury Road racer',
    summary: 'A carbon road bicycle with electronic shifting, weighed at 7.4 kilograms.',
    maker: 'Khoury Composites',
    tags: ['road', 'carbon'],
  },
  {
    id: 'city-step',
    name: 'Gomes Step-through city bicycle',
    summary: 'A step-through city bicycle with a chain guard, a basket, and a hub dynamo.',
    maker: 'Gomes Cycles',
    tags: ['commuter', 'steel'],
  },
])
 
const size = await narsil.preflight('bicycles', { term: 'commuter bicycle', termMatch: 'all' })
const words = await narsil.suggest('bicycles', { prefix: 'com', limit: 5 })
 
console.log(
  JSON.stringify(
    { count: size.count, countExact: size.countExact, suggestions: words.terms },
    null,
    2
  )
)
 
await narsil.shutdown()
{
  "count": 3,
  "countExact": true,
  "suggestions": [
    {
      "term": "commuter",
      "documentFrequency": 3
    },
    {
      "term": "composites",
      "documentFrequency": 1
    }
  ]
}

Three bicycles hold both commuter and bicycle across their text fields. countExact is true because the engine counted every one of them. The engine returns each suggestion with the spelling that your documents use, because surfaceForms defaults to true; for an index created with surfaceForms: false, it suggests the stems that it stores.

Where to go next