kurn search
kurn search is a collection of example search adapter implementations for the kurn engine, with TypeScript and Python clients. Use them as reference designs for building search into your own application.
Start by running the parts-catalog suggestion example below: load sample records, try a mistyped part number, filter the results and call the adapter from a client. Then substitute your own records or adapt an implementation to your application.
Run the example → · Browse the adapter implementations → · Use the clients →
A local kurnd backend, an example adapter service and a client query, using fictional data. Keep both services on loopback; the sample authentication is for local demonstration.
New to the engine? Try the engine quickstart first. For a smaller adapter example, use the one-record identifier lookup.
The Search source distribution includes AGPL-3.0-only service and adapter code, and MIT-licensed TypeScript and Python clients. These steps use source checkouts; no npm or PyPI installation is promised.
How it fits
application → TypeScript or Python client → kurn-searchd → kurnd
fixed Search API adapter plan list snapshotkurnd owns matching, typed payload filtering and versioned list snapshots. The adapter owns source projection, bounded probes, evidence checks and result ranking. The service supplies a trusted tenant scope and either returns a complete result or refuses. kurn search connects directly to kurnd.
Shared setup: build the local tools once
Use the current kurn and kurn search source checkouts with Go 1.27, Node.js, Python and curl. Use absolute physical checkout paths (pwd -P) and a fresh private run root outside both checkouts. Choose unused loopback ports.
Tested source and toolchain references
This walkthrough was checked with Node.js 25.6.1, Python 3.14.5, this Search source revision and this engine source revision. The Search checkout's docs/user-manual.md records its compatible engine identity. Preparation verifies that the tools match that contract.
export KURN_REPO=/absolute/path/to/kurn export KURN_SEARCH_REPO=/absolute/path/to/kurn-search export KURN_SEARCH_RUN_ROOT="$(cd "$(mktemp -d)" && pwd -P)" chmod 700 "$KURN_SEARCH_RUN_ROOT" umask 077 mkdir -p "$KURN_SEARCH_RUN_ROOT/bin" (cd "$KURN_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurn" ./cmd/kurn) (cd "$KURN_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurnd" ./cmd/kurnd) (cd "$KURN_SEARCH_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurn-searchd" ./cmd/kurn-searchd) (cd "$KURN_SEARCH_REPO" && go build -o "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" ./cmd/kurn-search-local)
Keep the same checkout paths and run root in every terminal; do not run mktemp again there. Continue with the prepared journey below. It does not require the ordinary bundle build, prepare, or mutable-store startup commands in the alternative example.
Prepared suggestions: source → version → query
First complete Shared setup once: it provides both checkout paths, the private run root and all four executables. Then follow this section through client integration; skip the mutable-store alternative. The checked source contains 24 fictional records, including a unique readiness record for tenant alpha, partition north. Your source must have exactly one record in its chosen readiness scope.
These rows from source-a.ndjson make the lookup visible. They are a subset of the supplied 24-row source, not a replacement input file:
| ID | Identifier | Manufacturer | Status | Tenant / partition |
|---|---|---|---|---|
| A01 | ZS-901 | Northstar | active | alpha / north |
| A02 | AX-100 | Northstar | active | alpha / south |
| A03 | AX-100 | Northstar | active | alpha / south |
| A04 | AX-100 | northstar | discontinued | alpha / south |
| B13 | AX-100 | Northstar | active | beta / north |
export PREPARED_VERSION="$KURN_SEARCH_RUN_ROOT/version-a" export KURND_ADDR=127.0.0.1:18080 KURN_SEARCHD_ADDR=127.0.0.1:18081 "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" prepare-artifact \ -profile parts-catalog-suggest \ -source "$KURN_SEARCH_REPO/examples/parts-catalog-suggest-prepared/source-a.ndjson" \ -kurn "$KURN_SEARCH_RUN_ROOT/bin/kurn" \ -kurnd "$KURN_SEARCH_RUN_ROOT/bin/kurnd" \ -searchd "$KURN_SEARCH_RUN_ROOT/bin/kurn-searchd" \ -search-checkout "$KURN_SEARCH_REPO" \ -build-dir "$KURN_SEARCH_RUN_ROOT/build-a" -out "$PREPARED_VERSION" \ -readiness-tenant alpha -readiness-partition north \ -kurnd-addr "$KURND_ADDR" -searchd-addr "$KURN_SEARCHD_ADDR" "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" verify-version -out "$PREPARED_VERSION"
Both targets must be absent and their parents physical, private 0700 directories. Require successful exits and verification reporting status: "VERIFIED", startable: true. Do not start a partial, uncertain or refused publication. Runtime controls contain absolute paths: do not relocate the version or rewrite controls. Keep logs outside it; only state/ is mutable. Serving no longer needs the source or mapping.
Keep these environment values in every terminal. Start the bundled backend in terminal 1:
"$PREPARED_VERSION/bin/kurnd" -addr "$KURND_ADDR" \ -artifact "$PREPARED_VERSION/data/parts-catalog-suggest-v1.kurn" \ -api-keys-file "$PREPARED_VERSION/kurnd-api-keys"
In terminal 2, require HTTP 200 and Kurn-Artifact-Verification: full before starting Search. This composition refuses trusted verification at readiness.
curl --fail --silent --show-error --include "http://$KURND_ADDR/readyz" "$PREPARED_VERSION/bin/kurn-searchd" -config "$PREPARED_VERSION/kurn-searchd.json"
In terminal 3, wait for $PREPARED_VERSION/state/search-status.json while Search remains running, then query with the newly generated tenant handle. This dependency-free example reads the handle internally, without printing it or putting it in command arguments:
PYTHONPATH="$KURN_SEARCH_REPO/clients/python" python3 - <<'PY'
import json
import os
from pathlib import Path
from kurn_search_client import KurnSearchClient, PartsCatalogSuggestRequest
version = Path(os.environ["PREPARED_VERSION"])
credentials = json.loads((version / "client-credentials.json").read_text())
handle = next(c["handle"] for c in credentials["credentials"] if c["tenant"] == "alpha")
client = KurnSearchClient("http://" + os.environ["KURN_SEARCHD_ADDR"], lambda: handle)
result = client.search_parts_catalog_suggest(
PartsCatalogSuggestRequest("ZS-901", 1, partition_id="north"),
timeout_seconds=5,
)
assert result["complete"] is True
assert [hit["entry_id"] for hit in result["hits"]] == ["A01"]
assert result["hits"][0]["result"]["score"] == 100
print("prepared sentinel: PASS (A01, score 100)")
PYTry a mistyped part number
Keep both prepared processes running. AX-010 swaps two adjacent digits in AX-100. Run this in terminal 3 with the same environment and alpha handle. The first query shows candidates, the second filters to active Northstar parts, and the last checks a no-match:
PYTHONPATH="$KURN_SEARCH_REPO/clients/python" python3 - <<'PY'
import json
import os
from pathlib import Path
from kurn_search_client import KurnSearchClient, PartsCatalogSuggestRequest
version = Path(os.environ["PREPARED_VERSION"])
credentials = json.loads((version / "client-credentials.json").read_text())
handle = next(c["handle"] for c in credentials["credentials"] if c["tenant"] == "alpha")
client = KurnSearchClient("http://" + os.environ["KURN_SEARCHD_ADDR"], lambda: handle)
for label, query, filters, expected in [
("typo", "AX-010", None, ["A02", "A03", "A04"]),
("filtered", "AX-010", {"manufacturer": "Northstar", "status": "active"}, ["A02", "A03"]),
("no-match", "QQQQQQ", None, []),
]:
result = client.search_parts_catalog_suggest(
PartsCatalogSuggestRequest(query, 10, partition_id="south", filters=filters),
timeout_seconds=5,
)
assert result["complete"] is True
assert [hit["entry_id"] for hit in result["hits"]] == expected
print(label + ":")
for hit in result["hits"]:
row = hit["result"]
print(" ", hit["entry_id"], row["identifier"], row["manufacturer"], row["status"])
if not result["hits"]:
print(" no matches")
PYtypo: A02 AX-100 Northstar active A03 AX-100 Northstar active A04 AX-100 northstar discontinued filtered: A02 AX-100 Northstar active A03 AX-100 Northstar active no-match: no matches
The result preserves separate record IDs even when identifiers repeat. Tenant scope comes from the handle, so beta's B13 is not a candidate. Manufacturer filters are case-sensitive. A generated adjacent-transposition variant can yield an exact companion hit; its score of 100 is whole-key membership, not confidence. See score origins and policy defaults before interpreting scores on other data.
API and clients
The Search HTTP API is distinct from the kurnd API. Its canonical contract is maintained in the Search repository. The generated TypeScript and Python clients call fixed routes and validate responses. The examples below import the clients directly from the source checkout; no package installation is needed.
Keep the prepared service running. This Node.js example uses the source-checkout TypeScript client's generated runtime, the same version's tenant handle and the suggestion adapter you just prepared. No client package installation is needed:
node --input-type=module <<'JS'
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const { KurnSearchClient } = await import(pathToFileURL(
process.env.KURN_SEARCH_REPO + "/clients/typescript/generated/client.mjs"
));
const credentials = JSON.parse(await readFile(
process.env.PREPARED_VERSION + "/client-credentials.json", "utf8"
));
const handle = credentials.credentials.find(c => c.tenant === "alpha").handle;
const client = new KurnSearchClient({
endpoint: "http://" + process.env.KURN_SEARCHD_ADDR,
getToken: () => handle,
});
const result = await client.searchPartsCatalogSuggest(
{ query: "AX-010", limit: 10, partitionId: "south",
filters: { manufacturer: "Northstar", status: "active" } },
{ timeoutMs: 5_000 },
);
for (const hit of result.hits) {
console.log(hit.entry_id, hit.result.identifier, hit.result.manufacturer);
}
JSExpected identifiers: A02 AX-100 Northstar and A03 AX-100 Northstar. Discovery is a different recipe: prepare its own service before calling searchPartsCatalogDiscover. Calling it on the suggestion service returns 404 adapter_not_found.
Stop and restart cleanly
The handle supplies tenant scope; do not send tenant_id or reuse an ordinary example's fixed fixture handle. Stop Search with Ctrl-C in its owning terminal and wait for exit, then stop the backend and wait. Clean Search shutdown removes its status file. Run "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" verify-version -out "$PREPARED_VERSION" again and require startable: true before restarting the unchanged version with its bundled executables. A status-file warning is not proof that a process stopped; inspect the owning terminals and never delete a live process's status file.
Use your own data or adapt an implementation
For the same part-number lookup, start by changing the source records as described below. For a different search behavior, choose the closest example implementation and use its source projection, query plan and result handling as a starting point for your own adapter. Keep the example’s tests as a reference and add cases for your own data and expected results.
Use the same NDJSON fields as the source example: id, identifier, manufacturer, category, status, tenant_id and optional partition_id. Follow that profile's validation rules; do not silently drop source fields or invent a readiness record in customer data.
Change -source to your file, select a tenant/partition containing exactly one readiness record, and use fresh build and version targets plus unused ports. Repeat verification and authenticated queries with that version's newly generated handle. Judge results on your own labelled typos and no-matches before integrating. Keep your authoritative source separately; replace a published version rather than editing its artifact or controls.
Alternative: mutable-store identifier example
This is a separate one-record example, not a prerequisite for prepared suggestions. Complete Shared setup, then use unused ports and the following commands instead of prepare-artifact. Its mutable bundle/update operations do not apply to read-only prepared serving.
export KURND_ADDR=127.0.0.1:8080 KURN_SEARCHD_ADDR=127.0.0.1:8081
"$KURN_SEARCH_RUN_ROOT/bin/kurn" build \ -in "$KURN_SEARCH_REPO/adapters/catalogid/testdata/list-feed.ndjson" \ -mapping "$KURN_SEARCH_REPO/adapters/catalogid/testdata/list-mapping.json" \ -out "$KURN_SEARCH_RUN_ROOT/bundle" "$KURN_SEARCH_RUN_ROOT/bin/kurn-search-local" prepare \ -bundle "$KURN_SEARCH_RUN_ROOT/bundle" -out "$KURN_SEARCH_RUN_ROOT/local" \ -kurnd-addr "$KURND_ADDR" -searchd-addr "$KURN_SEARCHD_ADDR"
Start each foreground process in its own terminal:
"$KURN_SEARCH_RUN_ROOT/bin/kurnd" -addr "$KURND_ADDR" -data "$KURN_SEARCH_RUN_ROOT/local/data" "$KURN_SEARCH_RUN_ROOT/bin/kurn-searchd" -config "$KURN_SEARCH_RUN_ROOT/local/kurn-searchd.json" node "$KURN_SEARCH_REPO/examples/catalog-identifier/search.mjs"
Start terminal 2 after kurnd listens, then run terminal 3 after the Search status file appears. The checked example returns only config-proof for identifier ABC-123. Stop Search first, then kurnd. The prepared handle is fixture data, not a production credential.
Different paths, different policy defaults
| Path or policy | Similarity generation | Acceptance and exact transposition step |
|---|---|---|
Ordinary bundle suggest / legacy | 0.65 | Legacy acceptance; transposition only on an empty pool. |
Prepared default: osa1-060 | 0.60 | Optimal-string-alignment distance at most one; transposition if no candidate is accepted. |
Opt-in osa1-060-union | 0.60 | Distance-one acceptance; always combine and deduplicate both branches. |
Experimental osa1 | 0.45 | Distance-one acceptance; transposition if nothing is accepted. |
Set -policy NAME during prepare-artifact. New preparation without it writes osa1-060 explicitly; an existing bound configuration with no policy member stays legacy. Non-legacy policies are named in config, manifest and diagnostic; -policy legacy deliberately omits those members to preserve legacy identities. Policy and diagnostic identities must agree.
There are two score origins. Similarity hits report rounded corpus-known n-gram coverage; unknown query grams are excluded from its denominator, so an inexact identifier can score 100. Exact companion hits instead receive 100 from whole-key membership of a generated transposition variant, with transposition: "yes" in their evidence. Under the union policy, transposition evidence wins when both branches find the same entry. Neither score is probability, confidence or edit distance; calibrate on your own labels.
-max-inflight accepts 1–64 (default 4); -max-queue accepts 1–4096 (default four times max-inflight). A full queue returns 503 service_queue_full with Retry-After, before adapter preparation but after HTTP parsing and authentication. A queued request waits at most five seconds for admission, then gets 503 service_admission_unavailable. These bound admission slots, not all HTTP work, process memory or end-to-end latency, and do not promise tenant fairness. Both clients expose the validated retry hint without automatically retrying.
Example adapter implementations
| Example | What it demonstrates |
|---|---|
catalog-identifier | Smallest exact identifier path. |
parts-catalog | Part identifiers with scalar manufacturer, category and status filters. |
parts-catalog-select | Bounded multi-select values within fields. |
parts-catalog-discover | One query over identifiers and names, with filters. |
parts-catalog-discover-facets | Exact counts over the complete matched set before the caller limit. |
parts-catalog-discover-facet-alternatives | Facet counts that show alternatives after a selection. |
parts-catalog-suggest | Scored part-number suggestions from a dedicated n-gram list. |
Each implementation has fictional source data and a focused example guide. One local kurn-searchd process loads one configured adapter. Run separate local processes for different examples.
Current limits
- These implementations are reference designs, not an arbitrary full-text query language. Each adapter has its own field grammar, bounds and completeness checks.
- The local service uses loopback listeners and fixture authentication. Production auth, TLS, supervision and fleet operation require separate work.
- Similarity scores are corpus-dependent n-gram coverage; exact transposition hits instead score 100 for whole-key membership of a generated variant. Neither is probability or edit distance.