Query features compose: one query can combine a search term with filters, facets, sorting, grouping, pinned results, and a pagination cursor. This page builds one file, search.ts, step by step. You start from a small products catalogue, run a plain search, then add filters, facets, sorting, grouping, and pagination. Each step prints its result, so you can watch the data change as you go.
Set up the catalogue
Every step queries the same products index, so start search.ts with an engine, a schema, and a handful of products to search against.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])Run a plain search
With the catalogue loaded, add a query for keyboard and print the ranked hits. Every hit carries the stored document and its BM25 relevance score.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))All six products mention keyboards, so all six come back. The wrist rest ranks first because keyboard appears in both its title and its description, so the term matches twice. The two shortest titles, Mechanical Keyboard and Wireless Keyboard, tie next, because BM25 weighs a match against field length and gives a shorter field more weight. The longer titles score below them.
Narrow with filters
Filters restrict which candidates the search scores. Filter on any indexed field with comparison operators (eq, ne, gt, lt, gte, lte, between), string operators (in, nin, startsWith, endsWith), array operators (containsAll, matchesAny, size), and presence checks (exists, notExists, isEmpty, isNotEmpty). Field conditions go under fields, and the and, or, and not combinators nest whole filter expressions, so any boolean shape is expressible.
Add a filters block so the query keeps only in-stock electronics or accessories priced between 10 and 100 that carry a bluetooth tag.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
filters: {
or: [
{ fields: { category: { eq: 'electronics' } } },
{ fields: { category: { eq: 'accessories' } } },
],
fields: {
inStock: { eq: true },
price: { between: [10, 100] },
tags: { containsAll: ['bluetooth'] },
},
},
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))That narrows six hits to three. The mechanical keyboard drops out on price, because 129.99 is above the range. The compact keyboard drops out because it is out of stock. The wrist rest drops out because it carries no bluetooth tag. In vector and hybrid modes the same filters restrict which documents the vector search considers, not only which scored hits survive.
Count values with facets
Facets return value counts alongside the hits, which is what a filter sidebar needs to show how many products belong to each option. String and enum facets take a limit and a sort direction, and numeric facets take explicit ranges. Add a facets block that counts categories and buckets prices.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
filters: {
or: [
{ fields: { category: { eq: 'electronics' } } },
{ fields: { category: { eq: 'accessories' } } },
],
fields: {
inStock: { eq: true },
price: { between: [10, 100] },
tags: { containsAll: ['bluetooth'] },
},
},
facets: {
category: { limit: 10, sort: 'desc' },
price: {
ranges: [{ from: 0, to: 50 }, { from: 50, to: 100 }],
},
},
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))The counts arrive under results.facets, keyed by field. Two of the three hits are electronics and one is an accessory, and by price two fall in the 50 to 100 bucket and one below 50. A sidebar reads these counts straight into its checkboxes and price ranges.
Sort and group hits
sort orders hits by field values instead of by score. Multiple entries apply in order, so the second field breaks ties in the first. group then merges hits that share field values, maxPerGroup limits how many hits each group keeps, and an optional reducer combines every grouped document into a single value. Sort cheapest first and group the results by category.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
filters: {
or: [
{ fields: { category: { eq: 'electronics' } } },
{ fields: { category: { eq: 'accessories' } } },
],
fields: {
inStock: { eq: true },
price: { between: [10, 100] },
tags: { containsAll: ['bluetooth'] },
},
},
sort: { price: 'asc', title: 'asc' },
group: {
fields: ['category'],
maxPerGroup: 3,
},
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))results.hits now runs cheapest first, from the folio case at 49.99 up to the split keyboard at 89.99. results.groups collects the same hits by category, at most three per group: the electronics group holds the two keyboards, and the accessories group holds the folio case.
Paginate a page at a time
A results page needs a size and a way to reach the next one. Shallow pagination sets limit for the page size and offset to skip into the results. Ask for two hits at a time so the three filtered products span two pages.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
filters: {
or: [
{ fields: { category: { eq: 'electronics' } } },
{ fields: { category: { eq: 'accessories' } } },
],
fields: {
inStock: { eq: true },
price: { between: [10, 100] },
tags: { containsAll: ['bluetooth'] },
},
},
limit: 2,
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))The first page holds the top two hits by relevance. count still reports 3, the total that match, and the page carries a cursor for the next step. To reach the second page with offset, skip the two you have already read.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
filters: {
or: [
{ fields: { category: { eq: 'electronics' } } },
{ fields: { category: { eq: 'accessories' } } },
],
fields: {
inStock: { eq: true },
price: { between: [10, 100] },
tags: { containsAll: ['bluetooth'] },
},
},
limit: 2,
offset: 2,
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))offset reads well for the first few pages. It grows more expensive the deeper a reader scrolls, though, because the engine walks every skipped hit before it reaches the page you asked for.
Switch to deep cursor pagination
searchAfter cursors keep latency flat at any depth by tracking a position in the ranking instead of counting from the start. Every page carries a cursor string, so you pass it straight back as searchAfter to fetch the next page. A cursor is only valid for the exact query parameters it came from. Lift the shared parameters into a page object and pass it to each call, so the second query matches the first and the cursor stays valid. Fetch the first page, then use its cursor to fetch the second.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const page = {
term: 'keyboard',
filters: {
or: [
{ fields: { category: { eq: 'electronics' } } },
{ fields: { category: { eq: 'accessories' } } },
],
fields: {
inStock: { eq: true },
price: { between: [10, 100] },
tags: { containsAll: ['bluetooth'] },
},
},
limit: 2,
}
const firstPage = await narsil.query('products', page)
const secondPage = await narsil.query('products', {
...page,
searchAfter: firstPage.cursor,
})
console.log(JSON.stringify(secondPage, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))firstPage returns the same top two hits and its cursor. Passing that cursor as searchAfter returns the third hit, the folio case, and the page comes back with no cursor, which signals the end of the results. A malformed or mismatched cursor fails with SEARCH_INVALID_CURSOR, which is why reusing the same page object matters.
Pin editorial results
pinned places specific documents at fixed positions in the ranked results, which serves sponsored or editorial placements. Positions are zero-based and apply on top of whichever ranking, sort, or pagination the query already uses. Pin the mechanical keyboard to the top slot.
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil()
await narsil.createIndex('products', {
schema: {
title: 'string',
description: 'string',
price: 'number',
inStock: 'boolean',
category: 'enum',
tags: 'string[]',
},
language: 'english',
})
await narsil.insertBatch('products', [
{ id: 'kb-mech', title: 'Mechanical Keyboard', description: 'Cherry MX Brown switches with PBT keycaps for tactile typing.', price: 129.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-lite', title: 'Wireless Keyboard', description: 'Slim low-profile board with a long battery life.', price: 59.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-ergo', title: 'Ergonomic Split Keyboard for Typists', description: 'Split layout that eases wrist strain over long typing sessions.', price: 89.99, inStock: true, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'kb-compact', title: 'Compact Wireless Keyboard', description: 'Tenkeyless board that frees desk space.', price: 74.99, inStock: false, category: 'electronics', tags: ['bluetooth', 'typing'] },
{ id: 'case-kb', title: 'Folio Keyboard Case for Tablets and Phones', description: 'Bluetooth folio case that turns a tablet into a laptop.', price: 49.99, inStock: true, category: 'accessories', tags: ['bluetooth', 'travel'] },
{ id: 'rest-wrist', title: 'Keyboard Wrist Rest', description: 'Memory-foam rest that supports your wrists at the keyboard.', price: 24.99, inStock: true, category: 'accessories', tags: ['ergonomic'] },
])
const results = await narsil.query('products', {
term: 'keyboard',
pinned: [{ docId: 'kb-mech', position: 0 }],
})
console.log(JSON.stringify(results, (k, v) => (typeof v === 'number' ? Number(v.toFixed(2)) : v), 2))Narsil places kb-mech at position zero with a score of 0, whether or not the search would have ranked it there, and the rest of the hits follow below in their usual order.