From ff2cad16b665c356c368c06e167007af6f0aebf9 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 4 Nov 2026 22:32:07 +0100 Subject: [PATCH] Index the words, group and id word count of each API operation The API search is moving to a per-token model where a typed word may name an operation by its endpoint, by a word of its id or path, by its group, or by a field. Those words and the group existed only concatenated into `search_title`, so they could not be weighted apart from the title and the method. `api.words` and `api.group` give each its own field, and `api.id_words` is a rank feature that breaks ties toward the more specific operation (`esql-query` over `esql-async-query-stop`). The query that reads them follows in the next change. Co-Authored-By: Claude Fable 5.1 --- .../Export/ApiSearchWords.cs | 2 + .../Export/OpenApiDocumentExporter.cs | 5 +- .../Docs/ApiOperationMetadata.cs | 22 ++++++++ .../Docs/DocumentationMappingExtensions.cs | 21 +++++++- .../ApiSearchWordsTests.cs | 7 +++ .../OpenApiOperationIdSearchTitleTests.cs | 6 +++ .../ApiPropertySearchTests.cs | 50 ++++++++++++++++--- 7 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/Elastic.ApiExplorer/Export/ApiSearchWords.cs b/src/Elastic.ApiExplorer/Export/ApiSearchWords.cs index 641ca24f81..c779906b2a 100644 --- a/src/Elastic.ApiExplorer/Export/ApiSearchWords.cs +++ b/src/Elastic.ApiExplorer/Export/ApiSearchWords.cs @@ -27,4 +27,6 @@ .. path .Select(word => word.ToLowerInvariant()) .Distinct(StringComparer.Ordinal) ]; + + internal static string[] OfOperationId(string operationId) => Of(operationId, string.Empty); } diff --git a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs index 8160ae88b3..6fe4564042 100644 --- a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs +++ b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs @@ -167,7 +167,10 @@ public IEnumerable ConvertToDocuments(OpenApiDocument ope Method = method, Path = path.Key, OperationId = operationId, - Deprecated = operation.Value.Deprecated + Deprecated = operation.Value.Deprecated, + Words = ApiSearchWords.Of(operationId, path.Key), + Group = groupParent?.Title ?? "", + IdWords = ApiSearchWords.OfOperationId(operationId).Length }; yield return new DocumentationDocument diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Docs/ApiOperationMetadata.cs b/src/services/search/Elastic.Documentation.Search.Contract/Docs/ApiOperationMetadata.cs index ae9317a36f..5e00b94f88 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Docs/ApiOperationMetadata.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Docs/ApiOperationMetadata.cs @@ -46,6 +46,28 @@ public record ApiOperationMetadata [JsonPropertyName("operation_id")] public required string OperationId { get; init; } + /// + /// Plain lowercase words of the operation id and its literal path segments, e.g. cluster health. + /// A typed cluster or esql names an operation by these words, which otherwise live only + /// concatenated into search_title. + /// + [Text] + [JsonPropertyName("words")] + public string[] Words { get; init; } = []; + + /// Title of the operation's group as the explorer shows it, e.g. ES|QL. Empty without a group. + [Text] + [JsonPropertyName("group")] + public string Group { get; init; } = ""; + + /// + /// Number of words in the operation id alone, e.g. 2 for cluster.health. Mapped as + /// rank_feature with negative score impact. Fewer id words is a more specific operation, + /// so ties break toward esql-query over esql-async-query-stop. + /// + [JsonPropertyName("id_words")] + public int IdWords { get; init; } + /// Whether the spec marks the operation deprecated. [Boolean] [JsonPropertyName("deprecated")] diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs b/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs index 93edc8b67b..d8720c4bfb 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Docs/DocumentationMappingExtensions.cs @@ -30,5 +30,24 @@ public static MappingsBuilder AddDocumentationMappings(th .AddProperty("applies_to.sub_type", f => f.Keyword().Normalizer(SharedMappingConfig.KeywordNormalizer)) .AddProperty("applies_to.lifecycle", f => f.Keyword().Normalizer(SharedMappingConfig.KeywordNormalizer)) .AddProperty("applies_to.version", f => f.Version()) - .AddProperty("api.properties.path", f => f.Keyword().MultiField("match", mf => mf.Text())); + .AddProperty("api.properties.path", f => f.Keyword().MultiField("match", mf => mf.Text())) + .AddProperty( + "api.words", + f => f + .Text() + .Analyzer(SharedMappingConfig.SynonymsFixedAnalyzer) + .SearchAnalyzer(SharedMappingConfig.SynonymsAnalyzer) + .MultiField( + "completion", + mf => mf + .SearchAsYouType() + .Analyzer(SharedMappingConfig.SynonymsFixedAnalyzer) + .SearchAnalyzer(SharedMappingConfig.SynonymsAnalyzer) + ) + ) + .AddProperty( + "api.group", + f => f.Text().Analyzer(SharedMappingConfig.SynonymsFixedAnalyzer).SearchAnalyzer(SharedMappingConfig.SynonymsAnalyzer) + ) + .AddProperty("api.id_words", f => f.RankFeature().PositiveScoreImpact(false)); } diff --git a/tests/Elastic.ApiExplorer.Tests/ApiSearchWordsTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiSearchWordsTests.cs index dce4d5ff1e..cfdd630664 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiSearchWordsTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiSearchWordsTests.cs @@ -17,4 +17,11 @@ public class ApiSearchWordsTests [Arguments("_bulk", "/_bulk", "bulk")] public void Of_OperationIdAndPath_ReturnsLowercaseDistinctWords(string operationId, string path, string words) => string.Join(' ', ApiSearchWords.Of(operationId, path)).Should().Be(words); + + [Test] + [Arguments("cluster.health", "cluster health")] + [Arguments("esql-async-query-stop", "esql async query stop")] + [Arguments("_bulk", "bulk")] + public void OfOperationId_OperationId_ReturnsIdWordsOnly(string operationId, string words) => + string.Join(' ', ApiSearchWords.OfOperationId(operationId)).Should().Be(words); } diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs index 3162b5fc68..0672abd385 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiOperationIdSearchTitleTests.cs @@ -182,9 +182,15 @@ public async Task ConvertToDocuments_TaggedOperations_IndexGroupAndWordsInSearch o => o.WithStrictOrdering() ); flush.Api!.Deprecated.Should().BeFalse(); + flush.Api.Words.Should().Equal("ml", "flush", "job", "anomaly", "detectors"); + flush.Api.Group.Should().Be("Machine learning anomaly detection"); + flush.Api.IdWords.Should().Be(3); docs["ml-close-job"].Api!.Deprecated.Should().BeTrue(); var bulk = docs["_bulk"]; bulk.SearchTitle.Should().Be("Bulk index or delete documents - Elasticsearch API - _bulk - PUT /_bulk - bulk"); + bulk.Api!.Words.Should().Equal("bulk"); + bulk.Api.Group.Should().Be(""); + bulk.Api.IdWords.Should().Be(1); bulk .Parents .Should() diff --git a/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs b/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs index 4713bcceb4..5990cac7d6 100644 --- a/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/ApiPropertySearchTests.cs @@ -303,17 +303,44 @@ public void BuildApiPropertyQuery_ExcludedQuery_ReturnsNull(string input, string [Test] [Arguments(false)] [Arguments(true)] - public void Mapping_ApiDeprecated_IsBooleanWithoutGroup(bool semantic) + public void Mapping_ApiDeprecated_IsBoolean(bool semantic) { - var context = semantic - ? DocumentationMappingContext.DocumentationDocumentSemantic.CreateContext(type: "assembler", env: "test") - : DocumentationMappingContext.DocumentationDocument.CreateContext(type: "assembler", env: "test"); - using var json = JsonDocument.Parse(context.GetMappingsJson()); - var api = json.RootElement.GetProperty("properties").GetProperty("api").GetProperty("properties"); - api.TryGetProperty("group", out _).Should().BeFalse(); + var api = ApiMapping(semantic); api.GetProperty("deprecated").GetProperty("type").GetString().Should().Be("boolean"); } + [Test] + [Arguments(false)] + [Arguments(true)] + public void Mapping_ApiWordsAndGroup_AreSynonymText(bool semantic) + { + var api = ApiMapping(semantic); + var words = api.GetProperty("words"); + words.GetProperty("type").GetString().Should().Be("text"); + words.GetProperty("analyzer").GetString().Should().Be("synonyms_fixed_analyzer"); + words.GetProperty("search_analyzer").GetString().Should().Be("synonyms_analyzer"); + var completion = words.GetProperty("fields").GetProperty("completion"); + completion.GetProperty("type").GetString().Should().Be("search_as_you_type"); + completion.GetProperty("analyzer").GetString().Should().Be("synonyms_fixed_analyzer"); + completion.GetProperty("search_analyzer").GetString().Should().Be("synonyms_analyzer"); + completion.TryGetProperty("index_options", out _).Should().BeFalse(); + + var group = api.GetProperty("group"); + group.GetProperty("type").GetString().Should().Be("text"); + group.GetProperty("analyzer").GetString().Should().Be("synonyms_fixed_analyzer"); + group.GetProperty("search_analyzer").GetString().Should().Be("synonyms_analyzer"); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public void Mapping_ApiIdWords_IsRankFeatureWithNegativeImpact(bool semantic) + { + var idWords = ApiMapping(semantic).GetProperty("id_words"); + idWords.GetProperty("type").GetString().Should().Be("rank_feature"); + idWords.GetProperty("positive_score_impact").GetBoolean().Should().BeFalse(); + } + [Test] [Arguments(false)] [Arguments(true)] @@ -768,4 +795,13 @@ public void ProcessHit_NestedPropertySource_ReturnsMatchingProperty() result.Properties.Should().ContainSingle().Which.Anchor.Should().Be("req-track_total_hits"); } + + private static JsonElement ApiMapping(bool semantic) + { + var context = semantic + ? DocumentationMappingContext.DocumentationDocumentSemantic.CreateContext(type: "assembler", env: "test") + : DocumentationMappingContext.DocumentationDocument.CreateContext(type: "assembler", env: "test"); + using var json = JsonDocument.Parse(context.GetMappingsJson()); + return json.RootElement.GetProperty("properties").GetProperty("api").GetProperty("properties").Clone(); + } }