An account manager types northwnd. They mean Northwind Holdings Group, a client group of four insureds in Tulsa — and there are five other client groups with the same name in other cities, forty-odd insureds that start with Northwind, and a few hundred policies under them. A good search answers that in a few milliseconds: the groups first, each with the line that tells them apart, then the insureds, and one click away the group's policies, active ones on top.
That is what Search in InsightCompose does, and it is an API before it is a page:
GET /api/v1/search?q=northwnd. The header search box, the search page and the “who
did you mean” step in Ask are three consumers of the same index.
Names are fuzzy, ids are exact
Two kinds of thing get searched, and they want opposite treatment. A name — a company, a person, a broker office — is typed from memory, so it is matched fuzzily from three letters: dropped letters, doubled letters, swapped letters, accents, & for and. An id — a policy number, a claim number, a submission reference — is copied from a document, so it is matched exactly, or by the start of it, with dashes, spaces and case ignored. GL2025-0001 finds GL-2025-000123; it never finds an office in Indianapolis because the letters happen to overlap.
Names that start with what was typed come first, then names with a word that starts with it, then matches inside a word. Among equally good matches the bigger account ranks higher — you choose what “bigger” means: written premium, a count of policies, revenue. A parent can be boosted above its own members, so marsh puts Marsh McLennan Companies above its nine offices.
Active first, and what belongs to it
Records with a life — a policy term, a contract, a licence — name the two dates that bound it.
A record in force today is active, one past its end is expired, and active ones rank
first; a caller can ask for either with state=active. A renewed policy is one
series of terms, so a search for its number shows the current term and lists the earlier ones,
instead of seven near-identical hits.
Every hit can carry its related records: a client group's insureds, an insured's policies,
claims and submissions, a broker's book. They come from the same index, scoped the same way, five per
type with a count of the rest, in one call to /api/v1/search/related. Filters — state,
status, line of business, any column you declare as one, including date and number ranges —
narrow only the types that have them, so filtering policies by line does not empty the list of
brokers.
Defined in a builder, not in SQL
Each entity type in a search index is described rather than written. Pick a base table and the editor drafts the rest from what indexing already learned about it: the id, the name and the columns it is also known by, the parent it belongs to and the join that reaches it, the current-row flag on a history table, the two dates of an active record, a measure to rank by, a subtitle. You check each section and publish.
Some entities are not rows at all. A client group is a column on the insured table that many insureds share; a broker group is the parent of many offices. Switch the type to grouped and it becomes one result per value, its single values taken from its members, its rank their sum, and its scope the set of every member's keys — which is what lets a person confined to one broker find the groups their insureds belong to and no others.
The editor previews each type against the warehouse before anything is built: the SQL it generated, a few rows, and the documents they become. Publish builds the index; Rebuild refreshes a published one from the warehouse, and a nightly rebuild can do that on its own.
Scope is part of the index, not a filter in the UI
Every document carries the keys of every scope it falls in — its broker, its broker group, its region, its client group. A caller's grant names the scopes it may see, and the search adds them to the query itself. A key confined to one broker group searching for northwind gets the Northwinds that group writes, and nothing tells it the others exist. A grant that confines a caller on an axis the index has no keys for matches nothing: it fails closed.
| GET /api/v1/search… | What it answers |
|---|---|
| ?q= | Names fuzzily, ids exactly; types, filters, state, as_of, limit |
| /lookup?key= | One id, exactly or by its start — a policy number pasted from a letter |
| /related | What belongs to a hit, five per type with the count of the rest |
| /types | The types, their labels, icons and filters — enough to draw the chips and dropdowns |
Postgres keeps the index; Typesense makes it forgiving
The index of record is a pair of tables in the deployment's own Postgres: names with a trigram index, ids with an exact and a prefix index. That alone answers in milliseconds and already forgives dropped and doubled letters. A Typesense container keeps a copy of the names, which adds swapped letters and typo tolerance across words; it holds nothing of its own and is refilled from Postgres in seconds whenever it starts empty. If it is down, search answers from Postgres and says so.
One read-only query per entity type fills the Postgres index of record; the names are then copied to Typesense and swapped in under an alias.
Names are answered by Typesense, ids and related records by Postgres — and Postgres answers everything if Typesense is down. Only hits inside the caller's scope come back.
On the insurance book we test with — 25,000 insureds, 557,000 policies, 232,000 claims, 417,000 submissions — a full rebuild is about four minutes and produces 26,000 names and 1.2 million keys; a search takes a few milliseconds on the server. A rebuild is written beside the live index and swapped in only when it is complete, and one that comes back much smaller than the index it would replace is refused rather than swapped: a warehouse that returned half its rows because of a timeout does not quietly halve your search.
The principle
People look things up by the name they remember and the number on the page in front of them. Answer both, in the order they would rank them, and never show anyone a record their grant would not let them open.
Read next
Resolve the name before writing the SQL — the same index, used by Ask so a misspelt client in a question becomes the right id. A catalog with a ledger — the grants that decide what a caller's search can see.