---
title: "Full-text search plugin"
description: "Index every heading as a searchable section and add `content.search()` and `content.resetSearchIndex()` to the Content instance."
canonical_url: "https://content.comark.dev/plugins/built-in/full-text-search"
---
# Full-text search plugin

> Index every heading as a searchable section and add \`content.search()\` and \`content.resetSearchIndex()\` to the Content instance.

`sqliteFullTextSearch` indexes every heading as a searchable section and adds `content.search(query, opts?)` and `content.resetSearchIndex()` to the Content instance. It uses [SQLite FTS5](https://sqlite.org/fts5.html) for BM25 ranking and highlighted snippets.

## Usage

Add the plugin with a database, and `content.search()` returns ranked sections:

```ts [content.ts]
import { comarkContent } from 'comark-content'
import fs from 'comark-content/sources/fs'
import sqlite from 'comark-content/database/sqlite-node'
import sqliteFullTextSearch from 'comark-content/plugins/sqlite-full-text-search'

const database = sqlite()

const content = comarkContent({
  source: fs('./content'),
  plugins: [sqliteFullTextSearch({ database })],
})

const hits = await content.search('streaming markdown', { limit: 10 })
hits[0].title // the matching section's heading
hits[0].id // the page path, with a heading anchor such as '/guide/watch#read-fresh-on-demand'
```

The plugin requires a database: `sqlite()` on the server (Node 22.5 or later) or `sqliteWasm()` in the browser. See the [database reference](https://content.comark.dev/reference/database). The first `search()` builds the index; later searches reuse it.

::tip{to="https://content.comark.dev/sources/snapshot#smaller-bundles-with-comark-contentruntime"}
Running search in the browser with a snapshot? Import from `comark-content/runtime` for a smaller bundle (\~46 KB vs \~295 KB). The runtime entry excludes the parser, which a snapshot-only app doesn't need.
::

Every option is optional. Tune snippets, field weights, and the minimum term length when the defaults don't fit:

```ts
const hits = await content.search('streaming markdown', {
  limit: 10,
  snippet: { columns: ['title', 'content'], around: 30, tag: 'mark' },
  weights: { title: 10, content: 5, heading: true },
  minTermLength: 2,
})
```

::note{to="https://content.comark.dev/plugins/built-in/full-text-search#shared-database"}
Running several instances behind a [hub](https://content.comark.dev/advanced/hub)? They must **share** one database for ranked search across them.
::

## API

The plugin adds two methods on top of its factory:

### `sqliteFullTextSearch(options)`

Returns a [`ContentPlugin`](https://content.comark.dev/reference/types/plugins#contentplugin) that maintains an FTS5 index of your content and adds `content.search()` and `content.resetSearchIndex()` to the Content instance. Throws if `options.database` is missing.

**Parameters:**

- `options`: see [Options](#options).

**Returns:** [`ContentPlugin`](https://content.comark.dev/reference/types/plugins#contentplugin)

### `content.search(query, opts?)`

Added by the plugin. Searches the instance's documents for `query` and returns ranked sections. The first `search()` builds the index lazily.

**Parameters:**

- `query`: `string`, the search query. Terms are split on whitespace.
- `opts`: [`SearchOptions`](https://content.comark.dev/reference/types/content#searchoptions), see [Options](#options).

**Returns:** `Promise<`[`SearchResult`](https://content.comark.dev/reference/types/content#searchresult)`[]>`

### `content.resetSearchIndex()`

Added by the plugin. Drops the entire `__fts_search` table and clears the set of indexed instances. The next `search()` rebuilds the index.

**Returns:** `Promise<void>`

---

## Options

The `opts` argument to [`content.search()`](#api-contentsearchquery-opts) is a [`SearchOptions`](https://content.comark.dev/reference/types/content#searchoptions) object:

| Option                                    | Type                             | Default   | Description                          |
| ----------------------------------------- | -------------------------------- | --------- | ------------------------------------ |
| [`limit`](#options-limit)                 | `number`                         | `50`      | Cap the number of returned sections. |
| [`fields`](#options-fields)               | `('title' \| 'content')[]`       | both      | Restrict where the term must match.  |
| [`minTermLength`](#options-mintermlength) | `number`                         | `1`       | Drop search terms shorter than this. |
| [`weights`](#options-weights)             | `{ title?, content?, heading? }` | see below | BM25 weighting per column.           |
| [`snippet`](#options-snippet)             | `{ columns?, around?, tag? }`    | none      | Highlighted snippet configuration.   |

### `limit`

Caps the number of returned sections. Defaults to `50`.

### `fields`

Restricts matching to specific FTS columns (`'title'`, `'content'`). When omitted, terms match either column.

### `minTermLength`

Drops query terms shorter than this length before searching. Defaults to `1`. If every term is dropped, `search()` returns `[]`.

### `weights`

BM25 weighting per column.

- `weights.title` (`number`, default `10`), BM25 weight for the `title` column.
- `weights.content` (`number`, default `5`), BM25 weight for the `content` column.
- `weights.heading` (`boolean`, default `true`), divide rank by heading level, boosting pages over deep H6s.

### `snippet`

Highlighted snippet configuration.

- `snippet.columns` (`('title' | 'content')[]`), columns to return as highlighted snippets. When `snippet` is set without `columns`, `content` is used.
- `snippet.around` (`number`, default `30`), tokens of context around each match.
- `snippet.tag` (`string`, default `'mark'`), HTML tag used to wrap matches.

---

## Searching

### Section model

The plugin walks each document's parsed AST and produces one section per heading:

| Section             | When               | Content                                          |
| ------------------- | ------------------ | ------------------------------------------------ |
| **Page section**    | Always, level 1    | Frontmatter title + description                  |
| **Heading section** | For each `h1`–`h6` | All prose text between this heading and the next |

Search results are returned at section granularity: a single page can yield several hits for different headings. Only entries whose `meta.kind` is `document` (and that have parsed nodes) are indexed.

### Index management

The plugin maintains a `__fts_search` virtual FTS5 table and hooks into the Content instance lifecycle automatically:

| Event                         | Action                               |
| ----------------------------- | ------------------------------------ |
| First `search()` per instance | Build the index from the manifest    |
| `content.resetSearchIndex()`  | Drop the entire `__fts_search` table |

Indexing is lazy: the first `content.search()` builds that instance's sections on demand, so no explicit boot step is required.

```ts [search.ts]
// First call builds the index for this instance, then queries it.
const hits = await content.search('getting started')
```

### Query syntax

The plugin escapes each term and applies a wildcard suffix, so `"getting started"` matches `getting*` AND `started*`. To restrict matching to a column, pass `fields`:

```ts [title-only.ts]
const hits = await content.search('getting', {
  fields: ['title'],
})
```

For FTS5 advanced syntax (phrase queries, NEAR, boolean ops), pre-process your query string and pass it verbatim: see the [FTS5 reference](https://sqlite.org/fts5.html#full_text_query_syntax).

### Snippets and weights

Return highlighted snippets and tune BM25 ranking per column:

```ts [ranked.ts]
const hits = await content.search('streaming markdown', {
  limit: 10,
  weights: { title: 10, content: 5, heading: true },
  snippet: { columns: ['title', 'content'], around: 40, tag: 'mark' },
})

