A/B Test Engines
Split your search traffic between two engines, or two settings, and let real visitors decide which one works better. Headband assigns each visitor a variant, keeps it the same for them, and compares the variants' searches, zero-result rate, latency and conversions in the dashboard.
A variant is an index. To test two engines, put the same data on both (see Index Into More Than One Engine). To test two settings, make two indexes on one engine with the same data and different settings, such as products and products_synonyms. The ab-test example runs the whole flow with simulated visitors.
1. Create the Rule in the Dashboard
Open Analytics, pick one project in the project menu (rules cannot be saved under All projects), then the Experiments tab. Under Managed experiment rules:
- Experiment key: a stable name, such as
products-engine-test. Your code sends it on every call. - Traffic %: the share of visitors in the test. The rest get no variant.
- Status: Active. Draft, Paused and Archived assign nobody.
- Variant 1 and Variant 2: a key (
baseline,candidate), the index each one searches, and a weight. The weights must add up to 100. - Save rules.
The latest saved rule for a key is the one in force. Name the control variant baseline (or control): the dashboard compares the other variant against it.
2. Give Each Visitor a Variant
Call POST /v1/experiments/assign with the experiment key and a subjectId that stays the same for a visitor: your user id, or an anonymous id you keep in a cookie. The same subject always gets the same variant, even in a new session. A search key is enough, so this can run in the browser.
const res = await fetch("https://headband.dev/v1/experiments/assign", {
method: "POST",
headers: { Authorization: `Bearer ${SEARCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
experimentKey: "products-engine-test",
subjectId: "user_123",
sessionId: "sess_456",
}),
});
const { data } = await res.json(); // experiment answers are wrapped in "data"
const index = data.assigned ? data.indexUid : "products_meili"; // not in the test: baseline{
"data": {
"assigned": true,
"experimentKey": "products-engine-test",
"variant": "candidate",
"indexUid": "products_typesense",
"sessionId": "sess_456",
"config": { "status": "active", "trafficPercent": 100, "killSwitch": false, "updatedAt": "..." }
}
}When assigned is false, reason says why: outside_rollout (the visitor is not in Traffic %), not_runnable (the rule is not active, or its kill switch is on) or no_weight. Serve your baseline and do not tag the search. An experiment key with no saved rule answers 404 experiment_config_not_found. A project key finds the rule in its own project; an org key must also send projectId or indexUid.
In our run, 40 visitors against a rule with Traffic % 30 and two variants at 50: 11 were assigned (5 baseline, 6 candidate), and each got the same variant when asked again with a new session.
3. Search the Variant's Index
Search the index the assignment named, and add experimentKey, variant and sessionId so Headband counts the search for that variant. Send both experimentKey and variant, or neither. POST /v1/search and POST /v1/ai/search take them; multi-search and hybrid search do not.
await fetch("https://headband.dev/v1/search", {
method: "POST",
headers: { Authorization: `Bearer ${SEARCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
index: data.indexUid,
q: "running shoes",
experimentKey: data.experimentKey,
variant: data.variant,
sessionId: data.sessionId,
}),
});4. Send Views, Clicks and Conversions
Report what the visitor did with POST /v1/experiments/events. The key needs analytics.write, which every search key has.
| eventType | Send it when |
|---|---|
experiment_view | The visitor saw the results. |
search_result_click | The visitor opened a result. |
conversion | The visitor bought, signed up or did whatever the test is for. |
custom | Anything else you want to count. |
async function event(eventType, extra = {}) {
await fetch("https://headband.dev/v1/experiments/events", {
method: "POST",
headers: { Authorization: `Bearer ${SEARCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
eventType,
experimentKey: data.experimentKey,
variant: data.variant,
indexUid: data.indexUid,
sessionId: data.sessionId,
...extra,
}),
});
}
await event("experiment_view", { query: "running shoes" });
await event("search_result_click", { query: "running shoes", metadata: { documentId: "42" } });
await event("conversion", { conversionValue: 12900, metadata: { orderId: "ord_789" } });experimentKey, variant and indexUid are required, and the index must exist. conversionValue is a whole number, such as cents, and metadata an object of at most 4 KB. An accepted event answers 202. Events do not check that a rule exists, so a key with a typo is recorded as its own experiment.
5. Read the Results
In Analytics, with the project picked, the Experiments tab lists each experiment by variant: searches, events, zero results, average hits, average latency, conversions and conversions per search. Above the table, a recommendation compares the candidate with the baseline:
| Label | When |
|---|---|
| Needs data | Fewer than two variants have 100 tagged searches each. |
| Rollback risk | The candidate is 50% slower, or its zero-result rate is 10 points higher. |
| Promote candidate | The candidate converts at least 10% better, with at least 5 conversions, and trips neither guardrail. |
| Keep running | Anything else. |
These are advisory promote/rollback recommendations: Headband never changes the rule or moves traffic by itself. The tab reads the most recent 1,000 analytics events in the period you pick, searches included, so on a busy project it covers only that window. Keep your product analytics as the record for revenue.
6. Stop or Finish
- To stop at once, tick "Enable kill switch flag for this experiment" or set Status to Paused, and save. Assignment then answers
assigned=falsewithnot_runnable, and your code serves the baseline. - To ramp up, raise Traffic % and save. Visitors already in the test keep their variant.
- To finish, point your app at the winning index, or switch the baseline index to the winning engine, then archive the rule.
Assigning in Your Own Code
You can skip hosted assignment and split traffic yourself, for example with a hash of the visitor id. Tag searches and send events the same way, with your own variant names; the Experiments tab reports them whether or not a rule exists. To read the saved rule from your code instead, call GET /v1/experiments/config below.
@headband/react 0.2.0 adds assignExperiment for the hosted endpoint, getExperimentConfigs plus deterministic local assignment helpers, and recordExperimentEvent. It is on npm.
Patterns
Engine A/B/C test
Split traffic across Meilisearch, Typesense, Elasticsearch, and Visor indexes for the same corpus.
Worth watching: conversion, zero_result_rate, p95_latency_ms, reformulation_rate
Ranking rules test
Compare synonyms, typo tolerance, filters, and boosts without changing client code.
Worth watching: click_through_rate, add_to_cart_rate, search_exit_rate
Hybrid/RAG quality test
Compare lexical, vector, and hybrid retrieval before rolling a RAG flow to everyone.
Worth watching: answer_acceptance, grounding_failure, latency_ms
The Experiments tab reports searches, zero results, hits, latency and conversions. Track anything else, such as answer acceptance, as a custom event or in your own analytics.
API Reference
/v1/experiments/assignApply the latest saved rule for experimentKey to a stable subject and return the variant and its index.
Project-scoped keys infer the project. Org-scoped keys must provide projectId or indexUid. assigned=false is returned when a rule is paused, killed, outside rollout, or has no usable variant weight. 404 experiment_config_not_found when no rule is saved under the key.
{
"experimentKey": "products-engine-test",
"subjectId": "user_123",
"sessionId": "sess_456"
}{
"data": {
"assigned": false,
"reason": "outside_rollout",
"experimentKey": "products-engine-test",
"sessionId": "sess_456",
"config": { "status": "active", "trafficPercent": 25, "killSwitch": false }
}
}/v1/experiments/eventsRecord a view, click, conversion or custom event for one variant, under the project of its index.
Needs analytics.write (every search key has it). 400 for a malformed field, 403 for a key without analytics.write, 404 for an index that does not exist.
{
"eventType": "conversion",
"experimentKey": "products-engine-test",
"variant": "candidate",
"indexUid": "products_typesense",
"sessionId": "sess_456",
"query": "trail shoes",
"conversionValue": 1299,
"metadata": { "orderId": "ord_123" }
}{
"data": {
"accepted": true,
"eventType": "conversion",
"experimentKey": "products-engine-test",
"variant": "candidate",
"indexUid": "products_typesense"
}
}/v1/experiments/config?experimentKey=products-engine-testRead the saved rules, so your own code can apply weights and the kill switch itself.
Project-scoped keys infer the project. Org-scoped keys must provide projectId or indexUid.
{
"data": {
"experiments": [
{
"experimentKey": "products-engine-test",
"status": "active",
"trafficPercent": 100,
"killSwitch": false,
"variants": [
{ "key": "baseline", "indexUid": "products_meili", "weight": 50 },
{ "key": "candidate", "indexUid": "products_typesense", "weight": 50 }
]
}
]
}
}/api/projects/:projectId/experimentsSave a rule, as the dashboard's Save rules does. Signed-in dashboard session only, not an API key. The latest rule saved for an experimentKey is the one in force.
Variant weights must add to 100, and trafficPercent is 0 to 100.
{
"experimentKey": "products-engine-test",
"name": "Products engine test",
"status": "active",
"trafficPercent": 100,
"killSwitch": false,
"primaryMetric": "conversion_rate",
"guardrailMetric": "p95_latency_ms",
"variants": [
{ "key": "baseline", "indexUid": "products_meili", "weight": 50 },
{ "key": "candidate", "indexUid": "products_typesense", "weight": 50 }
]
}