# Dbrij Search Documentation

Everything you need to implement search with Dbrij Search: indexes, documents, querying, filters, facets, relevance settings and limits.

Canonical page: https://dbrij.com/products/search/docs

## Dbrij Search

Search your users can feel: typo tolerant, faceted and ranked, answered in milliseconds through one API call. You push JSON documents into an index and query them. Your documents, your fields, your relevance.

There are three things to know before anything else. An **index** is a named collection of documents with its own search rules. A **document** is any JSON object with an id, and sending the same id again replaces it. A **search only key** can read an index and do nothing else, which is what makes it safe to ship in a browser.

Base URL`https://api.dbrij.com/api`

Every response is wrapped as `{ "success": true, "data": … }`, the same envelope the rest of the Dbrij API uses. The examples below show the payload inside `data`.

## Quickstart

Three calls from nothing to a working search.

1. Create an index

```
curl -X POST https://api.dbrij.com/api/search/indexes \
  -H "Authorization: Bearer $DBRIJ_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "products", "settings": { "filterableFields": ["brand", "price"], "sortableFields": ["price"] } }'
```

2. Push your documents

```
curl -X POST https://api.dbrij.com/api/search/indexes/products/documents \
  -H "Authorization: Bearer $DBRIJ_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "documents": [
        { "id": "1", "title": "Running shoes", "brand": "Nomad", "price": 45000 },
        { "id": "2", "title": "Trail runners", "brand": "Ardent", "price": 62000 }
      ] }'
```

**Check what came back, not just the status.** A write reports itself honestly and your importer should read it, because "the request worked" and "your documents are in" are different claims:

2b. Trust the body, in whatever language you sync from

```
const res = await fetch(`${BASE}/search/indexes/products/documents`, { method: 'POST', headers, body });
const result = await res.json();

// 201 all landed · 207 some did not · 422 none did.
if (result.failed > 0) {
  // Every entry says which document and the engine's reason, cause included.
  console.error(`${result.failed} of ${result.failed + result.indexed} rejected`, result.errors);
  throw new Error('Sync incomplete');
}
```

3. Search from your front end

```
const res = await fetch(`${BASE}/search/indexes/products/query`, {
  method: 'POST',
  headers: {
    // A search only key. It can read this index and nothing else, which is
    // why it is fine for your users to see it.
    Authorization: 'Bearer dbrij_live_...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ q: input.value, facets: ['brand'], highlight: true }),
});
const { data } = await res.json();
render(data.hits);
```

Call it on every keystroke. A query is milliseconds of work, and search that waits for a button press stops feeling like search.

## Keys and safety

Pass a key as `Authorization: Bearer dbrij_live_…`. Keys are made in the dashboard under API keys and come in two kinds, and the difference is the most important thing on this page.

KEY KINDS

|  |  |  |
| --- | --- | --- |
| search only | search:query | Reads indexes. Cannot write a document, change relevance or delete anything. Ship it in your JavaScript, your mobile app, anywhere your users can read it. |
| admin | search:write, search:manage | Writes documents, changes settings, and reads Analytics (which needs search:manage, so a search only key cannot call it). Keep it on your server, in your secret manager, and never in a browser bundle. |

We store a hash of every key, so the full value is shown once at creation and is genuinely unrecoverable afterwards. Revoking takes effect within seconds. If an admin key ever reaches a public repository, revoke it first and investigate second.

**One index, many tenants: scoped tokens.** A search only key can read its whole index, which is not isolation when one index holds many customers\' rows, because anyone can open devtools and drop your filter. A scoped token fixes that: your backend mints it from a search key, the filter travels inside it, signed, and the browser gets the token instead of the key. The server verifies the signature and ANDs the embedded filter into every query; the client cannot loosen it.

Mint a scoped token on your server (Node)

```
import { createHash, createHmac } from 'node:crypto';

function scopedToken(rawKey, keyId, { filter, indexes, expiresInSeconds }) {
  const payload = Buffer.from(JSON.stringify({
    k: keyId,                                     // the search key's id (shown in the dashboard)
    f: filter,                                    // e.g. tenantId = "t_42", ANDed into every query
    ix: indexes,                                  // e.g. ['products'], the only indexes it may touch
    exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
  })).toString('base64url');
  const secret = createHash('sha256').update(rawKey).digest('hex');
  const sig = createHmac('sha256', secret).update(payload).digest('base64url');
  return `dbsst_${payload}.${sig}`;
}

// Hand the token to the browser; it calls /query with
//   Authorization: Bearer dbsst_…
// exactly as it would with a key.
```

The signing secret is the sha256 of your raw key: your server can derive it, Dbrij stores exactly that hash, and the person holding the token has neither. A scoped token can only ever query, whatever the parent key could do, and revoking the parent key kills every token minted from it at once.

## Frontend kit

Search in a browser without writing the plumbing: a script tag for any website, widgets, React components and a typed client, all with click tracking built in, so learned ranking, taste, experiments and recommendations learn from day one. Use a search only key or a scoped token, never an admin key.

Any website: one script tag

```
<div id="search"></div>
<script src="https://search.dbrij.com/search.js" async
  data-key="dbrij_live_your_search_key"
  data-index="products"
  data-target="#search"
  data-facets="brand,category"></script>
```

With `data-target` the whole experience draws inside that element: the box, refinements, results and pages, starting from `?q=` in the page address. Give `data-input` with your own search box instead and search opens in a panel over the page as people type; pressing `/` opens it too. Add `data-answers="true"` for an answer above the results, `data-understand="true"` to read plain language into filters, and `data-theme="dark"` or `auto` for dark colours.

Or install it

```
npm install @dbrij/search
```

Widgets

```
import { SearchClient, mountSearch, mountAutocomplete } from '@dbrij/search';

const client = new SearchClient({ key: 'dbrij_live_your_search_key' });

mountSearch('#search', client, { index: 'products', facets: ['brand'], answers: true });

mountAutocomplete('#site-search', client, {
  index: 'products',
  onSelect: ({ query, objectId }) => {
    location.href = objectId ? `/products/${objectId}` : `/search?q=${encodeURIComponent(query ?? '')}`;
  },
});
```

React

```
import { SearchProvider, SearchBox, RefinementList, Hits, Pagination } from '@dbrij/search/react';

<SearchProvider client={client} index="products" facets={['brand']}>
  <SearchBox />
  <RefinementList field="brand" label="Brand" />
  <Hits render={(hit, track) => <a href={`/products/${hit.id}`} onClick={track}>{hit.document.title}</a>} />
  <Pagination />
</SearchProvider>
```

**Clicks and conversions track themselves.** The widgets and components send each click with the search that produced it. A purchase later on, even on another page, is credited to that search for an hour:

