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:

  1. Experiment key: a stable name, such as products-engine-test. Your code sends it on every call.
  2. Traffic %: the share of visitors in the test. The rest get no variant.
  3. Status: Active. Draft, Paused and Archived assign nobody.
  4. Variant 1 and Variant 2: a key (baseline, candidate), the index each one searches, and a weight. The weights must add up to 100.
  5. 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.

search key
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
200
{
  "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.

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.

eventTypeSend it when
experiment_viewThe visitor saw the results.
search_result_clickThe visitor opened a result.
conversionThe visitor bought, signed up or did whatever the test is for.
customAnything else you want to count.
search key
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:

LabelWhen
Needs dataFewer than two variants have 100 tagged searches each.
Rollback riskThe candidate is 50% slower, or its zero-result rate is 10 points higher.
Promote candidateThe candidate converts at least 10% better, with at least 5 conversions, and trips neither guardrail.
Keep runningAnything 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=false with not_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

POST/v1/experiments/assign

Apply 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.

Auth search key onlyReturns 200
Request body
{
  "experimentKey": "products-engine-test",
  "subjectId": "user_123",
  "sessionId": "sess_456"
}
Response
{
  "data": {
    "assigned": false,
    "reason": "outside_rollout",
    "experimentKey": "products-engine-test",
    "sessionId": "sess_456",
    "config": { "status": "active", "trafficPercent": 25, "killSwitch": false }
  }
}
POST/v1/experiments/events

Record 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.

Auth search key onlyReturns 202
Request body
{
  "eventType": "conversion",
  "experimentKey": "products-engine-test",
  "variant": "candidate",
  "indexUid": "products_typesense",
  "sessionId": "sess_456",
  "query": "trail shoes",
  "conversionValue": 1299,
  "metadata": { "orderId": "ord_123" }
}
Response
{
  "data": {
    "accepted": true,
    "eventType": "conversion",
    "experimentKey": "products-engine-test",
    "variant": "candidate",
    "indexUid": "products_typesense"
  }
}
GET/v1/experiments/config?experimentKey=products-engine-test

Read 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.

Auth search key onlyReturns 200
Response
{
  "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 }
        ]
      }
    ]
  }
}
POST/api/projects/:projectId/experiments

Save 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.

Auth dashboard sessionReturns 201
Request body
{
  "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 }
  ]
}