# Full-Text Search for Qualitative Coding

Source: https://www.potatoannotator.com/docs/qda/search

Potato builds a full-text index over the text of every item when the server starts, and an admin can search that index in any project, with or without QDA Mode. A search for `copay` returns each interview excerpt or open-ended survey answer containing a word that starts with "copay". Results arrive ranked by relevance, with the matching words highlighted in a short snippet. The optional setting `search.annotator_claim` adds a Find panel to the coding page. In the Find panel, a coder searches the same index and claims matching items into the coder's own queue. Search and QDA Mode both arrived in Potato 2.6.0.

[FTS5](https://www.sqlite.org/fts5.html) is the full-text search extension of SQLite. SQLite is the embedded database behind Potato's project store. Potato keeps the index as a table in `<task_dir>/project.sqlite`, and the same file holds memos and the codebook. The index contains the words of the items. Codes, memos, and annotations are outside the index, and a search never applies a code to the passages the search finds.

Search serves two jobs in a qualitative project:

- **Finding passages to code.** A coder working through a corpus by topic searches for "bus" or "copay", claims the items that mention the word, and codes the relevant passages with a span scheme.
- **Checking a term across the corpus.** An analyst or adjudicator writing a code's inclusion criteria searches for a term to see which items use the term. No coder's queue changes.

## Configuration

Search needs no configuration. A project with no `search` block indexes every item at startup and serves admin search. The config below is a complete QDA Mode project that also turns on search-and-claim for the coder:

```yaml
annotation_task_name: Clinic access interviews
task_dir: .
output_annotation_dir: annotation_output/
data_files:
- data/interviews.json
item_properties:
  id_key: id
  text_key: text
admin_api_key: change-this-key

qda_mode:
  enabled: true

search:
  annotator_claim: true

annotation_schemes:
- annotation_type: span
  name: codes
  description: Highlight a passage and apply a code
  codebook: true
  labels: [access barriers, cost concerns, provider trust]
```

QDA Mode assumes one coder over the whole corpus. Under `qda_mode.enabled: true`, Potato accepts `annotator_claim` with any assignment design. The `admin_api_key` lets you call admin search from a script or the command line.

The `search` block takes four keys:

| Option | Default | Description |
|--------|---------|-------------|
| `search.enabled` | `true` | Build the index at startup and serve the search endpoints. With `false`, Potato builds no index, and admin search answers `503`. |
| `search.backend` | `fts5` | The index engine. `fts5` is the only backend in Potato 2.13.1. Any other value turns search off and logs a warning. |
| `search.max_instances` | `100000` | The largest number of items indexed. Potato indexes items in load order up to the cap. Items past the cap stay out of the index, and the server log reports the cap. |
| `search.annotator_claim` | `false` | Show the Find panel to coders and serve the coder search and claim endpoints. Potato refuses to start when the setting conflicts with the assignment design (see [the assignment guard](#the-assignment-guard)). |

## Who can search

Admin search and coder search read the same index through different endpoints:

| Who | Endpoint | Available when | Results per request |
|-----|----------|----------------|---------------------|
| Admins and adjudicators | `GET /admin/api/search?q=<query>&limit=<n>` | `search.enabled` is true | 50 by default, up to 500 |
| Any signed-in coder | `GET /api/search?q=<query>&limit=<n>`, and the Find panel | `search.annotator_claim: true` | 50 by default, up to 200; the Find panel asks for 50 |
| Any signed-in coder | `POST /api/search/claim` with `{"instance_id": "<id>"}` | `search.annotator_claim: true` | One item per request |

Admin access means the admin API key in an `X-API-Key` header, or a signed-in user whose role grants the admin dashboard. An adjudicator in a project with the `adjudication` block enabled can use admin search as well. Admin search is read-only and changes no queue. Potato 2.13.1 has no search box on the admin dashboard, and admins search through the endpoint:

```bash
curl -H "X-API-Key: change-this-key" \
  "http://localhost:8000/admin/api/search?q=copay&limit=20"
```

The endpoint returns JSON in this shape:

```json
{
  "count": 1,
  "query": "copay",
  "results": [
    {
      "id": "P01-03",
      "instance_id": "P01-03",
      "score": -0.525,
      "snippet": "My \u0002copay\u0003 went up again this year, so I skipped the follow…"
    }
  ]
}
```

The snippet runs up to 12 words around the best-matching part of the item and marks each matched word with the control characters U+0002 and U+0003. The Find panel turns the marks into highlights. `score` is the FTS5 [BM25](https://en.wikipedia.org/wiki/Okapi_BM25) rank. A lower score is a better match, and results arrive best match first. `count` is the number of results returned. `limit` caps the count, and the response does not report how many items match beyond the cap.

## Query syntax

Potato splits a query at every character that is not a letter, a digit, or an underscore, and returns the items that contain every resulting word as a prefix. Potato never passes the query to FTS5 as query syntax. The rules below follow from that one step:

- **Every word must appear.** `bus line` matches an item containing both "bus" and "line", in either order and at any distance apart.
- **Each word matches as a prefix.** `copay` matches "copay", "copays", and "copayment". `care` matches "career" and "careful" along with "care".
- **Case and accents are ignored.** `Copay` and `copay` return the same items, and `cafe` matches "café".
- **Punctuation splits words.** `self-care` searches for "self" and "care". `cost-of-care` returns any item containing "cost", "of", and "care" anywhere in the text.
- **Apostrophes split words.** `couldn't` searches for "couldn" and "t". The item containing "couldn't" is found, and the snippet also highlights other words beginning with "t".
- **Operators are ordinary words.** Quotation marks and `*` are dropped, and `OR`, `NOT`, and `NEAR` become words the item must contain. A query such as `cost OR care` usually returns nothing.
- **Word forms are not merged.** `run` matches "running" through the prefix rule, and `ran` never matches "run". Search separately for each form a participant might use.

## Indexed text and index rebuilds

Potato indexes one string per item, chosen in this order:

1. The field named by `item_properties.text_key` (default `text`).
2. A list or object under `text_key`, such as a transcript stored as a list of turns. Potato indexes the JSON text the coding page shows, field names included.
3. For an item with no usable value under `text_key`, the first non-empty field among `caption`, `description`, `transcript`, `prompt`, `question`, `title`, `content`, and `message`.

Items with none of these fields stay out of the index, and the server log reports how many items were skipped. An image with no caption is the common case. Item IDs, case fields such as `participant_id`, and any other item field are not indexed, and a search for a participant ID finds nothing. [Cases](/docs/qda/cases) group excerpts by participant.

Potato rebuilds the whole index each time the server starts. For a study loaded from a `data_directory`, Potato rebuilds the index again after the directory loads, and the directory watcher reindexes when new files arrive. When the Python build's SQLite lacks FTS5, Potato leaves search off, the search endpoints answer `503`, and the rest of Potato runs normally.

## Search-and-claim

### The Find panel

With `search.annotator_claim: true`, the coding page shows a **Find** button. The Find panel takes a query and lists up to 50 matches, each with the snippet, the item ID, and a **Claim** button. Claim adds the item to the end of the coder's queue and replaces the button with "In your queue". A claim on an item already in the coder's queue changes nothing, and the claim endpoint reports `"already_assigned": true`. The panel does not open the item, and the coder reaches a claimed item by moving forward through the queue.

The claim endpoint accepts any item ID in the project. An item another coder already holds can be claimed as well. An unknown ID returns `404`, a request without a session returns `401`, and a project without `annotator_claim` returns `403`.

### The assignment guard

A coder who claims items is choosing the sample, and many study designs depend on Potato making that choice. With `search.annotator_claim: true`, Potato refuses to start when the config also sets any of the following:

| Setting | Reason Potato refuses |
|---------|-----------------------|
| `assignment_strategy` set to `random`, `diversity_clustering`, `max_diversity`, `active_learning`, `llm_confidence`, `least_annotated`, `category_based`, `batch`, `psychometric`, `model_review`, or `engine` | The strategy's sample or order depends on Potato choosing each item. |
| `num_annotators_per_item` above 1, an `overlap_sample.count` above 1, or the older keys `max_annotations_per_item` and `min_annotators_per_instance` above 1 | Potato cannot guarantee that overlap items reach several coders. |
| `attention_checks.enabled` or `gold_standards.enabled` | Coders could find and skip check items. |
| `icl_labeling.enabled` | Blind verification items for a model would become findable. |
| `adjudication.enabled` | Potato curates the adjudication queue. |
| `crowdsourcing.provider` set to any value except `expert`, or a legacy `prolific` or `mturk` block or login type | A paid task is the assigned unit, and claiming breaks payment and coverage. |

The guard allows the assignment strategies `fixed_order` and `priority`, and `fixed_order` is Potato's default. A config with `assignment_strategy: random` and `num_annotators_per_item: 2` fails validation with this message:

```text
ERRORS:
  - search.annotator_claim: true is incompatible with this configuration:
  - assignment_strategy: random (self-selection breaks sampling/ordering)
  - num_annotators_per_item: 2 (inter-annotator overlap cannot be guaranteed under self-selection)
```

Under `qda_mode.enabled: true` or `solo_mode.enabled: true`, Potato skips every check in the table above. A QDA Mode team project that assigns an overlap sample for [intercoder reliability](/docs/guides/intercoder-reliability-qualitative-research) therefore starts with claim on, and coders can then claim items outside the sample Potato assigned to each coder. Leave `annotator_claim` off while a team codes a reliability sample, and turn the setting on afterward if coders choosing their own items fits the design. Admin search falls outside the guard, because admin search changes no queue.

## Searching while you code in QDA Mode

A researcher who searches a corpus for a term and then codes each passage where the term appears follows these steps in Potato:

1. Open **Find** and search for the term, for example `transport`. The prefix rule also returns "transportation" and "transported".
2. Read the snippets, and claim each item whose snippet looks relevant.
3. Code each claimed item when the item comes up in your queue. Highlight the passage and apply a code from the [codebook](/docs/qda/codebook), or press `i` to make an in-vivo code from the selection.
4. Attach a [memo](/docs/qda/memos) to an item or a selection to record why the passage fits the code, or why a near-miss falls outside the code.

Search returns items, with one snippet per item. An item that mentions the term five times appears once, and the full item opens only from the queue. Potato 2.13.1 has no keyword-in-context list of every occurrence and no step that applies a code to every search match. Each coding decision stays with the coder reading the passage.

## Limits

- **Item text only.** Codes, memos, annotations, item IDs, and fields other than the indexed text are not searchable.
- **No phrase, Boolean, proximity, or stemmed search.** Every query is a set of prefixes that must all appear.
- **One snippet per item.** A snippet runs up to 12 words, and an item appears once however often the term occurs.
- **Capped result lists.** Admin search returns at most 500 items per request, coder search at most 200, and the Find panel 50.
- **Languages written without spaces.** Chinese or Japanese text is indexed as runs of characters between punctuation marks, and a query matches only at the start of a run.
- **Media without text.** An image, audio, or video item is indexed only through a caption or transcript field named by `text_key` or found among the fallback fields.

## Upstream example project

From a Potato checkout, start the upstream search example and query the admin endpoint with the example's key:

```bash
python potato/flask_server.py start examples/advanced/search-example/config.yaml -p 8000
curl -H "X-API-Key: search-example-key" \
  "http://localhost:8000/admin/api/search?q=rare"
```

The example loads six short status reports, and three of the reports mention "rare". The example turns on search-and-claim under `fixed_order` assignment with `max_annotations_per_user: 2`. To see the Find panel in a project of interview excerpts, start the QDA Mode example at `examples/advanced/qda-mode-example/config.yaml` instead.

## Further reading

- [QDA Mode](/docs/qda/qda-mode), for the workspace search belongs to
- [Codebook](/docs/qda/codebook), for the codes a coder applies to the passages search finds
- [Memos](/docs/qda/memos), for notes on items and selections
- [Task Assignment](/docs/features/task-assignment), for the assignment strategies the guard checks
- [Coding Interview Transcripts](/docs/guides/coding-interview-transcripts), for the full path from recordings to coded excerpts

For implementation details, see the [source documentation](https://github.com/davidjurgens/potato/blob/master/docs/advanced/search.md).
