> For a complete page index of the Captain API documentation, fetch https://docs.captain.dev/llms.txt?excludeSpec=true

# Search

> Full search, typeahead, browse, similar records and several searches in one request.

> **Note**
>
> Product Search is in beta with design partners. Endpoints roll out per tenant.

All search routes take a search key or a scoped key. Use the search key on your server. In a browser or an app, and on an index with per-record access control, use a scoped key. They return the same response shape: `hits`, `total` and `timing`, plus `facets` when you ask for them with `facet_keys`. Browse always counts the index's facets.

| Route                                | Use it for                                                               |
| ------------------------------------ | ------------------------------------------------------------------------ |
| `POST /v1/indexes/{index}/search`    | A search submitted with Enter or a button.                               |
| `POST /v1/indexes/{index}/typeahead` | Results while the user types, one request per keystroke.                 |
| `POST /v1/indexes/{index}/browse`    | Category and listing pages: filters, sort and facets with no query text. |
| `POST /v1/indexes/{index}/similar`   | "More like this" for one record.                                         |
| `POST /v1/multi_search`              | Up to 10 searches in one request, across indexes.                        |

## Search and typeahead

Search and typeahead take the same request body and return the same response shape. The route you call decides how much work Captain does, so there's no field to send for it.

* **Search** runs every ranking step. It matches both the words and what they mean, then a ranking model reorders the top 50 matches. Counting facets is on by default. It returns 10 results unless you set `k`.
* **Typeahead** skips the slower steps so it can answer every keystroke. It completes the word still being typed and fixes spelling, and it starts matching on meaning once the text is 3 characters long. It skips the reordering model, and it counts facets only when you ask with `facet_keys` or `"facets": true`. It returns 8 results unless you set `k`. Send `prev_top` with the id you showed first after the last keystroke, and that record stays first while it's still among the top 3, so the top suggestion doesn't jump around.

Use typeahead for the suggestions under a search box, and search when the shopper presses Enter or opens a results page. Every response says which one ran in `request.mode`: `full` for search, `instant` for typeahead.

Every search on an index weighs keyword and meaning matches the same way. A request can't change that balance.

## Typeahead

Send the text typed so far. Captain answers each keystroke with spelling correction and prefix matching.

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/indexes/products/typeahead" \
  -H "Authorization: Bearer $CAPTAIN_SEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "query": "waterpr",
  "k": 3
}'
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_search_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

