Skip to content

Rank API search by the best role of each typed word - #4699

Merged
reakaleek merged 11 commits into
mainfrom
api-search/token-roles
Nov 4, 2026
Merged

reakaleek merged 11 commits into
mainfrom
api-search/token-roles

Conversation

@reakaleek

@reakaleek reakaleek commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

The API search box now explains each typed word by the best thing it names about an operation, so cluster flat_settings and es|ql drop_null_columns list the Cluster and ES|QL operations with their parameter rows, where they used to return body matches with no rows. Word order no longer matters, and a field row shows only under a word that matched a field.

Affects: Search

Prompt summary: The API search box ranks search retriever perfectly but returns bogus results for cluster flat_settings and nothing useful for es|ql drop_null_columns, although both are real query parameters. Asked why, and then asked to design the fix properly instead of patching the rule, the work became a per-token query model delivered as a stack of three changes. This is the middle layer, the query and the result rows.

Why

The old query read two plain words as "operation + field" in both orders and required the operation half to be an endpoint key. search worked only because its underscore alias _search is a key. cluster and es|ql are not keys (the keys are _cluster/settings, _query and so on), so both readings were dropped, the nested field query never ran, and the body match at boost 0.1 decided the list. The same design summed the lexical branch with the readings, so an operation whose title held both words and that also had a field named like one of them was paid twice for one word, and ties inside a group of correct operations fell to body noise.

What

One role per word

ApiLookupQueryBuilder splits the query on whitespace, reads at most its first eight words, folds a method typed before a path into that path (the typed verb lifts operations with that canonical method instead of being a required word, so a pasted POST /_count still finds the count operation), and gives each word one dis_max over the things it can name, each a named constant_score: an endpoint key (6.0 for the typed value and its _ alias, 5.5 for the routes under that alias, so snapshot also covers _snapshot/{repository}/{snapshot}/_restore), a word of the operation id or path (2.0, from api.words), the group (1.0), a word of the title (1.0), the HTTP method (0.5), a field name (1.0 exact, 0.6 prefix, with the dotted-path and trailing-dot children options as before) and a word of the description (0.1). Up to two words must all be explained; from three on, 75 percent, which replaces the separate loose clause. Whole-query lifts keep a title that holds every word (3.0), a title that starts with the typed text, the children of a typed path, the navigation rank features, and two priors that break ties deterministically: fewer operation id words and not deprecated. ApiLookupWeights holds the table and states the measured invariants between the weights.

Field rows follow the winning role

The request asks Elasticsearch for the score of each named query. ApiLookupRoles.Resolve picks the best role per word, and SearchResultProcessor lists a word's inner hits only when that word named a field at least as well as anything else, so search retriever lists retriever but not search_type.

The docs-wide search is untouched

Only type=api requests take the new builder. Every other filter builds the same lexical query and identifier field reading as before. The docs page for the API explorer describes the new rule.

Verify

dotnet test tests/Elastic.Documentation.Api.Tests/
# ApiLookupQueryBuilderTests, ApiLookupResultTests

Against a cluster that has the index fields from the layer below (DOCUMENTATION_ELASTIC_URL, DOCUMENTATION_ELASTIC_APIKEY, ENVIRONMENT=dev, DOCS_BUILD_TYPE=assembler):

dotnet test tests-integration/Search.IntegrationTests/ -- --treenode-filter "/*/*/SearchRelevanceTests/ApiSearch*"
# 32 cases: the judged first results and the field rows under the operation
dotnet test --project tests-integration/Search.IntegrationTests/ --no-build --output Detailed --treenode-filter "/*/*/SearchRelevanceTests/ApiSearchRelevanceReport"
# prints the top three per judged query with each word's winning role

On dev after reindexing with the layer below, all 32 API cases pass, 22 of 23 holdout queries the weights were never tuned on rank as expected (the one miss is a tie between datafeed operations), and the 13 docs-wide first-result failures are the identical list on the previous dated index, so nothing outside the API lookup moved. The served site shows the three reported queries with their rows.

Risk: the query reads api.words, api.group and api.id_words, which exist only on indices written after the layer below. On an older index a rank_feature query on the unmapped field returns normally and the match roles match nothing (measured on the previous dated dev index), so until the next index run the lookup falls back to the endpoint, title and field roles and nothing errors.

Out of scope: the deletion of the old readings from SearchQueryBuilder (the next layer), a prose-gated semantic branch (measured and deferred), a popularity prior for the ties that specificity does not break, and field names split on underscores so flat settings finds flat_settings.

Stack: 2 of 3, on top of #4698; #4700 removes the legacy readings on top of this branch.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Docs preview (local build)

Handbook preview: https://docs-v3-preview.elastic.dev/elastic/docs-builder/pull/4699/

Sample changed pages:

Base automatically changed from api-search/index-words to main November 4, 2026 22:34
reakaleek and others added 11 commits November 4, 2026 23:34
Two plain words were read as an operation plus a field, and the operation half had to be an endpoint, so `cluster flat_settings` found no field rows. Each word now picks its best role on an operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API lookup names every role option per word. A word that matched an endpoint as well as a field prefix, such as `search` in `search retriever`, no longer lists `search_type` rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds first-result and field-row cases for queries the operation-plus-field readings missed, and an explicit report of the top three hits with each word's winning role for whoever tunes the weights next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the two cluster flat_settings first-result cases, where four tied operations make the first result arbitrary. Loosen the three-word case to what the index guarantees. Limit the relevance report source to the fields the document type requires.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… first

Four Cluster operations take `flat_settings` and tie on every signal, and the
specificity prior puts `GET /_cluster/state` first, so the docs no longer
claim `GET /_cluster/settings` as the result of `cluster flat_settings`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`snapshot restore` ranked `snapshot-create` first. Its path minus parameters is exactly `_snapshot`, while `snapshot-restore` only has `_snapshot/*/*/_restore`. A plain word now also matches endpoints under its `_name` at 5.5, just below an exact endpoint at 6.0. So `snapshot restore` finds `snapshot-restore`, and a bare `search` still prefers `_search` over `_search/scroll`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each word adds a dis_max with a nested inner-hits query to a request sent on every keystroke, so a pasted paragraph built a very large query. The cap mirrors the eight path segments an endpoint query reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The api.method field holds only the spec's canonical verb, so a pasted request line such as `POST /_count` found nothing: POST was a required word that the count operation, canonically GET, cannot explain. The method now joins the path it precedes and only lifts operations whose verb matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@reakaleek
reakaleek force-pushed the api-search/token-roles branch from 89a4679 to 958d9cb Compare November 4, 2026 22:34
@reakaleek
reakaleek merged commit d9420a5 into main Nov 4, 2026
19 checks passed
@reakaleek
reakaleek deleted the api-search/token-roles branch November 4, 2026 22:35

This branch was successfully deployed

2 active deployments
docs-preview — 958d9cb7 Deployed Nov 4, 2026 by github-actions[bot]
assembler-preview — 958d9cb7 Deployed Nov 4, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant