The SD Search API enables search across different datasets.
Supported configurations:
- Bigpicture image search
- PostgreSQL: database for search metadata
- OpenSearch: search indexes build from the search metadata
- Snowstorm: SNOMED CT ontology server
OpenSearch indexes:
- Bigpicture:
bp-image-index.json
Install uv, then create the virtualenv and install all dependencies:
uv sync --devActivate the pre-commit hook to run tox before every commit:
uv run pre-commit installtox -e ruff
tox -e mypytox -e pytestIntegration tests require Postgres and OpenSearch to be running. Start them with Docker Compose:
docker compose --env-file tests/integration/.env --profile dev up --build -dThen run:
uv run pytest tests/integration/Environmental variables are defined in tests/integration/.env.
The UI component of SD Search is available at: https://github.com/CSCfi/sd-search-ui
To run them both locally at the same time, first start the API stack, redirecting the OIDC login flow back to the UI instead of the API itself:
OIDC_REDIRECT_URL=http://localhost:8081/search BASE_URL=http://localhost:8081 docker compose --env-file tests/integration/.env --profile dev up --build -dThen, from the sd-search-ui repository, start the UI joined to the same Compose project so it can reach the API in the
same Docker network:
COMPOSE_PROJECT_NAME=sd-search-api docker compose up --build -dThe UI service will then be available at http://localhost:8081
Build the Docker image for deployment and push it to the image container registry used by the OpenShift's ImageStream triggering rollout automatically.
docker build --platform=linux/amd64 -f dockerfiles/Dockerfile -t <image-registry-url>/sd-search-api:latest .
docker push <image-registry-url>/sd-search-api:latestSnowstorm is a SNOMED CT terminology server used by the SD Search API to resolve SNOMED CT terms to concepts.
- A Snowstorm instance is available at
https://snowstorm.rahtiapp.fi. - A SNOMED browser instance is available at:
https://snomed-browser.rahtiapp.fi/.
This is only needed when importing a new SNOMED CT release into the shared instance. The full procedure is described in https://github.com/IHTSDO/snowstorm/blob/master/docs/loading-snomed.md.
First check that the Snowstorm service is healthy:
curl https://snowstorm.rahtiapp.fi/actuator/health
Expected output:
{"status":"UP","groups":["liveness","readiness"]}%
curl -i --location 'https://snowstorm.rahtiapp.fi/imports' \
--header 'Content-Type: application/json' \
--data '{"type":"SNAPSHOT","branchPath":"MAIN","createCodeSystemVersion":true}'
Example output:
HTTP/1.1 201
location: https://snowstorm.rahtiapp.fi/imports/<ID>
Get the import ID (e.g. f0801e81-3740-48bd-bc3e-848c7aa7468e) from the response location header and define the IMPORT_ID environmental variable:
export IMPORT_ID=<ID>
Upload SNOMED release file (e.g. SnomedCT_InternationalRF2_PRODUCTION_20260601T120000Z.zip):
curl --location -X POST "https://snowstorm.rahtiapp.fi/imports/${IMPORT_ID}/archive" \
-F "file=@<SNOMED release file>"
The upload and import can take several hours. Poll the import status until status is COMPLETED
or until the import job is no longer available:
curl --location "https://snowstorm.rahtiapp.fi/imports/${IMPORT_ID}"
Example output while running:
{
"status" : "RUNNING",
"type" : "SNAPSHOT",
"branchPath" : "MAIN",
"internalRelease" : false,
"moduleIds" : [ ],
"createCodeSystemVersion" : true
}
You can monitor the import progress also from the logs:
oc logs -f deployment/snowstorm
Once finished, verify that the import has been completed.
Check the imported versions:
curl -s https://snowstorm.rahtiapp.fi/codesystems/SNOMEDCT/versions | jq '.items[] | {version, branchPath}'
Example output:
{
"version": "2026-06-01",
"branchPath": "MAIN/2026-06-01"
}
Check the MAIN branch:
curl -s https://snowstorm.rahtiapp.fi/branches/MAIN
Example output:
{
"path" : "MAIN",
"state" : "UP_TO_DATE",
"containsContent" : true,
"locked" : false,
"creation" : "2026-06-11T05:12:34.688Z",
"base" : "2026-06-11T05:12:34.688Z",
"head" : "2026-06-11T05:52:38.457Z",
"creationTimestamp" : 1781154754688,
"baseTimestamp" : 1781154754688,
"headTimestamp" : 1781157158457,
...
}
Get number of concepts:
curl -s "https://snowstorm.rahtiapp.fi/MAIN/concepts?limit=1&active=true" | jq '{total}'
Example output:
{
"total": 532824
}
Get a concept:
curl -s "https://snowstorm.rahtiapp.fi/MAIN/concepts/337915000" | jq '{conceptId, active, fsn: .fsn.term}'
Example output:
{
"conceptId": "337915000",
"active": true,
"fsn": "Homo sapiens (organism)"
}
The first positional argument selects a command group: a deployment or an ontology
(snomed, send).
The commands connect directly to Postgres and OpenSearch, so the environment must define
POSTGRES_HOST,POSTGRES_PORT,POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD and
OPENSEARCH_HOST,OPENSEARCH_PORT,OPENSEARCH_USER,and OPENSEARCH_PASSWORD. Loading
and the ontology commands also resolve preferred terms, that require SNOWSTORM_URL.
Running against a deployed environment means logging in to OpenShift, taking the credentials from Vault, and port-forwarding OpenSearch to localhost.
- Log in to the Rahti web console.
- Click your username in the top right corner.
- Select "Copy Login Command" from the dropdown menu.
- Authenticate again when asked.
- Copy the resulting
oc login ... --token=...command and paste it into your terminal.
Activate the project:
oc project sd-searchTake the values from Vault and export them:
export POSTGRES_HOST=
export POSTGRES_PORT=
export POSTGRES_DB=
export POSTGRES_USER=
export POSTGRES_PASSWORD=
export OPENSEARCH_PORT=
export OPENSEARCH_USER=
export OPENSEARCH_PASSWORD=
export SNOWSTORM_URL="https://snowstorm.rahtiapp.fi"OpenSearch is reached through a port forward rather than directly, so its host is localhost:
export OPENSEARCH_HOST=localhostStart the port forward. It runs in the foreground, so leave it in its own terminal, and make sure
OPENSEARCH_PORT is the local side of the forward:
oc port-forward svc/opensearch 9200:9200Then run the CLI in another terminal, e.g.:
uv run python scripts/admin.py Bigpicture load /path/to/datasets/ --multi-dir --load --syncAs an alternative to exporting the variables, --env-file <path> loads them from a file. It goes
before the command group:
uv run python scripts/admin.py --env-file <path> Bigpicture load /path/to/datasets/ --loadtests/integration/.env is a working set for local integration tests.
Load a single dataset directory (default):
uv run python scripts/admin.py Bigpicture load /path/to/dataset/ --loadLoad from a parent directory containing multiple dataset subdirectories:
uv run python scripts/admin.py Bigpicture load /path/to/datasets/ --multi-dir --loadOmit --load to parse XMLs without loading them to the database.
To also sync to OpenSearch immediately after loading, add --sync:
uv run python scripts/admin.py Bigpicture load /path/to/datasets/ --multi-dir --load --sync.c4gh-encrypted XML files are decrypted while they are read, given the key to decrypt them with:
uv run python scripts/admin.py Bigpicture load /path/to/datasets/ --load \
--c4gh-key-file /path/to/key.sec --c4gh-passphrase <passphrase>Delete every document from both the database and the OpenSearch index, together with the preferred terms cached for them, e.g. to reload a dataset from scratch:
uv run python scripts/admin.py Bigpicture clearThe deployment name has to be typed to confirm. Refused when DEPLOYMENT_ENV=prod. Only the terms
cached for this deployment's own fields are deleted. The terms are
cached again as the documents are loaded:
uv run python scripts/admin.py Bigpicture load /path/to/datasets/ --multi-dir --load --syncDrop and rebuild both stores, discarding all documents and the cached ontology terms. The
index is recreated from the generated mapping, so run generate-index first if
the field definitions changed:
uv run python scripts/admin.py Bigpicture recreateThe deployment name has to be typed to confirm. Refused when DEPLOYMENT_ENV=prod. Everything then
has to be reloaded, and the ontology caches refreshed:
uv run python scripts/admin.py send refresh
uv run python scripts/admin.py Bigpicture load /path/to/datasets/ --multi-dir --load --syncThe OpenSearch index mapping (search_api/api/bigpicture/index/bp-image-index.json) is
generated from the filtered and non-filtered field definitions, so that field names
and types stay in sync with them.
After changing them, regenerate and commit the file:
uv run python scripts/admin.py Bigpicture generate-indexA unit test fails if this file differs from a freshly generated one.
generate-index only writes the mapping to a local file — it does not create the index in
OpenSearch. A new OpenSearch instance needs the index created from that mapping before the
first --sync. If documents are synced into an index that doesn't exist yet, OpenSearch
silently auto-creates it with a dynamic mapping (e.g. keyword fields become text, and
nested fields become plain objects), which breaks aggregations and nested queries in ways
that only surface later, disconnected from the actual cause.
Create the index explicitly:
uv run python scripts/admin.py Bigpicture create-indexThis fails loudly if the index already exists, rather than silently leaving a stale mapping in place. If an index was already auto-created with the wrong mapping, OpenSearch cannot change an existing field's type in place, so it must be deleted and recreated, and previously-synced documents must be resynced:
curl -X DELETE https://<opensearch-host>:9200/bp-image-index -u <user>:<password>
uv run python scripts/admin.py Bigpicture create-index
# Reset sync state so the next --sync repopulates the recreated index:
# UPDATE document SET synced_at = NULL;
uv run python scripts/admin.py Bigpicture load <dir> --load --syncThese commands are not tied to a deployment: every deployment resolves its terms against the same caches.
After a new SNOMED CT release, import it into Snowstorm and update the preferred terms cached for it
in the database. --release-file is required and takes the release archive:
uv run python scripts/admin.py snomed refresh --release-file /path/to/SnomedCT_InternationalRF2_PRODUCTION_<date>.zipThe import is the procedure described under Import SNOMED release, done for you: the job is created, the archive uploaded, and the command polls until Snowstorm reports it completed.
SEND is small and simple enough to cache whole, so the concept table itself lives in the database rather than in a terminology server. This fetches the current release from NCI EVS, and updates the stored table and the preferred terms cached for it:
uv run python scripts/admin.py send refreshA release no newer than the stored one is skipped. A running server picks the new table up on its next poll, without a restart.
The experimental Bigpicture LLM search endpoint uses a small local Ollama model. Install and start it before running the API:
brew install ollama
ollama pull qwen2.5:14b
ollama serveThe /ai/query endpoint accepts a query for the LLM search. The LLM translates
the query text into Beacon V2 filters and returns structured results.
Example:
curl -X POST "http://localhost:8000/ai/query" \
-H "Content-Type: application/json" \
-d '{"query": "images for human females"}'