response = requests.post(
    f"{BASE_URL}/v1/indexes/{INDEX}/typeahead",
    headers=headers,
    json={
        "query": "waterpr",
        "k": 3,
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_search_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/typeahead`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: "waterpr",
    k: 3,
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "hits": [
    {
      "id": "sku-10482",
      "title": "Moab 3 Mid Waterproof Hiking Boot",
      "brand": "Merrell",
      "category": "Hiking Boots",
      "price": 139.0,
      "rating": 4.6,
      "image_url": "https://cdn.acme.example/img/sku-10482.jpg",
      "score": 1.42
    },
    {
      "id": "sku-11020",
      "title": "Targhee III Waterproof Mid Boot",
      "brand": "KEEN",
      "category": "Hiking Boots",
      "price": 150.0,
      "rating": 4.4,
      "image_url": "https://cdn.acme.example/img/sku-11020.jpg",
      "score": 1.18
    },
    {
      "id": "sku-10517",
      "title": "X Ultra 4 Mid GTX Hiking Boot",
      "brand": "Salomon",
      "category": "Hiking Boots",
      "price": 165.0,
      "rating": 4.5,
      "image_url": "https://cdn.acme.example/img/sku-10517.jpg",
      "score": 0.97
    }
  ],
  "total": 57,
  "query_id": "e41b7c9a2d5f4a86b03e7d1c9f2a6b58",
  "query_understanding": {
    "completions": [
      "waterproof",
      "waterproofing"
    ],
    "partial": "waterpr"
  },
  "request": {
    "mode": "instant",
    "build": "b20260928201247"
  },
  "timing": {
    "engine_ms": 8.7
  }
}
```

Cancel the previous request when a new keystroke arrives, and draw only the reply to the latest one.

## Filters, sort and pages

`filter` takes an expression over the fields returned by [List Filters](/reference/product-search/indexes/list-filters), which also gives the operators each field accepts:

```
status IN ["open", "pending"] AND filed_at >= 2024-01-01 AND price <= 150
```

Operators: `=`, `!=`, `<`, `<=`, `>`, `>=`, `IN`, `NOT IN`, `BETWEEN`, `EXISTS(field)`, combined with `AND`, `OR`, `NOT` and parentheses.

An expression that names an unknown field, or uses an operator the field does not support, returns `400 FILTER_INVALID` with the position of the problem and what could come there.

* `sort`: order by one sortable field, `{"field": "price", "order": "asc"}`, instead of relevance. On a geo field, give a point: `{"field": "location", "near": {"lat": 40.71, "lng": -74.0}, "order": "asc"}` puts the nearest records first, and each hit carries `geo_distance_m`. A search sorts its 1,000 most relevant records, and a geo sort on browse orders the first 100 matches. Add a `WITHIN` filter to cover every match: the reply's `sort.exact` is `true` when the browse ordered all of them.
* `page` and `per_page`: page through results, from page `0`, up to `per_page` 50.
* `prefer`: lift records that match a condition without removing the others.
* `group_by`: one hit per value of a field, such as one product per variant family. `k` counts groups.

### How deep search goes

Search returns up to 1,000 results, with `k` or with pages. Every page of a query is a cut of one list, so page 2 never repeats or skips a record from page 1.

* **Plain search:** the first 100 results are the full ranking. Results 101 to 1,000 continue it with the rest of the matches, in a simpler keyword order.
* **`sort` and a geo sort:** the 1,000 most relevant records, sorted. Records without the field come last, in relevance order.
* **`constraints: "strict"`:** the 1,000 most relevant records, with records that fail a constraint removed.
* **`prefer`:** reorders the first 100 results. Results 101 to 1,000 stay in relevance order, so a preference never pulls a weak match to the top.
* **`group_by`:** groups come in the order of each group's best record. A grouped search reads the 100 most relevant records, and further down the 1,000 when it needs more to fill `k` groups. The reply's `grouped.depth` says how many it read.

A page that ends past result 1,000 returns `400 PAGE_TOO_DEEP`. To walk every match, for an export or a full catalog listing, use [Browse](#browse) with a filter, a sort, `k` and `page`.

## Browse

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/indexes/products/browse" \
  -H "Authorization: Bearer $CAPTAIN_SEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "filter": "category = \"Hiking Boots\"",
  "sort": [
    {
      "field": "price",
      "order": "asc"
    }
  ],
  "k": 3,
  "facet_keys": [
    "brand"
  ]
}'
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_search_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

response = requests.post(
    f"{BASE_URL}/v1/indexes/{INDEX}/browse",
    headers=headers,
    json={
        "filter": 'category = "Hiking Boots"',
        "sort": [{"field": "price", "order": "asc"}],
        "k": 3,
        "facet_keys": ["brand"],
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_search_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/browse`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    filter: 'category = "Hiking Boots"',
    sort: [{ field: "price", order: "asc" }],
    k: 3,
    facet_keys: ["brand"],
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "hits": [
    {
      "id": "sku-10482",
      "title": "Moab 3 Mid Waterproof Hiking Boot",
      "brand": "Merrell",
      "category": "Hiking Boots",
      "price": 139.0,
      "rating": 4.6,
      "image_url": "https://cdn.acme.example/img/sku-10482.jpg"
    },
    {
      "id": "sku-11020",
      "title": "Targhee III Waterproof Mid Boot",
      "brand": "KEEN",
      "category": "Hiking Boots",
      "price": 150.0,
      "rating": 4.4,
      "image_url": "https://cdn.acme.example/img/sku-11020.jpg"
    },
    {
      "id": "sku-10517",
      "title": "X Ultra 4 Mid GTX Hiking Boot",
      "brand": "Salomon",
      "category": "Hiking Boots",
      "price": 165.0,
      "rating": 4.5,
      "image_url": "https://cdn.acme.example/img/sku-10517.jpg"
    }
  ],
  "total": 42,
  "facets": {
    "brand": [
      {
        "value": "Merrell",
        "count": 14
      },
      {
        "value": "Salomon",
        "count": 11
      },
      {
        "value": "KEEN",
        "count": 9
      },
      {
        "value": "Columbia",
        "count": 8
      }
    ]
  },
  "facets_exact": true,
  "timing": {
    "engine_ms": 6.2
  }
}
```

## Similar records

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/indexes/products/similar" \
  -H "Authorization: Bearer $CAPTAIN_SEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "id": "sku-10482",
  "k": 2
}'
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_search_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

response = requests.post(
    f"{BASE_URL}/v1/indexes/{INDEX}/similar",
    headers=headers,
    json={
        "id": "sku-10482",
        "k": 2,
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_search_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/similar`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    id: "sku-10482",
    k: 2,
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "hits": [
    {
      "id": "sku-11020",
      "title": "Targhee III Waterproof Mid Boot",
      "brand": "KEEN",
      "category": "Hiking Boots",
      "price": 150.0,
      "rating": 4.4,
      "image_url": "https://cdn.acme.example/img/sku-11020.jpg",
      "score": 0.884
    },
    {
      "id": "sku-10517",
      "title": "X Ultra 4 Mid GTX Hiking Boot",
      "brand": "Salomon",
      "category": "Hiking Boots",
      "price": 165.0,
      "rating": 4.5,
      "image_url": "https://cdn.acme.example/img/sku-10517.jpg",
      "score": 0.861
    }
  ],
  "total": 2,
  "timing": {
    "engine_ms": 5.8
  }
}
```

## Multiple searches

Each entry names an index, a route (`search`, `typeahead` or `browse`, default `search`) and that route's body.

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/multi_search" \
  -H "Authorization: Bearer $CAPTAIN_SEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "searches": [
    {
      "index": "products",
      "query": "rain jacket",
      "k": 2
    },
    {
      "index": "articles",
      "query": "rain jacket care",
      "k": 1
    }
  ]
}'
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_search_key"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

response = requests.post(
    f"{BASE_URL}/v1/multi_search",
    headers=headers,
    json={
        "searches": [
            {"index": "products", "query": "rain jacket", "k": 2},
            {"index": "articles", "query": "rain jacket care", "k": 1},
        ],
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_search_key";

const response = await fetch(`${BASE_URL}/v1/multi_search`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    searches: [
      { index: "products", query: "rain jacket", k: 2 },
      { index: "articles", query: "rain jacket care", k: 1 },
    ],
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "results": [
    {
      "index": "products",
      "route": "search",
      "status": 200,
      "result": {
        "hits": [
          {
            "id": "sku-20311",
            "title": "Torrentshell 3L Rain Jacket",
            "brand": "Patagonia",
            "category": "Rain Jackets",
            "price": 179.0,
            "rating": 4.7,
            "image_url": "https://cdn.acme.example/img/sku-20311.jpg",
            "score": 0.931
          },
          {
            "id": "sku-20402",
            "title": "Resolve 2 Rain Jacket",
            "brand": "The North Face",
            "category": "Rain Jackets",
            "price": 110.0,
            "rating": 4.5,
            "image_url": "https://cdn.acme.example/img/sku-20402.jpg",
            "score": 0.902
          }
        ],
        "total": 18,
        "query_id": "5d9e2b7a1c4f4380b6a2e8f37c1d9a04"
      }
    },
    {
      "index": "articles",
      "route": "search",
      "status": 200,
      "result": {
        "hits": [
          {
            "id": "art-0147",
            "title": "How to wash and re-waterproof a rain jacket",
            "category": "Care guides",
            "score": 0.887
          }
        ],
        "total": 6,
        "query_id": "a0c3f6e91b2d47c58e4a7b96d3f21c08"
      }
    }
  ]
}
```

## Per-record access control

When records carry access lists, searches use scoped keys. A scoped key is minted for one end user and returns only the records that user may see. Suggestions and facet counts respect the same rule. Scores are never returned to a scoped key.

A write key can set a record's access list, including making it public. Check the access fields in the integration before going live.