A conversion, with no queryId to carry around

```
client.insights.converted({ index: 'products', objectIds: ['sku-1'], kind: 'purchase', value: 4500, currency: 'NGN' });
```

Drawing results yourself? Put `data-dbrij-object-id`, `data-dbrij-position`, `data-dbrij-index` and `data-dbrij-query-id` on each one and call `trackClicks(container, client)` once. Events are batched, sent within a second, flushed when the page closes, and never throw into your page.

**One person, one token.** The client gives each visitor an anonymous userToken and keeps it, so typing sessions, personal taste and experiments see one person. When they sign in, call `client.setAuthenticatedUserToken(yourUserId)` and their earlier activity joins their profile.

**Safe with any record.** Record text is always escaped, highlights keep only their `<mark>` tags, and links that are not web addresses are dropped, so a record cannot run script on your page.

## Indexes

Name an index after what it holds: products, articles, customers. Create one per kind of thing rather than one big index with a type field, because relevance settings are per index and mixing kinds means tuning for both at once.

POST`/search/indexes`search:manage

Create an index

An index holds documents and the rules for searching them. Every setting has a working default, so a bare name is a valid call.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| name* | string | 2 to 64 characters: lowercase letters, numbers, dashes and underscores. This is what every path uses. |
| settings | object | Optional relevance settings, see Relevance. Anything omitted keeps its default. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "products", "settings": { "searchableFields": ["title", "description"], "filterableFields": ["category", "price"] } }'
```

Request body

```
{ "name": "products", "settings": { "searchableFields": ["title", "description"], "filterableFields": ["category", "price"] } }
```

Response 200

```
{
  "success": true,
  "data": { "id": "3f…", "name": "products", "documentCount": 0, "status": "ready" }
}
```

GET`/search/indexes`search:manage

List indexes

Every index in the workspace with its document count, size and settings.

Request

```
curl -X GET https://api.dbrij.com/api/search/indexes \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": [{ "name": "products", "documentCount": 1240, "sizeBytes": 918273, "status": "ready" }]
}
```

PUT`/search/indexes/:name/settings`search:manage

Change relevance settings

Field lists, ranking, typo, matchFields and matchingStrategy settings apply to the next query with no rebuild. Changing synonyms, stop words, language, geoFields, splitLetterDigit or matchJoinedWords rebuilds the index in the background: the call returns immediately with status "rebuilding", queries keep working on the old rules, and the index flips to "ready" when the copy finishes. Poll GET /search/indexes to watch it. The response echoes every setting as stored, so compare it with what you sent. Sending the same settings again is a no-op and does not rebuild.

Request

```
curl -X PUT https://api.dbrij.com/api/search/indexes/:name/settings \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }'
```

Request body

```
{ "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }
```

Response 200

```
{
  "success": true,
  "data": { "name": "products", "settings": { "synonyms": [{ "terms": ["trousers", "pants"] }] } }
}
```

DELETE`/search/indexes/:name`search:manage

Delete an index

Removes the index and every document in it. Your own database is untouched, so this costs a reindex rather than the records.

Request

```
curl -X DELETE https://api.dbrij.com/api/search/indexes/:name \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": { "ok": true }
}
```

## Documents

A document is any JSON object with an id. Fields are typed as they arrive, and every string is stored twice: once broken into words for searching, once whole for filtering and faceting. That is what lets one field answer both "find shoes when I type shoe" and "category is exactly shoes".

POST`/search/indexes/:name/documents`search:write

Add or replace documents

Up to 1,000 documents a call. Sending the same id again replaces that document, so re-running an import is a sync, not a pile of duplicates. CHECK THE STATUS: 201 means every document landed, 207 means some did not, and 422 means none did. Read `indexed` and `failed` either way.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| documents* | object[] | Any JSON you like. Each one needs an id field. |
| primaryKey | string | The field carrying the id, when it is not called `id`. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/documents \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "documents": [{ "id": "sku-1", "title": "Running shoes", "price": 45000, "brand": "Nomad" }] }'
```

Request body

```
{ "documents": [{ "id": "sku-1", "title": "Running shoes", "price": 45000, "brand": "Nomad" }] }
```

Response 200

```
{
  "success": true,
  "data": { "indexed": 1, "failed": 0, "errors": [] }
}
```

POST`/search/indexes/:name/documents/update`search:write

Partially update documents

Only the fields you send change; everything else on the document stays. Built for volatile fields: a price or stock tick is a two field call, not a resend of the whole record. A document that does not exist is reported per id, never half created.

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/documents/update \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "documents": [{ "id": "sku-1", "price": 42000, "inStock": false }] }'
```

Request body

```
{ "documents": [{ "id": "sku-1", "price": 42000, "inStock": false }] }
```

Response 200

```
{
  "success": true,
  "data": { "updated": 1, "failed": 0, "errors": [] }
}
```

POST`/search/indexes/:name/documents/delete`search:write

Delete documents by id or by filter

Send ids to remove named documents, or a filter expression to remove everything that matches it. Retention rules and erasure requests are usually a filter, not a list.

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/documents/delete \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filter": "author.id = 'usr_42'" }'
```

Request body

```
{ "filter": "author.id = 'usr_42'" }
```

Response 200

```
{
  "success": true,
  "data": { "deleted": 17 }
}
```

POST`/search/indexes/:name/clear`search:write

Empty an index

