Dbrij Search Documentation

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

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 URLhttps://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 onlysearch:queryReads indexes. Cannot write a document, change relevance or delete anything. Ship it in your JavaScript, your mobile app, anywhere your users can read it.
adminsearch:write, search:manageWrites 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.

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/indexessearch: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*string2 to 64 characters: lowercase letters, numbers, dashes and underscores. This is what every path uses.
settingsobjectOptional 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/indexessearch: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/settingssearch: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/:namesearch: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/documentssearch: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.
primaryKeystringThe 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/updatesearch: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/deletesearch: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/clearsearch: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.

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

= !=anyExact match. Strings are compared whole, so `brand = "Nomad"` does not match "Nomad Sport".
> >= < <=numberRanges over numeric fields, e.g. `price >= 10000`.
IN [a, b]anyAny of the listed values: `category IN ["shoes", "boots"]`.
AND ORAND binds tighter than OR. Use brackets when you mean otherwise.
( )Grouping, e.g. `(brand = "Nomad" OR brand = "Ardent") AND price < 50000`.
EXISTS NOT EXISTSanyWhether 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 NULLanyThe same question in the other spelling. `zone IS NULL` is `zone NOT EXISTS`.
_geoRadius(field, lat, lng, m)geoEverything 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.

Relevance

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

SETTINGS

searchableFieldsstring[]What a query looks at, most important first. Empty searches every text field, which is the right start for most people.
filterableFieldsstring[]The only fields a filter may name.
sortableFieldsstring[]The only fields a sort may name.
retrievableFieldsstring[]What comes back in a hit. Empty returns the whole document.
typoTolerancebooleanForgive typos. On by default. Not available together with matchFields: "cross".
matchFieldsstringbest (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.
matchingStrategystringall (default) returns only documents holding every word; most keeps short queries strict and lets longer ones through on a strong majority.
splitLetterDigitbooleanSplit 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.
matchJoinedWordsbooleanAlso 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.
stopWordsstring[]Words ignored in a query. Leave empty unless you have a reason: stop words hurt phrase searches.
synonymsgroup[]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.
languagestringUsed for stemming, so "running" finds "run". Defaults to english.
geoFieldsstring[]Fields holding a location as { lat, lon }. Declared here so the engine stores them as points; changing this rebuilds the index.
customRankingstring[]Business signals as tiebreakers after text relevance, e.g. ["popularity:desc", "createdAt:desc"]. When two hits match equally well, these decide who wins.
geoRankingobjectBlend distance into the score rather than filtering or sorting by it. Needs aroundLatLng on the query, otherwise it simply does not apply.
rankingChainstring[]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.
facetNormalisationobjectFold the many spellings of one facet value into one ("XL", "xl", "X Large" count together). Changing it rebuilds the index.
facetAliasesobjectDisplay labels for folded facet values, read at query time with no rebuild.
semanticobjectMatch meaning as well as words: { enabled, model, fields, weight }. Turning it on or changing the model rebuilds the index. See Semantic search.
fusionstringHow 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

enabledbooleanOff by default. Turning it on embeds your documents and rebuilds the index.
modelstringvoyage-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 prefer OpenAI as the vendor).
fieldsstring[]Fields whose text is embedded. Empty means the searchable fields, which is almost always what you want.
weightnumberHow 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.

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=30search: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.

PlanDocuments includedUp toEach extra 1,000 a monthSearches a monthIndexesRequests a minuteSemantic
Free10,00010,000Hard ceiling3,000260No
Starter100,000300,000₦130500,0005600Yes
Growth300,0001,000,000₦1305,000,000201,200Yes
Scale900,00010,000,000₦13050,000,0001003,000Yes

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

207partial writeSome 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.
400bad requestA 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.
401unauthorizedMissing, revoked or wrong key.
403forbiddenThe key is real but lacks the scope, e.g. a search only key trying to write documents.
402plan limitThe 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.
404not foundNo index by that name in this workspace.
422nothing indexedThe 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.
429rate limitedPast 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`.
503unavailableThe 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.

Dbrij is Nigeria’s company operating system: the first Nigerian built platform to put team chat, video meetings, email, HR, tax compliant payroll, customer support, marketing, analytics and cloud hosting in one product on one subscription.

Everything on Dbrij

More from Dbrij