hits[0].snippets?.content   // '<mark>streaming</mark> markdown ...'
hits[0].rank                // BM25 rank, lower is better
```

---

## Shared database

`__fts_search` is **one** table with a `source` column, not one table per instance. That is what makes
searching across instances work: every instance writes its sections into the same table, and one
query ranks all of them together. An instance pinned with [`withRef()`](https://content.comark.dev/reference/content/with-ref)
writes its rows under `<name>@<key>`, so pinned instances share the table without mixing rows, and
[`clean()`](https://content.comark.dev/reference/content/clean) deletes only that instance's rows. Results always report the
plain instance name.

So when you compose instances with [`contentHub()`](https://content.comark.dev/advanced/hub), pass the **same** database to each one:

```ts [content.ts]
import sqlite from 'comark-content/database/sqlite-node'

// One database, shared by every instance.
const database = sqlite()

const docs = comarkContent('docs', {
  source: fs('./content/docs'),
  plugins: [sqliteFullTextSearch({ database })],
})

const blog = comarkContent('blog', {
  source: fs('./content/blog'),
  plugins: [sqliteFullTextSearch({ database })],
})

export const content = contentHub([docs, blog])

// One ranked result set over both instances.
const hits = await content.search('streaming markdown')
```

::warning
Give each instance its **own** `sqlite()` call and they each get a private `__fts_search` table. The
hub still answers, but it has to query each index separately and concatenate the results. BM25 scores
are computed per index, so they are not comparable across instances and the merged ordering is only
approximate. The hub logs a warning when it detects this.
::

Searching one instance directly (`docs.search(...)`) is unaffected — a private database is fine when
nothing needs to rank across instances. The [`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) plugin is also
unaffected: it keeps one `__search_<name>` table per instance and never ranks across them, so sharing
a database there is a convenience, not a requirement.


## Sitemap

See the full [sitemap](https://content.comark.dev/sitemap.md) for all pages.