Removes every document and keeps the index and its settings, which is what a full rebuild wants.

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/clear \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": { "ok": true }
}
```

**Keeping in sync.** Push on write: when a record changes in your database, push that one document. Ids make it an update, so there is nothing to reconcile. A nightly full push is a reasonable safety net on top, and never a substitute.

## Searching

POST`/search/indexes/:name/query`search:query

Search an index

The one call your app makes. Safe to call straight from a browser with a search only key.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| q | string | What was typed. Empty returns everything, which is how you browse with facets. |
| filter | string | A filter expression over filterable fields, see Filters and facets. |
| facets | string[] | Fields to count values for, so you can draw refinement lists. |
| refinements | object | The values picked in refinement lists, by field, like `{ "brand": ["Nomad"] }`. Any value of a field, every field. A refined field is counted without its own picks, so the other values stay offered. See Filters and facets. |
| sort | string[] | `field:asc` or `field:desc`, applied before relevance. |
| page | number | Defaults to 1. |
| perPage | number | Defaults to 20, up to 100. |
| highlight | boolean | Wraps matched words in `<em>` so you can bold them. |
| typoTolerance | boolean | Overrides the index default for this one query. |
| cursor | string | Deep pagination: pass the cursor from the previous response instead of page. Stable at any depth, which page numbers are not. |
| groupBy | string | Collapse near duplicates: one representative hit per value of this filterable field (the same shirt in six colours becomes one row). |
| aroundLatLng | [lat, lng] | Where the person searching is. Required for geoRanking and the geo ranking stage; without an origin distance cannot influence the order. |
| mode | string | keyword (default), semantic, or hybrid. Semantic and hybrid need the index to have semantic search enabled, see Semantic search. |
| matchFields | string | best (default) needs every word inside one field; cross lets one query’s words land in different fields of the same document. Overrides the index setting for this query. See Relevance. |
| matchingStrategy | string | all (default) returns only documents holding every word; most returns the near misses when nothing holds them all. See Relevance. |
| analytics | boolean | Set false to keep this query out of the analytics report. Use it for smoke tests and diagnostics. The query is still billed: the engine still did the work. |
| userToken | string | Who is searching, as your own pseudonymous id (never an email). Personalizes results when the index learns taste, keeps a person in one experiment variant, and should match the userToken on their events. |
| ruleContexts | string[] | Contexts merchandising rules can be aimed at, like "mobile" or "homepage". |
| rules | boolean | Set false to search without merchandising rules (a raw preview). |
| personalize | boolean | Set false to search without this person’s taste. |
| explain | boolean | Adds hit.ranking to each hit: whether it was pinned by a rule, moved by learned ranking (from and to), lifted by taste or by the predicted category. |
| understand | boolean | Reads plain language ("trainers under 20000 in Lagos") into words, a filter and a sort, returned as understood. Needs understanding on for the index and three words or more. See Answers and plain language. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/query \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "runing shoes", "filter": "price < 50000", "facets": ["brand"], "highlight": true }'
```

Request body

```
{ "q": "runing shoes", "filter": "price < 50000", "facets": ["brand"], "highlight": true }
```

Response 200

```
{
  "success": true,
  "data": {
    "hits": [{ "id": "sku-1", "document": { "title": "Running shoes", "price": 45000 }, "score": 8.21, "highlights": { "title": ["<em>Running</em> shoes"] } }],
    "total": 1,
    "page": 1,
    "perPage": 20,
    "totalPages": 1,
    "tookMs": 4,
    "query": "runing shoes",
    "facets": { "brand": { "Nomad": 1 } },
    "cursor": "WzguMjEsInNrdS0xIl0",
    "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77",
    "appliedRules": [{ "id": "r-1", "name": "Shoe week" }],
    "userData": [{ "banner": "Shoe week: 20% off", "link": "/sale" }],
    "categories": [{ "value": "Footwear", "confidence": 0.86 }]
  }
}
```

POST`/search/indexes/:name/suggest`search:query

Suggest while someone types

