Skip to content

Full-Text Search for Qualitative Coding

Find every interview excerpt or open-ended answer that mentions a word, and let a coder pull the matching items into the coder's own queue.

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 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:

OptionDefaultDescription
search.enabledtrueBuild the index at startup and serve the search endpoints. With false, Potato builds no index, and admin search answers 503.
search.backendfts5The index engine. fts5 is the only backend in Potato 2.13.1. Any other value turns search off and logs a warning.
search.max_instances100000The 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_claimfalseShow 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).

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

WhoEndpointAvailable whenResults per request
Admins and adjudicatorsGET /admin/api/search?q=<query>&limit=<n>search.enabled is true50 by default, up to 500
Any signed-in coderGET /api/search?q=<query>&limit=<n>, and the Find panelsearch.annotator_claim: true50 by default, up to 200; the Find panel asks for 50
Any signed-in coderPOST /api/search/claim with {"instance_id": "<id>"}search.annotator_claim: trueOne 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 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 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:

SettingReason Potato refuses
assignment_strategy set to random, diversity_clustering, max_diversity, active_learning, llm_confidence, least_annotated, category_based, batch, psychometric, model_review, or engineThe 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 1Potato cannot guarantee that overlap items reach several coders.
attention_checks.enabled or gold_standards.enabledCoders could find and skip check items.
icl_labeling.enabledBlind verification items for a model would become findable.
adjudication.enabledPotato curates the adjudication queue.
crowdsourcing.provider set to any value except expert, or a legacy prolific or mturk block or login typeA 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 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, or press i to make an in-vivo code from the selection.
  4. Attach a memo 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

For implementation details, see the source documentation.