> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frogbot.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Collection search

> Rank collection records with lexical, vector, and hybrid search.

Collection search ranks records of one collection by text relevance, vector similarity, or both. You declare named indexes on the collection and query them with `frogbot.search()`, REST, or GraphQL, or [search several collections](#search-several-collections) in one call with `frogbot.searchMany()`. Ranking runs in the database.

The [Postgres and Vercel Postgres](https://docs.frogbot.ai/database/postgres.md#collection-search), [SQLite](https://docs.frogbot.ai/database/sqlite.md#collection-search), and [MongoDB](https://docs.frogbot.ai/database/mongodb.md#collection-search) adapters implement search, and every option on this page works the same way on each. The [Cloudflare D1](https://docs.frogbot.ai/database/sqlite.md#d1-collection-search) adapter implements lexical search; D1 has no native vector search, so an index with `vector` fails setup with reason `engine-gap`. Adapter pages list engine-specific details such as supported metrics and languages. On other adapters, any configured search index fails database initialization with `SearchCapabilityError` and reason `not-implemented`; leave `search` off those collections and use [vector fields](https://docs.frogbot.ai/fields/vector.md) for storage only.

## Configure indexes

Each key under `search` names an index and selects the fields it ranks:

```ts theme={"theme":"tokyo-night"}
import type { CollectionConfig } from 'frogbot';

export const Articles: CollectionConfig = {
  slug: 'articles',
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'body', type: 'textarea' },
    { name: 'embedding', type: 'vector', dimensions: 1536 },
  ],
  search: {
    content: {
      lexical: { fields: ['title', 'body'] },
      vector: { field: 'embedding' },
    },
    titles: {
      lexical: { fields: ['title'] },
    },
  },
};
```

`dimensions` belongs to the vector field, not the index. When an index includes both components, supplying both query inputs selects hybrid mode with configured reciprocal-rank fusion (`rrf`). FrogBot does not calculate embeddings; your application supplies stored vectors and query vectors. The adapter creates any native indexes as part of the database schema.

### Index options

| Option | Type | Default | Description |
| - | - | - | - |
| `lexical.fields` | `string[]` | Required with `lexical` | Text and textarea fields to rank by text relevance. |
| `lexical.language` | `string` | `'simple'` | Stemming and tokenizing language. Each adapter lists the languages it supports. |
| `vector.field` | `string` | Required with `vector` | The vector field to rank by similarity. |
| `vector.metric` | `'cosine' \| 'euclidean' \| 'dotProduct'` | `'cosine'` | Similarity metric. |
| `vector.approximate` | `boolean` | `true` | Build an approximate nearest-neighbour index for this field. Set `false` to always search exactly. |
| `hybrid.fusion` | `'rrf'` | `'rrf'` | Reciprocal rank fusion. It is the only method. |
| `hybrid.weights` | `{ lexical: number; vector: number }` | `{ lexical: 1, vector: 1 }` | Positive multipliers for each component's contribution to the fused score. |
| `defaultCandidates` | `number` | `100` | The [candidate count](#candidates) when the query does not set `candidates`. Only indexes with `vector` accept it. |
| `filters` | `{ fields: string[] } \| { exclude: string[] }` | Every eligible field | Which stored fields `where` may filter on: only `fields`, or every eligible field except `exclude`. |

An index with both `lexical` and `vector` accepts a `hybrid` object:

```ts theme={"theme":"tokyo-night"}
import type { CollectionConfig } from 'frogbot';

export const Articles: CollectionConfig = {
  slug: 'articles',
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'embedding', type: 'vector', dimensions: 1536 },
  ],
  search: {
    content: {
      lexical: { fields: ['title'] },
      vector: { field: 'embedding' },
      hybrid: { weights: { lexical: 1, vector: 2 } },
      defaultCandidates: 200,
    },
  },
};
```

## Query

The operation accepts `collection`, a named `index`, and `query` (`SearchOptions`). The query inputs select the mode:

```ts theme={"theme":"tokyo-night"}
import type { FrogBotRequest } from 'frogbot';

export async function searchArticles(req: FrogBotRequest, vector: number[]) {
  const lexical = await req.frogbot.search({
    collection: 'articles',
    index: 'content',
    query: { text: 'account recovery' },
    where: { title: { equals: 'Account recovery' } },
    limit: 10,
    select: { title: true },
    req,
  });

  const semantic = await req.frogbot.search({
    collection: 'articles',
    index: 'content',
    query: { vector },
    req,
  });

  const hybrid = await req.frogbot.search({
    collection: 'articles',
    index: 'content',
    query: { text: 'account recovery', vector },
    req,
  });

  return { lexical, semantic, hybrid };
}
```

### Search options

| Option | Type | Default | Description |
| - | - | - | - |
| `collection` | `CollectionSlug` | Required | Collection to search. |
| `index` | `string` | Required | Name of an index under the collection's `search`. Checked at runtime. |
| `query` | `{ text?: string; vector?: number[] }` | Required | `text` alone runs lexical search, `vector` alone runs vector search, and both run hybrid search. The index must have the matching components. |
| `where` | `Where` | — | Filter applied before ranking. Only fields allowed by the index's `filters` can be used. |
| `limit` | `number` | `10` | Maximum hits. Must be a positive integer. |
| `candidates` | `number` | The index's `defaultCandidates`, then `100` | See [Candidates](#candidates). Must be a positive integer. |
| `select` | `SelectType` | Every field | Fields to return on each `doc`. |
| `depth` | `number` | `0` | Relationship depth for each `doc`. Must be a non-negative integer. |
| `draft` | `boolean` | `false` | Search and return draft versions. |
| `locale` | `string` | The request locale | Locale to search and return. `'all'` is rejected because each hit needs one locale. |
| `fallbackLocale` | `string \| false` | The request fallback | Locale used for missing localized values, or `false` for none. |
| `overrideAccess` | `boolean` | `false` | Set `true` to skip collection and field read access. Required when `req` is omitted. |
| `req` | `FrogBotRequest` | Required unless `overrideAccess: true` | Request whose user's access is enforced. |

Search enforces access and requires `req` unless you explicitly set `overrideAccess: true`. `index` is currently typed as a string; index names are checked against the collection at runtime. The `vector` must match the indexed field's dimensions and contain finite numbers. A text query must be non-empty, and `limit` defaults to 10 (positive integer required). `where`, `select`, `depth` (defaults to `0`), `candidates`, `draft`, `locale`, and `fallbackLocale` are options, but which filters and modes can run depends on the adapter and selected index.

The database ranks only documents the caller can read: collection read access and your `where` filter are applied before ranking. FrogBot then reads the ranked documents in one ordinary collection read, so collection and field read hooks run on each result as they do for any other read. A ranked document that this read does not return, for example because it was deleted or is no longer readable, is left out; `hits` can therefore be shorter than `limit`. Field-level read access on a ranked or filtered field stops the query with `SearchFilterUnsupportedError` while access is enforced, because scores computed over a hidden value would reveal it.

The result is `{ mode, ranking, hits }`: `mode` is `lexical`, `vector`, or `hybrid`; `ranking` contains `method`, `higherIsBetter`, and `approximate`; each hit has `{ doc, score }`, in ranked order. Hybrid results add component details, described below. Scores are ordering values, not probabilities; scores from different methods are not directly comparable, and neither are [scores from different collections](#scores-across-collections). An empty `hits` array is a successful no-match result. Results hold only the top hits: there are no totals, pages, or cursors. Ranking and hybrid fusion run in the database, with no JavaScript similarity or fusion fallback.

### Candidates

`candidates` sets how many top matches a ranked component considers before `limit` is applied. It has one meaning on every adapter and applies to vector and hybrid queries:

* **Approximate vector search** asks the nearest-neighbour index for this many neighbours. A larger value finds more true neighbours, and leaves more hits after your filters on adapters whose index cannot filter, at the cost of a slower query.
* **Hybrid search** takes this many top matches from the lexical and vector components separately, then fuses them. A document outside both candidate lists cannot appear in the results, so a larger value can surface documents that rank moderately in both components.

Lexical queries and exact vector queries rank every matching document and ignore it. Each adapter page describes how its engine uses the count.

The count comes from the query's `candidates`, then the index's `defaultCandidates`, then `100`, and is never lower than `limit`:

```ts theme={"theme":"tokyo-night"}
import type { FrogBotRequest } from 'frogbot';

export async function deepSearch(req: FrogBotRequest, vector: number[]) {
  return req.frogbot.search({
    collection: 'articles',
    index: 'content',
    query: { text: 'account recovery', vector },
    limit: 10,
    candidates: 500,
    req,
  });
}
```

`candidates` must be a positive integer.

### Hybrid components

Each hybrid hit also has `components`, the rank and score the document received from each component before fusion. A component the document did not match is `null`:

```ts theme={"theme":"tokyo-night"}
const hit = {
  doc: { id: 12, title: 'Account recovery' },
  score: 0.0325,
  components: {
    lexical: { rank: 1, score: 0.1 },
    vector: { rank: 3, score: 0.92 },
  },
};
```

Component scores use the same scale as a lexical-only or vector-only search on the same index. `ranking.components` describes each one with `method`, `higherIsBetter`, and `approximate`, so you can, for example, drop hits whose vector similarity is below a threshold. Lexical and vector results do not include `components`.

## Search several collections

`frogbot.searchMany()` runs one query against several collections and returns one ranked list per collection. Each entry names a collection and one of its indexes, plus the options that depend on that collection's fields:

```ts theme={"theme":"tokyo-night"}
import type { FrogBotRequest } from 'frogbot';

export async function siteSearch(req: FrogBotRequest, text: string) {
  const { results } = await req.frogbot.searchMany({
    collections: [
      { collection: 'articles', index: 'content', select: { title: true } },
      { collection: 'faqs', index: 'answers', select: { question: true } },
    ],
    query: { text },
    limit: 5,
    req,
  });

  const [articles, faqs] = results;

  return { articles: articles.hits, faqs: faqs.hits };
}
```

`results` follows the order of `collections`. Each list holds up to `limit` hits from its collection, in the same order and with the same scores as a `frogbot.search()` of that collection alone. A search box can show the lists as sections, such as "Articles" and "FAQs". FrogBot doesn't order hits from different collections against each other, because their [scores aren't comparable](#scores-across-collections). When you need one list, [merge the results with rerank](#merge-results-with-rerank).

### Search many options

These options apply to every collection in the call:

| Option | Type | Default | Description |
| - | - | - | - |
| `collections` | `SearchManyCollection[]` | Required | Collections to search, each with the [entry options](#collection-entries) below. Must not be empty, and a collection can appear only once. |
| `query` | `{ text?: string; vector?: number[] }` | Required | Searched in every collection and selects the mode as in `search()`. Every listed index must have the matching components, and a `vector` must match each index's dimensions. |
| `limit` | `number` | `10` | Maximum hits per collection. Must be a positive integer. |
| `draft` | `boolean` | `false` | Search and return draft versions. |
| `locale` | `string` | The request locale | Locale to search and return. `'all'` is rejected because each hit needs one locale. |
| `fallbackLocale` | `string \| false` | The request fallback | Locale used for missing localized values, or `false` for none. |
| `overrideAccess` | `boolean` | `false` | Set `true` to skip collection and field read access in every collection. Required when `req` is omitted. |
| `req` | `FrogBotRequest` | Required unless `overrideAccess: true` | Request whose user's access is enforced in every collection. |

#### Collection entries

Each entry in `collections` accepts:

| Option | Type | Default | Description |
| - | - | - | - |
| `collection` | `CollectionSlug` | Required | Collection to search. |
| `index` | `string` | Required | Name of an index under this collection's `search`. Checked at runtime. |
| `where` | `Where` | — | Filter applied before ranking. Only fields allowed by this index's `filters` can be used. |
| `select` | `SelectType` | Every field | Fields to return on each `doc`. |
| `candidates` | `number` | The index's `defaultCandidates`, then `100` | See [Candidates](#candidates). Must be a positive integer. |
| `depth` | `number` | `0` | Relationship depth for each `doc`. Must be a non-negative integer. |

Any other key on an entry, such as its own `query` or `limit`, fails with `SearchValidationError`.

### Search many results

The result is `{ results }`, with one item per entry:

| Field | Type | Description |
| - | - | - |
| `collection` | `CollectionSlug` | The entry's collection. |
| `mode` | `'lexical' \| 'vector' \| 'hybrid'` | The mode selected by the shared query, the same for every item. |
| `ranking` | `{ method, higherIsBetter, approximate, components? }` | How this collection's index ranked, as in a single search. It can differ between collections, for example when one index is approximate and another is exact. |
| `hits` | `{ doc, score, components? }[]` | This collection's ranked hits, as in a single search. Can be empty or shorter than `limit`. |

Each item is typed by its collection: in the example above, `articles.hits[0].doc` is an article and `faqs.hits[0].doc` is a FAQ. When you loop over `results`, checking `item.collection` narrows the document type.

### Failures

The call succeeds or fails as a whole. Before running any query, FrogBot checks the list and every entry: an empty list, an entry that isn't an object or lacks `collection` or `index`, an unknown entry key, a repeated collection, an index that isn't configured, an index without the query's mode, a vector of the wrong length, or an invalid option fails with `SearchValidationError`. `locale: 'all'` fails with `SearchFilterUnsupportedError`, also with status `400`.

FrogBot then searches the collections one after another, in list order, on the same request, so the call also works inside a database transaction. If any collection fails, the whole call fails and returns no results. A collection the user can't read fails the call with `403`, as a single search of it would, rather than being left out. Errors about one entry's index, query, options, or filters name its collection and index, for example `Search index 'answers' in collection 'faqs' requires a vector of 768 finite numbers.`

### Scores across collections

Within one collection, a hit ranked higher is a better match. Across collections the scores don't line up:

* **Text scores are relative to each collection.** On SQLite, D1, and MongoDB, BM25 weighs each word by how rare it is in that collection's records, so the same word scores differently in 40 FAQs than in 8,000 articles. On every adapter, the score also depends on which fields and language the index ranks.
* **Vector scores depend on each index's metric.** Cosine and dot product are similarities where higher is better; Euclidean is a distance where lower is better. Two vector fields with the same dimensions can still hold embeddings from different models, and FrogBot doesn't record which model produced a vector, so it can't tell whether two similarities are comparable.
* **Hybrid scores come from rank positions.** Reciprocal rank fusion scores a document by where it ranked within its own collection, so comparing fused scores across collections compares positions, not how well each document matched.

### Merge results with rerank

To get one list, for example to choose passages for an AI answer, merge the lists and order them with [`frogbot.rerank()`](https://docs.frogbot.ai/ai/overview#rerank-options). It scores each text against the query with your rerank model, so the scores are comparable regardless of which collection a hit came from:

```ts theme={"theme":"tokyo-night"}
import type { FrogBotRequest } from 'frogbot';

export async function retrieve(req: FrogBotRequest, question: string) {
  const { results } = await req.frogbot.searchMany({
    collections: [
      { collection: 'articles', index: 'content' },
      { collection: 'faqs', index: 'answers' },
    ],
    query: { text: question },
    limit: 20,
    req,
  });

  const [articles, faqs] = results;

  const passages = [
    ...articles.hits.map(({ doc }) => ({
      collection: 'articles',
      id: doc.id,
      text: `${doc.title}\n${doc.body ?? ''}`,
    })),
    ...faqs.hits.map(({ doc }) => ({
      collection: 'faqs',
      id: doc.id,
      text: `${doc.question}\n${doc.answer}`,
    })),
  ];

  const { ranking } = await req.frogbot.rerank({
    model: 'voyage/rerank-2.5',
    query: question,
    documents: passages.map(({ text }) => text),
    topN: 5,
    req,
  });

  return ranking.map(({ originalIndex, score }) => ({ ...passages[originalIndex], score }));
}
```

`ranking` is sorted by relevance, and `originalIndex` points back into `documents`. Reranking calls a model on every search, which adds cost and delay, and needs a configured rerank model.

## REST API

Every collection with a search index gets a `POST /api/{collection-slug}/search` endpoint. The JSON body accepts the same options as `frogbot.search()` except `collection`, `overrideAccess`, and `req`: the endpoint always searches as the requesting user with access enforced.

```ts theme={"theme":"tokyo-night"}
const response = await fetch('/api/articles/search', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({
    index: 'content',
    query: { text: 'account recovery' },
    where: { title: { equals: 'Account recovery' } },
    limit: 5,
  }),
});

const { mode, ranking, hits } = await response.json();
```

The response body is the same `{ mode, ranking, hits }` object that `frogbot.search()` returns. See the [REST API](https://docs.frogbot.ai/rest-api/overview.md) for authentication.

`POST /api/frogbot/search` runs [`frogbot.searchMany()`](#search-several-collections). It exists when at least one collection has a search index. The body accepts `collections`, `query`, `limit`, `draft`, `locale`, and `fallbackLocale`, and each entry accepts the same keys as in `searchMany()`; any other key fails with `400`. It also searches as the requesting user with access enforced, and a collection that user can't read fails the whole request with `403`.

```ts theme={"theme":"tokyo-night"}
const response = await fetch('/api/frogbot/search', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({
    collections: [
      { collection: 'articles', index: 'content' },
      { collection: 'faqs', index: 'answers' },
    ],
    query: { text: 'reset password' },
    limit: 5,
  }),
});

const { results } = await response.json();
```

The response body is the same `{ results }` object that `frogbot.searchMany()` returns. Both endpoints report errors with the statuses in [Setup and errors](#setup-and-errors).

## GraphQL

Every searchable collection with GraphQL queries enabled also gets a `search` query named after its plural GraphQL name, such as `searchArticles`. Setting the collection's `graphQL` option to `false` or `graphQL.disableQueries` to `true` omits it.

```graphql theme={"theme":"tokyo-night"}
query {
  searchArticles(index: "content", text: "account recovery", limit: 5) {
    mode
    ranking {
      method
      higherIsBetter
      approximate
    }
    hits {
      score
      doc {
        id
        title
      }
    }
  }
}
```

Hybrid hits expose `components { lexical { rank score } vector { rank score } }`, and `ranking` exposes `components { lexical { method higherIsBetter approximate } vector { ... } }`. Both are `null` for lexical and vector queries.

The query accepts `index`, `text`, `vector`, `where`, `limit`, `candidates`, `draft`, and, when localization is enabled, `locale` and `fallbackLocale`. `doc` uses the collection's GraphQL type. Like the REST endpoint, the query always enforces the requesting user's access. See the [GraphQL API](https://docs.frogbot.ai/graphql/overview.md).

GraphQL has no query that searches several collections. Call each collection's search query in one request instead, using aliases to name the results and to call the same query more than once:

```graphql theme={"theme":"tokyo-night"}
query {
  articles: searchArticles(index: "content", text: "reset password", limit: 5) {
    hits {
      score
      doc {
        id
        title
      }
    }
  }
  faqs: searchFaqs(index: "answers", text: "reset password", limit: 5) {
    hits {
      score
      doc {
        id
        question
      }
    }
  }
  titles: searchArticles(index: "titles", text: "reset password", limit: 5) {
    hits {
      doc {
        id
        title
      }
    }
  }
}
```

## Setup and errors

FrogBot checks every configured index when the database initializes, including when you create or run migrations. The check is silent when a configured index is compatible. On an adapter without search, **any configured index fails initialization** with `SearchCapabilityError` (`reason: 'not-implemented'`), naming the collection, index, and mode. An adapter also stops setup when the engine cannot run an option (`engine-gap`) or a database prerequisite is missing; see the [Postgres](https://docs.frogbot.ai/database/postgres.md#prerequisites) and [SQLite](https://docs.frogbot.ai/database/sqlite.md#prerequisites) prerequisites and the [D1 limits](https://docs.frogbot.ai/database/sqlite.md#d1-collection-search).

The core also exports `SearchValidationError`, `SearchFilterUnsupportedError`, and `SearchReadinessError` for invalid inputs, unsupported filters or visibility rules, and invalid or unready ranked results. REST and GraphQL report them with these HTTP statuses:

| Error | Status | Meaning |
| - | - | - |
| `SearchValidationError` | `400` | The index, query, or an option is invalid. |
| `SearchFilterUnsupportedError` | `400` | A filter, locale, or field visibility rule cannot be searched. |
| `SearchCapabilityError` | `501` | The database adapter cannot run the requested mode. |
| `SearchReadinessError` | `503` | The native index is not ready or returned an invalid ranked result. |

A caller without collection read access receives `403`. `SearchCapabilityError` carries a `reason`: `not-implemented`, `engine-gap`, `missing-prerequisite`, `permission-denied`, or `setup-failed`. See the [database status](https://docs.frogbot.ai/database/overview.md#collection-search-status) for adapter support.

## Adapter integration

Database adapters add search through a `search` object on the adapter. `capabilities` declares which modes an index supports. `buildSchema` receives every collection's normalized indexes while the database initializes, before tables and indexes are created, so native search indexes are part of schema push and migrations. `readiness` can reject a query while an index is still building, and `search` returns ranked `{ id, score }` rows for FrogBot to read back. The Postgres, SQLite, MongoDB, and D1 adapters implement this contract. `search` receives the resolved `candidates` count for vector and hybrid queries.

You can optionally call the existing `frogbot.rerank()` on readable hits, as in [Merge results with rerank](#merge-results-with-rerank), or compose retrieval and answer generation in your own application. There is no automatic embedding or built-in RAG preset for this collection search API.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.