Prefix matching over the searchable fields, tuned for a dropdown under the search box: "run" finds "Running shoes" before the word is finished. Metered like a search.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| q* | string | What has been typed so far. |
| limit | number | 1 to 20, defaults to 5. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/suggest \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "run", "limit": 5 }'
```

Request body

```
{ "q": "run", "limit": 5 }
```

Response 200

```
{
  "success": true,
  "data": { "suggestions": [{ "id": "sku-1", "document": { "title": "Running shoes" }, "score": 4.1 }], "tookMs": 2 }
}
```

POST`/search/multi-search`search:query

Search several indexes at once

People search for things, not kinds: one call runs up to 10 queries across your indexes and returns each result set labelled by index. Each query in the batch is metered as a search.

Request

```
curl -X POST https://api.dbrij.com/api/search/multi-search \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "queries": [{ "index": "products", "q": "invoice" }, { "index": "articles", "q": "invoice" }] }'
```

Request body

```
{ "queries": [{ "index": "products", "q": "invoice" }, { "index": "articles", "q": "invoice" }] }
```

Response 200

```
{
  "success": true,
  "data": { "results": [{ "index": "products", "hits": [], "total": 0 }, { "index": "articles", "hits": [{ "id": "a1" }], "total": 1 }] }
}
```

**Paging deep.** Page numbers are fine for a results page; for export or infinite scroll use the `cursor` that comes back on every response and pass it to the next call. It walks the whole index in order without the drift and cost page offsets have at depth.

**Typos are forgiven by default.** One wrong letter on short words, two on longer ones. Turn it off per query with `typoTolerance: false` when you are searching identifiers, where "sku-1234" and "sku-1235" are different things rather than a near miss.

**Highlights** come back per field as an array of snippets with matches wrapped in `<em>` (a long field can produce several snippets). Render them as text unless you trust every document in the index, since the surrounding content is yours, not ours.

## Filters and facets

A filter narrows what can match, before relevance is considered. It may only name fields listed in the index's `filterableFields`: anything else is refused by name, which keeps a filter string that came from a URL from reaching further than you meant it to.

FILTER GRAMMAR

|  |  |  |
| --- | --- | --- |
| = != | any | Exact match. Strings are compared whole, so `brand = "Nomad"` does not match "Nomad Sport". |
| > >= < <= | number | Ranges over numeric fields, e.g. `price >= 10000`. |
| IN [a, b] | any | Any of the listed values: `category IN ["shoes", "boots"]`. |
| AND OR |  | AND binds tighter than OR. Use brackets when you mean otherwise. |
| ( ) |  | Grouping, e.g. `(brand = "Nomad" OR brand = "Ardent") AND price < 50000`. |
| EXISTS NOT EXISTS | any | Whether the field is present on the document at all: `location EXISTS`. A document missing the field is not the same as one where it is empty, and a geo filter silently excludes both. Works on any field in filterableFields, and on a geoFields field without listing it as filterable as well. |
| IS NULL IS NOT NULL | any | The same question in the other spelling. `zone IS NULL` is `zone NOT EXISTS`. |
| _geoRadius(field, lat, lng, m) | geo | Everything within m meters of the point, on a field declared in geoFields: `_geoRadius(location, 6.45, 3.39, 5000)`. |

**Length.** A filter may be up to 20,000 characters, which holds a list of roughly a thousand values. That is deliberate room for the common case of narrowing to one neighbourhood or one tenant with `IN [...]`. Splitting one logical search across several queries to get under a limit costs you metered searches and gives you scores from different queries, which are not comparable.

**Catalogues that are still filling in.** A geo filter excludes documents with no coordinates yet, which is rarely what you want while you are still importing. Say so explicitly: `_geoRadius(location, 6.45, 3.39, 5000) OR location NOT EXISTS`.

**Dates.** Send dates as ISO 8601 strings (`2026-08-24T10:00:00Z`) and they are stored as real times, so ranges do what you mean: `publishedAt >= "2026-07-01"` is the last month, not an alphabet comparison.

**Arrays and nested objects.** An array field matches when any element matches, so `tags = "sale"` finds a document whose tags are `["new", "sale"]`, and faceting on it counts every element. Reach into nested objects with dots: `author.country = "NG"`. Declare the dotted path itself in `filterableFields`.

**Near me.** Declare the field in `geoFields`, store it as `{ "lat": 6.45, "lon": 3.39 }`, then filter with `_geoRadius` and sort by distance with `"sort": ["_geoDistance(location, 6.45, 3.39):asc"]`. To blend distance into relevance instead of sorting by it outright, send the searcher's position as `aroundLatLng` on the query and set `geoRanking` on the index, see Relevance.

A faceted product page in one call

```
{
  "q": "shoes",
  "filter": "inStock = true AND price < 50000",
  "facets": ["brand", "category"],
  "sort": ["price:asc"],
  "perPage": 24
}
```

Facets come back as counts per value, computed over everything the filter allowed. That is what lets you show "Nomad (12)" beside a checkbox and have the number be true.

**Picks go in refinements, not the filter.** Put what a shopper ticked in `refinements`. The results narrow exactly as they would with the same picks in the filter, but a refined field is counted as if its own picks were not made, so after choosing Nomad the brand list still shows Ardent with its count, ready to add. Every other filter and pick still applies to that count. It is one search, billed once.

Two brands picked, sizes still counted under both

```
{
  "q": "shoes",
  "filter": "inStock = true",
  "refinements": { "brand": ["Nomad", "Ardent"], "size": [42] },
  "facets": ["brand", "size"]
}
```

## Relevance

Settings live on the index. Change them in the dashboard or through the settings endpoint.

SETTINGS

|  |  |  |
| --- | --- | --- |
| searchableFields | string[] | What a query looks at, most important first. Empty searches every text field, which is the right start for most people. |
| filterableFields | string[] | The only fields a filter may name. |
| sortableFields | string[] | The only fields a sort may name. |
| retrievableFields | string[] | What comes back in a hit. Empty returns the whole document. |
| typoTolerance | boolean | Forgive typos. On by default. Not available together with matchFields: "cross". |
| matchFields | string | best (default) needs every word of a query inside ONE field; cross lets them land in different fields of the same document, while still ranking by field weights. See Matching below. |
| matchingStrategy | string | all (default) returns only documents holding every word; most keeps short queries strict and lets longer ones through on a strong majority. |
| splitLetterDigit | boolean | Split where letters meet digits, so "item7" and "item 7" find each other and the number is required rather than optional. An index setting only, never per query: it changes how documents are stored, so it rebuilds the index from the documents it already holds. Nothing needs re-pushing. |
| matchJoinedWords | boolean | Also index adjacent words joined, so "amalasky" finds "Amala Sky". Phone keyboards drop spaces constantly and typo tolerance does not cover a missing one. An index setting only, never per query, because it lives in the stored index. Rebuilds the index; costs index size, not query time. |
| stopWords | string[] | Words ignored in a query. Leave empty unless you have a reason: stop words hurt phrase searches. |
| synonyms | group[] | Two shapes: { terms: ["sofa", "couch"] } makes every term match the others, and { from: ["panadol"], to: ["paracetamol"] } is one way, so the query "panadol" finds paracetamol without the reverse. Mine the "found nothing" list for candidates. |
| language | string | Used for stemming, so "running" finds "run". Defaults to english. |
| geoFields | string[] | Fields holding a location as { lat, lon }. Declared here so the engine stores them as points; changing this rebuilds the index. |
| customRanking | string[] | Business signals as tiebreakers after text relevance, e.g. ["popularity:desc", "createdAt:desc"]. When two hits match equally well, these decide who wins. |
| geoRanking | object | Blend distance into the score rather than filtering or sorting by it. Needs aroundLatLng on the query, otherwise it simply does not apply. |
| rankingChain | string[] | What decides the order and in which order it decides: stages of relevance, geo, custom. Defaults to relevance then custom. Putting geo in the chain is the blunt tool where nearest wins outright; geoRanking is the fine one, use one at a time. |
| facetNormalisation | object | Fold the many spellings of one facet value into one ("XL", "xl", "X Large" count together). Changing it rebuilds the index. |
| facetAliases | object | Display labels for folded facet values, read at query time with no rebuild. |
| semantic | object | Match meaning as well as words: { enabled, model, fields, weight }. Turning it on or changing the model rebuilds the index. See Semantic search. |
| fusion | string | How keyword and semantic result lists become one in hybrid mode: rrf (default, rank based, nothing to calibrate) or linear. Query time, no rebuild. |

**Matching: where the words have to be.** By default a document matches when some single searchable field contains every word. That suits prose and is wrong for records assembled from parts. If your items carry `itemName`, `storeName` and `zone`, then `"jollof rice ikeja"` returns nothing when the dish is in the name and the place is in the zone, even though the document plainly holds every word. Set `matchFields: "cross"` and the searchable fields are pooled for matching while your weights still decide the order. Thing plus place is how people search a catalogue, so most marketplaces want this on.

**Matching: how many words have to land.** `matchingStrategy: "all"` is precise and returns nothing when a long query has no perfect document. `"most"` keeps a two word query strict and lets a five word one through on four, so a stray word returns the near misses instead of an empty page. Set either on the index or per query.

**One caveat.** Typo tolerance and `matchFields: "cross"` cannot both apply to one query: the engine will not fuzzy match across pooled fields. Cross wins and typo tolerance is skipped for that query, rather than the query being refused.

**Field weights.** Searchable fields take a weight with a caret: `"title^3"` counts a title match three times as hard as a body match. Order plus weights is usually all the tuning a catalogue needs before customRanking earns its keep.

**The workflow that actually improves search.** Read the "found nothing" list in Analytics. Each row is somebody who wanted something and did not get it. Most of them are one synonym away, or one field that should have been searchable.

**Synonym mining does that reading for you.** The dashboard's Relevance page suggests synonyms mined from your own missed queries (each candidate shows how many searches it would have rescued), and accepting one applies it to the index in a click. Start there before writing synonyms by hand.

## Semantic search

Keyword search matches what somebody typed. Semantic search matches what they meant, which is the difference between "something for a headache" finding nothing and finding paracetamol. Neither is better: keyword is exact and cheap and gets product codes right; semantic is forgiving and gets intent right. **Hybrid runs both and fuses the two lists**, and is what most search boxes want.

Turn it on per index, in the dashboard's index settings or through the settings endpoint. Enabling it (or changing the model) rebuilds the index in the background while queries keep serving on the old rules. It is off by default and needs a paid plan, because a vector index is resident memory.

Enable it on an index

```
curl -X PUT https://api.dbrij.com/api/search/indexes/products/settings \
  -H "Authorization: Bearer $DBRIJ_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "settings": { "semantic": { "enabled": true, "model": "voyage-4-lite", "fields": [], "weight": 0.5 } } }'
```

SEMANTIC SETTINGS

|  |  |  |
| --- | --- | --- |
| enabled | boolean | Off by default. Turning it on embeds your documents and rebuilds the index. |
| model | string | voyage-4-lite (default: good quality, smallest memory), voyage-4 (better on specialised vocabulary, four times the memory), or text-embedding-3-small (for teams that would rather not add a second vendor). |
| fields | string[] | Fields whose text is embedded. Empty means the searchable fields, which is almost always what you want. |
| weight | number | How much of the ranking meaning gets, 0 to 1. At 0 semantic contributes nothing; at 1 keyword does. 0.5 is the sensible start. |

**Choosing per query.** Send `mode` on the query call: `"keyword"` (default), `"semantic"`, or `"hybrid"`. A common pattern is hybrid for the main search box and keyword for identifier lookups where "sku-1234" must never fuzzy match "sku-1235".

A hybrid query

```
{ "q": "something for a headache", "mode": "hybrid", "perPage": 10 }
```

**Fusion.** In hybrid mode the keyword list and the semantic list become one ranking. The default, `rrf` (reciprocal rank fusion), only reads the position a document reached in each list, so there is nothing to calibrate: a hit near the top of either list rises, a hit near the top of both wins. `linear` adds the scores instead and is offered for anyone who has measured their corpus and disagrees.

Semantic and hybrid queries on an index that has semantic disabled, or on the Free plan, return a 400 that says so. Everything else about the call (filters, facets, scoped tokens, metering) works exactly as in keyword mode.

## Events

Every search returns a `queryId`. When the person clicks a result, adds it to a cart or buys it, send an event with that queryId. Events are what learned ranking, personal taste, categories and experiments learn from, and what engagement analytics count.

POST`/search/events`search:query

Send clicks, conversions and views

Up to 1,000 events per call, from the browser with a search only key or from your server. Valid events are kept and invalid ones are named with their reason in the response; one bad event never sinks the batch.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| events[].type* | string | click, conversion or view. |
| events[].name* | string | Your label, 1 to 64 characters, like "Product clicked" or "Purchased". |
| events[].index* | string | The index the records live in. |
| events[].userToken* | string | Your pseudonymous id for the person: 1 to 129 of letters, digits and _ = / + . and -. Never an email or a phone number. |
| events[].authUserToken | string | The same person once signed in, so an anonymous session joins their profile. |
| events[].queryId | string | The queryId the search returned. Required for clicks to teach learned ranking and to count in experiments. |
| events[].objectIds* | string[] | 1 to 50 record ids. |
| events[].positions | number[] | Optional. With a queryId the positions are read from the search itself, which is more reliable than anything a page can send. |
| events[].kind | string | For conversions: addToCart or purchase. |
| events[].value | number | Revenue for a purchase, with currency (NGN, USD). |
| events[].timestamp | number | Milliseconds since 1970, up to 30 days old. Defaults to when it arrived. |

Request

```
curl -X POST https://api.dbrij.com/api/search/events \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "events": [
  { "type": "click", "name": "Product clicked", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"] },
  { "type": "conversion", "name": "Purchased", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"], "kind": "purchase", "value": 45000, "currency": "NGN" }
] }'
```

Request body

```
{ "events": [
  { "type": "click", "name": "Product clicked", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"] },
  { "type": "conversion", "name": "Purchased", "index": "products", "userToken": "anon-71c2", "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77", "objectIds": ["sku-1"], "kind": "purchase", "value": 45000, "currency": "NGN" }
] }
```

Response 200

```
{
  "success": true,
  "data": { "accepted": 2, "rejected": [] }
}
```

**Checked against the search.** A click that names a queryId must be a record that search actually showed, or it is refused with that reason, so a buggy page cannot quietly train your ranking wrong. Its position is taken from the search itself.

**Private by design.** The userToken is yours to choose and should never be a name, an email or a phone number. Each person counts once per record per search when ranking is learned, so one enthusiast cannot move a result. Erase a person at any time with the profile endpoint under Learned ranking and taste.

GET`/search/events/health?days=7`search:manage

Events health

Events accepted and refused, the reasons for refusals, and the share of clicks that came with a queryId. A broken integration shows here before it shows anywhere else.

Request

```
curl -X GET https://api.dbrij.com/api/search/events/health?days=7 \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": { "accepted": 18240, "rejected": 12, "attributedClickShare": 0.97, "reasons": [{ "reason": "a clicked record was not in that search’s results.", "count": 12 }], "lastEventAt": "2026-10-27T09:14:02Z" }
}
```

GET`/search/analytics/engagement?days=30`search:manage

Engagement analytics

Click through and conversion rates, the average click position, revenue from search, the most clicked searches, and the searches that found results nobody clicked.

Request

```
curl -X GET https://api.dbrij.com/api/search/analytics/engagement?days=30 \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": { "trackedSearches": 48210, "clickThroughRate": 0.41, "conversionRate": 0.06, "averageClickPosition": 2.3, "noClickQueries": [{ "query": "charger", "searches": 310, "withResults": 310 }], "topClickedQueries": [{ "query": "running shoes", "searches": 1204, "clicks": 802, "ctr": 0.58 }], "revenue": [{ "currency": "NGN", "total": 9120000 }] }
}
```

## Learned ranking and taste

Four settings on an index, each read when a search runs, so turning one on or changing it never rebuilds anything. All of them learn from events and update every hour.

**reRanking** `{ enabled, windowDays: 7 | 30 | 90, maxMove: 1 to 20, promoteFilter }`. Results people choose for a search rise and ones nobody picks sink. A click at position 8 counts for more than a click at position 1 (people click the top because it is the top), small numbers are smoothed toward the search’s average instead of trusted, nothing moves more than maxMove places, and only records matching promoteFilter may rise. It only reorders what the search found; it never adds a record. Ask with `explain: true` to see from and to on each hit that moved.

**personalization** `{ enabled, facets: [{ field, weight }], impact: 0 to 100, eventWeights }`. Each person’s results lean toward the values they choose (a brand, a category), from the events carrying their userToken. At impact 100 a favourite gets at most double the score. Taste halves every 30 days without new events and is forgotten after 90.

**categorization** `{ enabled, field, boostAt, filterAt }`. What a search is about, learned from the category of what people chose after it, returned as `categories` with a confidence. At boostAt the category is boosted; at filterAt (optional) results are filtered to it. A person’s own filter on that field always wins. A search needs choices spread over at least three records before it is categorized.

**suggestions** `{ banned, minSearches, minLetters }` keep junk out of query suggestions.

POST`/search/indexes/:name/query-suggestions`search:query

Query suggestions

What people actually search that finds results, matching what is being typed: "run" offers "running shoes" because people search it. Rebuilt every hour from the last 30 days. Not metered as a search.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| q | string | What has been typed. Empty returns the most searched. |
| limit | number | 1 to 20, defaults to 8. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/query-suggestions \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "run" }'
```

Request body

```
{ "q": "run" }
```

Response 200

```
{
  "success": true,
  "data": [{ "query": "running shoes", "searches": 1204 }, { "query": "running watch", "searches": 233 }]
}
```

GET`/search/indexes/:name/profiles/:userToken`search:manage

What personalization learned about a person

Their affinity for each value of each learned field, 0 to 1.

Request

```
curl -X GET https://api.dbrij.com/api/search/indexes/:name/profiles/:userToken \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": { "userToken": "anon-71c2", "lastEventAt": "2026-10-27T09:14:02Z", "affinities": { "brand": { "Nomad": 1, "Trek": 0.42 } } }
}
```

DELETE`/search/profiles/:userToken`search:manage

Erase a person

Their taste is deleted and their events and searches stop carrying their token, across every index. Use it when someone asks to be forgotten.

Request

```
curl -X DELETE https://api.dbrij.com/api/search/profiles/:userToken \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": { "erased": true }
}
```

## Merchandising rules

Rules steer what a search shows: pin a record to a place, hide one, boost or bury by a filter, add a filter, rewrite the query, or hand your app a banner. Every response names the rules that fired in `appliedRules`, so nothing about the order is a mystery.

**Which rule wins.** The most precise: an exact match beats starts with, which beats ends with, which beats contains; then the longer pattern; then a rule aimed at a context; then the most recently changed. Only the best rewrite is used. Two pins asking for one place: the better rule keeps it and the other moves down. A record both pinned and hidden is hidden. Pins respect the search’s filters, including a scoped key’s.

The order of work in a search: rules (rewrite, filters, boosts, hides), then learned ranking, then pins.

POST`/search/indexes/:name/rules`search:manage

Add a rule

A rule names the searches it is for and what to do for them. Any one condition matching is enough. Takes effect within 30 seconds.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| name* | string | Up to 120 characters. |
| conditions* | object[] | 1 to 25 of { pattern, anchoring: is \| startsWith \| endsWith \| contains, context? }. Matching is on whole words, case and accents folded. An empty pattern with is matches browsing (no query). |
| consequence.pins | object[] | Up to 50 of { id, position }: the record at that place, counting from 1 across pages. |
| consequence.hide | string[] | Up to 200 record ids never shown for these searches. |
| consequence.boosts | object[] | Up to 10 of { filter, strength: slight \| moderate \| strong, direction: boost \| bury }. Boosts multiply relevance, so a strong boost on a poor match still loses to an excellent one. |
| consequence.filter | string | A filter added to these searches, ANDed with the caller’s. |
| consequence.replaceQuery | string | Search for this instead. |
| consequence.removeWords | string[] | Words dropped from the query ("cheap" in "cheap phones"). |
| consequence.userData | object | Up to 8 KB returned in userData for your app to draw: a banner, a redirect, a message. |
| validFrom, validUntil | string | ISO times. Outside its window a rule does nothing, so promotions end on their own. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/rules \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Shoe week", "conditions": [{ "pattern": "shoes", "anchoring": "contains" }], "consequence": { "pins": [{ "id": "sku-9", "position": 1 }], "boosts": [{ "filter": "brand = \"Nomad\"", "strength": "moderate", "direction": "boost" }], "userData": { "banner": "Shoe week: 20% off" } }, "validUntil": "2026-11-30T23:59:59Z" }'
```

Request body

```
{ "name": "Shoe week", "conditions": [{ "pattern": "shoes", "anchoring": "contains" }], "consequence": { "pins": [{ "id": "sku-9", "position": 1 }], "boosts": [{ "filter": "brand = \"Nomad\"", "strength": "moderate", "direction": "boost" }], "userData": { "banner": "Shoe week: 20% off" } }, "validUntil": "2026-11-30T23:59:59Z" }
```

Response 200

```
{
  "success": true,
  "data": { "id": "r-1", "index": "products", "name": "Shoe week", "enabled": true, "conditions": [ … ], "consequence": { … } }
}
```

GET`/search/indexes/:name/rules`search:manage

List rules

Every rule on the index, newest change first.

Request

```
curl -X GET https://api.dbrij.com/api/search/indexes/:name/rules \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": [{ "id": "r-1", "name": "Shoe week", "enabled": true }]
}
```

PUT`/search/indexes/:name/rules/:ruleId`search:manage

Change a rule

The whole rule, as on create.

Request

```
curl -X PUT https://api.dbrij.com/api/search/indexes/:name/rules/:ruleId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

DELETE`/search/indexes/:name/rules/:ruleId`search:manage

Remove a rule

Gone within 30 seconds.

Request

```
curl -X DELETE https://api.dbrij.com/api/search/indexes/:name/rules/:ruleId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

## A/B experiments

Try a change on part of your real traffic before everyone gets it. Each search that carries a userToken stays in one variant for that person; searches without one are assigned one by one. The response says which variant ran in `abTest`, and the clicks and conversions sent with its queryId decide the result.

Two answers are given side by side: the probability a variant is really better (Bayesian, which is what a person deciding wants to know) and a two sided p value (for anyone who has to report one), plus the searches each variant still needs. Stop early on a strong verdict if you like; stopping on a weak one is how experiments lie.

POST`/search/ab-tests`search:manage

Start an experiment

Two to four variants share an index’s traffic. A variant changes only what is read at search time, so nothing is copied or rebuilt and there is no extra cost per variant. One experiment runs per index at a time.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| name, index* | string | What it is called and where it runs. |
| metric* | string | clickThroughRate, conversionRate, noResultRate or revenuePerSearch. |
| variants* | object[] | { name, trafficPercent, overrides }. Shares add up to 100. overrides may set searchableFields, customRanking, rankingChain, typoTolerance, matchFields, matchingStrategy, mode, and turn reRanking, personalization, rules or categorization on or off. |
| days* | number | 1 to 90. It finishes on its own. |
| minimumEffect | number | The smallest relative change worth detecting (0.05 is 5 percent), for the sample size. |

Request

```
curl -X POST https://api.dbrij.com/api/search/ab-tests \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Learned ranking on", "index": "products", "metric": "clickThroughRate", "days": 14, "variants": [{ "name": "A", "trafficPercent": 50, "overrides": {} }, { "name": "B", "trafficPercent": 50, "overrides": { "reRanking": true } }] }'
```

Request body

```
{ "name": "Learned ranking on", "index": "products", "metric": "clickThroughRate", "days": 14, "variants": [{ "name": "A", "trafficPercent": 50, "overrides": {} }, { "name": "B", "trafficPercent": 50, "overrides": { "reRanking": true } }] }
```

GET`/search/ab-tests`search:manage

Experiments and their results

Per variant: searches, click through, conversion, empty searches and revenue per search, then against the first variant the probability of being really better, a two sided p value and the change. samplesNeeded says how many searches each variant needs before the result can be trusted, and verdict says it in a sentence.

Request

```
curl -X GET https://api.dbrij.com/api/search/ab-tests \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": [{ "name": "Learned ranking on", "status": "running", "results": [{ "name": "A", "searches": 9120, "clickThroughRate": 0.40 }, { "name": "B", "searches": 9184, "clickThroughRate": 0.44, "probabilityBetter": 0.99, "pValue": 0.0003, "lift": 0.10 }], "samplesNeeded": 8900, "verdict": "B beats A on click through rate (up 10%), with a 99% chance of being really better." }]
}
```

POST`/search/ab-tests/:testId/stop`search:manage

Stop an experiment

Traffic goes back to the index as set up; the results stay.

Request

```
curl -X POST https://api.dbrij.com/api/search/ab-tests/:testId/stop \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

## Website crawler

Point a crawler at a site and it keeps an index of its pages, daily, weekly or when you run it. It identifies as DbrijSearchBot, obeys robots.txt (a group naming it wins over *), reads at most two pages at a time with a pause between them, and skips pages marked noindex.

**Safe recrawls.** Pages the last crawl made that this one no longer finds are removed, unless that would remove more than a tenth of them: a site that is down, or a robots.txt that started refusing everything, must not empty your index. That crawl stops and says so instead.

Pages are read as your server sends them, without running JavaScript. A page that only appears after scripts run should be pushed with the documents API instead. Each page is one record and counts toward your plan.

POST`/search/crawlers`search:manage

Add a website crawler

Turns a website into records: one per page with id, url, title, description, headings, content, lang and crawledAt. Make title, description, headings and content searchable on the index.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| name, index* | string | What it is called and the index it fills. |
| startUrls* | string[] | Up to 20 pages links are followed from. Without include patterns the crawl stays on these sites. |
| sitemaps | string[] | Sitemaps or sitemap indexes. Sitemaps named in robots.txt are read anyway. |
| include, exclude | string[] | URL patterns where * is any run of characters. |
| maxUrls | number | Pages read per crawl, up to 50,000. Defaults to 1,000. |
| schedule | string | manual, daily or weekly. |

Request

```
curl -X POST https://api.dbrij.com/api/search/crawlers \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Docs", "index": "docs", "startUrls": ["https://docs.example.com"], "exclude": ["*/archive/*"], "schedule": "daily" }'
```

Request body

```
{ "name": "Docs", "index": "docs", "startUrls": ["https://docs.example.com"], "exclude": ["*/archive/*"], "schedule": "daily" }
```

POST`/search/crawlers/:crawlerId/run`search:manage

Crawl now

Starts in the background; the crawler’s status and lastRun follow it.

Request

```
curl -X POST https://api.dbrij.com/api/search/crawlers/:crawlerId/run \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

GET`/search/crawlers`search:manage

List crawlers

Each crawler with its last run: pages read, records written and removed, failures, and any error in words.

Request

```
curl -X GET https://api.dbrij.com/api/search/crawlers \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

GET`/search/crawlers/:crawlerId/runs`search:manage

Recent crawls

The last 20 runs.

Request

```
curl -X GET https://api.dbrij.com/api/search/crawlers/:crawlerId/runs \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

PUT`/search/crawlers/:crawlerId`search:manage

Change a crawler

As on create.

Request

```
curl -X PUT https://api.dbrij.com/api/search/crawlers/:crawlerId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

DELETE`/search/crawlers/:crawlerId`search:manage

Remove a crawler

The pages it made stay in the index until you remove them.

Request

```
curl -X DELETE https://api.dbrij.com/api/search/crawlers/:crawlerId \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

## Answers and plain language

Two ways the index reads people the way they write, both off until you turn them on under the index’s Learning tab (or with the `answers` and `understanding` settings), because both spend the account’s AI credits.

**answers** `{ enabled, fields, instructions, quality: fast | best, maxSources: 1 to 10 }`. fields are what is read from each record (empty reads them all); instructions are your words on how to answer.

POST`/search/indexes/:name/answer`search:query

Answer a question

An ordinary search first (rules, learned ranking, your filter and a scoped key’s all apply, and it counts as a search), then the best records go to the model as numbered sources. Every sentence of the answer cites the records it rests on, and when they do not answer the question you get answered: false instead of an invented answer. A repeat within ten minutes is served from the cache at no AI cost. Spends the account’s AI credits, so it is off until answers are turned on for the index.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| q* | string | The question, up to 500 characters. |
| filter | string | Only answer from records matching this. |
| userToken | string | As on a search: personalizes which records are read. |
| ruleContexts | string[] | As on a search. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/answer \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "q": "How long do I have to return something?" }'
```

Request body

```
{ "q": "How long do I have to return something?" }
```

Response 200

```
{
  "success": true,
  "data": {
    "answer": "You have 30 days from delivery to return an item, unworn and with its tags [1]. Refunds land within 5 working days of the return arriving [2].",
    "answered": true,
    "sources": [{ "id": "help-returns", "ref": 1, "cited": true, "document": { "title": "Returns" } }, { "id": "help-refunds", "ref": 2, "cited": true, "document": { "title": "Refunds" } }],
    "queryId": "9b2f7c1e-4d3a-4f7e-9a51-2c8e0b6d1a77",
    "cached": false,
    "tookMs": 1840
  }
}
```

**understanding** `{ enabled }`. Send `understand: true` on a search with three words or more, and it is read into words, a filter and a sort against the index’s own fields and a sample of their values. Everything read is checked before it is used: a filter that does not parse or names a field the index does not filter on is dropped, and so is a sort on a field that is not sortable. The read filter is ANDed onto yours, so it can only narrow, and your own sort always wins. The response says what was read in `understood`. Each new phrasing costs one model call and is remembered for a day; if anything goes wrong the search runs as typed.

## Recommendations

From the same records and the same events as search, with nothing to set up. Related works from day one; the others fill in as events arrive and are recomputed every hour.

- **related**: records like this one, by meaning when the index has semantic search on (the record’s nearest neighbours), else by its words.
- **boughtTogether**: records that at least two different people converted on together, within a day of each other, over the last 90 days.
- **trending**: records at least three people chose this week, ranked by how much faster than last week, weighed by how many.
- **trendingValues**: the same, rolled up by a field’s values (the rising brands or categories).

POST`/search/indexes/:name/recommend`search:query

Recommendations

Counted as one search. Never returns the record asked about, and honours your filter and a scoped key’s.

PATH / BODY PARAMETERS

|  |  |  |
| --- | --- | --- |
| model* | string | related, boughtTogether, trending or trendingValues. |
| objectId | string | For related and boughtTogether: the record to recommend from. |
| facet | string | For trendingValues: a filterable field, like brand. |
| filter | string | Only recommend records matching this, like inStock = true. |
| limit | number | 1 to 30, defaults to 10. |

Request

```
curl -X POST https://api.dbrij.com/api/search/indexes/:name/recommend \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "model": "boughtTogether", "objectId": "sku-1", "filter": "inStock = true" }'
```

Request body

```
{ "model": "boughtTogether", "objectId": "sku-1", "filter": "inStock = true" }
```

Response 200

```
{
  "success": true,
  "data": { "model": "boughtTogether", "hits": [{ "id": "sku-7", "document": { "title": "Running socks" }, "score": 14 }] }
}
```

## Analytics API

Every query is logged with whether it found anything. The "found nothing" list is the single most actionable thing in search: each row is somebody who wanted something, did not get it, and might have left over it.

GET`/search/analytics?days=30`search:manage

Query analytics

The same numbers the dashboard shows, as an endpoint, so the improvement loop can be automated: alert when noResultSearches jumps, or feed missedQueries into synonym generation.

Request

```
curl -X GET https://api.dbrij.com/api/search/analytics?days=30 \
  -H "Authorization: Bearer $DBRIJ_API_KEY"
```

Response 200

```
{
  "success": true,
  "data": {
    "totalSearches": 48210,
    "noResultSearches": 1861,
    "avgTookMs": 6,
    "topQueries": [{ "query": "running shoes", "count": 1204, "noResultRate": 0, "avgTookMs": 4 }],
    "missedQueries": [{ "query": "panadol", "count": 320, "noResultRate": 1, "avgTookMs": 3 }]
  }
}
```

This endpoint needs `search:manage`, so call it with an admin key from your server. A search only key gets a 403 here by design: analytics describe everything your users search for, which is not something a key that ships in a browser should be able to read.

Retention follows your plan (7 to 90 days). Suggestions are not logged: five keystrokes are one intent, and logging each would drown the signal this list exists for.

**Keep your own tests out of it.** Send `"analytics": false` on smoke tests, health checks and anything you run from a script. A diagnostic is not a person looking for something, and the "found nothing" list is only worth reading while every row on it is real. The query is still billed either way: the engine still did the work.

## Plans, errors and limits

Documents are a hard ceiling and searches are metered. A document costs memory for as long as it exists, while a query costs milliseconds, so going past your document limit is refused rather than billed, and searches past the allowance bill per thousand at renewal. The free plan has no overage at all: it stops serving instead of running up a bill you did not agree to.

**Typing is one search.** Search as you type sends a request on every keystroke; you are billed for the search, not the keystrokes. Requests from the same person on the same index within 30 seconds of each other, whose words extend, shorten or repeat the last ones, count as the one search they started, along with its pages and refinements. Suggestions count the same way. A session lasts at most two minutes and 50 requests. The person is your userToken when you send one, else the browser, so send a userToken from a server that searches on behalf of many people.

| Plan | Documents included | Up to | Each extra 1,000 a month | Searches a month | Indexes | Requests a minute | Semantic |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Free | 10,000 | 10,000 | Hard ceiling | 3,000 | 2 | 60 | No |
| Starter | 100,000 | 300,000 | ₦130 | 500,000 | 5 | 600 | Yes |
| Growth | 300,000 | 1,000,000 | ₦130 | 5,000,000 | 20 | 1,200 | Yes |
| Scale | 900,000 | 10,000,000 | ₦130 | 50,000,000 | 100 | 3,000 | Yes |

A document counts once for every 2 KB it takes in the index, so a long article can count as several. A typical product or listing counts as one.

ERRORS WORTH HANDLING

|  |  |  |
| --- | --- | --- |
| 207 | partial write | Some documents were indexed and some were not. `indexed` and `failed` say how many, and `errors` says which and why. A write is the one place where "the request worked" and "your data is in" are different claims. |
| 400 | bad request | A filter naming a field the index did not declare filterable, a sort on an unsortable field, or a malformed expression. The message says which field. Also a semantic or hybrid query on the Free plan or on an index with semantic disabled. |
| 401 | unauthorized | Missing, revoked or wrong key. |
| 403 | forbidden | The key is real but lacks the scope, e.g. a search only key trying to write documents. |
| 402 | plan limit | The workspace holds more documents or indexes than its plan allows and the week of grace has passed. The message names what is over and the cheapest plan that holds it. Move up, or remove what is over; adding more was already refused. Code plan_limit_exceeded. |
| 404 | not found | No index by that name in this workspace. |
| 422 | nothing indexed | The request was valid but not one document was accepted. `errors` carries the engine's reason per document, including what caused it. Treat this as a failed sync, never a successful one. |
| 429 | rate limited | Past your plan's requests a minute, or on Free, past the day's 100 searches (code daily_search_cap, with resetsAt). `Retry-After` says how long to wait, and every response carries `X-RateLimit-Remaining`. |
| 503 | unavailable | The search engine is not reachable. Existing data is fine; retry shortly. |

**Bulk ingestion.** Up to 1,000 documents per call; chunk bigger loads and run chunks in sequence. Writes count against your requests a minute like everything else, and the response tells you per document what was indexed and what failed, so a crashed import is safe to re-run from the top.

**A plan is what you hold, not only what you add.** The document and index ceilings apply to what the workspace is holding right now, not just to the next push. Move to a smaller plan while holding more than it allows and you get a week: everything keeps working, the owner gets an email, the billing page says what is over and which plan fits. After the week, queries answer `402` until the workspace is back inside its plan, either by moving up or by removing what is over. Free plans also cap searches at 100 a day.

**Never trust the status code alone on a write.** A write has three outcomes and each has its own code: `201` every document landed, `207` some did not, `422` none did. Read `indexed` and `failed` as well, and only record a sync as complete when `failed` is zero. A loop that checks `res.ok` and moves on will happily report a finished import over an empty index.

**Handling 429 in a search box.** Debounce by 100ms or so before searching on keystroke, and on a 429 keep the last good results on screen rather than clearing them. An empty list reads as "nothing found", which is a worse lie than a slightly stale one.
