diff --git a/src/Meilisearch/Index.Documents.cs b/src/Meilisearch/Index.Documents.cs index e21c0097..21fd7952 100644 --- a/src/Meilisearch/Index.Documents.cs +++ b/src/Meilisearch/Index.Documents.cs @@ -572,5 +572,35 @@ public async Task FacetSearchAsync(string facetName, .ReadFromJsonAsync(cancellationToken: cancellationToken) .ConfigureAwait(false); } + + /// + /// Retrieve documents similar to a specific search result. + /// + /// Type parameter to return. + /// + /// + /// + /// + public async Task> SearchSimilarDocuments(string id, + SimilarDocumentsSearch searchAttributes = default, CancellationToken cancellationToken = default) + { + SimilarDocumentsSearch similarSearch; + if (searchAttributes == null) + { + similarSearch = new SimilarDocumentsSearch() { Id = id }; + } + else + { + similarSearch = searchAttributes; + similarSearch.Id = id; + } + + var responseMessage = await _http.PostAsJsonAsync($"indexes/{Uid}/similar", similarSearch, cancellationToken) + .ConfigureAwait(false); + + return await responseMessage.Content + .ReadFromJsonAsync>() + .ConfigureAwait(false); + } } } diff --git a/src/Meilisearch/MeilisearchClient.cs b/src/Meilisearch/MeilisearchClient.cs index c926da86..d1b6f67a 100644 --- a/src/Meilisearch/MeilisearchClient.cs +++ b/src/Meilisearch/MeilisearchClient.cs @@ -422,6 +422,33 @@ public string GenerateTenantToken(string apiKeyUid, TenantTokenRules searchRules return TenantToken.GenerateToken(apiKeyUid, searchRules, apiKey ?? ApiKey, expiresAt); } + /// + /// Get a list of all experimental features that can be activated via the /experimental-features route and whether or not they are currently activated. + /// + /// The cancellation token for this call. + /// A dictionary of experimental features and their current state. + public async Task> GetExperimentalFeaturesAsync(CancellationToken cancellationToken = default) + { + var response = await _http.GetAsync("experimental-features", cancellationToken).ConfigureAwait(false); + return await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Activate or deactivate experimental features. + /// + /// true to activate, false to deactivate. + /// Experimental feature name to change. + /// The cancellation token for this call. + /// The experimental feature's updated state. + public async Task> UpdateExperimentalFeatureAsync(string featureName, bool activeState, CancellationToken cancellationToken = default) + { + var feature = new Dictionary() { { featureName, activeState } }; + var response = await _http.PatchAsJsonAsync($"experimental-features", feature, Constants.JsonSerializerOptionsRemoveNulls, cancellationToken).ConfigureAwait(false); + + var responseData = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken).ConfigureAwait(false); + return responseData.FirstOrDefault(); + } + /// /// Create a local reference to a task, without doing an HTTP call. /// @@ -437,5 +464,6 @@ private TaskEndpoint TaskEndpoint() return _taskEndpoint; } + } } diff --git a/src/Meilisearch/SimilarDocumentsResult.cs b/src/Meilisearch/SimilarDocumentsResult.cs new file mode 100644 index 00000000..96dc2233 --- /dev/null +++ b/src/Meilisearch/SimilarDocumentsResult.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + /// + /// Represents the result of a similar documents search. + /// + /// Hit type. + public class SimilarDocumentsResult + { + /// + /// Documents similar to Id. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Results of the query. + /// + [JsonPropertyName("hits")] + public IReadOnlyCollection Hits { get; } + + /// + /// Number of documents skipped. + /// + [JsonPropertyName("offset")] + public int Offset { get; } + + /// + /// Number of documents to take. + /// + [JsonPropertyName("limit")] + public int Limit { get; } + + /// + /// Gets the estimated total number of hits returned by the search. + /// + [JsonPropertyName("estimatedTotalHits")] + public int EstimatedTotalHits { get; } + + /// + /// Processing time of the query. + /// + [JsonPropertyName("processingTimeMs")] + public int ProcessingTimeMs { get; } + } +} diff --git a/src/Meilisearch/SimilarDocumentsSearch.cs b/src/Meilisearch/SimilarDocumentsSearch.cs new file mode 100644 index 00000000..15c66941 --- /dev/null +++ b/src/Meilisearch/SimilarDocumentsSearch.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + /// + /// Retrieve documents similar to a specific search result. + /// + public class SimilarDocumentsSearch + { + /// + /// Identifier of the target document. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Embedder to use when computing recommendations. + /// + [JsonPropertyName("embedder")] + public string Embedder { get; set; } = "default"; + + /// + /// Attributes to display in the returned documents. + /// + [JsonPropertyName("attributesToRetrieve")] + public IEnumerable AttributesToRetrieve { get; set; } = new[] { "*" }; + + /// + /// Number of documents to skip. + /// + [JsonPropertyName("offset")] + public int Offset { get; set; } = 0; + + /// + /// Maximum number of documents returned. + /// + [JsonPropertyName("limit")] + public int Limit { get; set; } = 20; + + /// + /// Filter queries by an attribute's value. + /// + [JsonPropertyName("filter")] + public string Filter { get; set; } = null; + + /// + /// Display the global ranking score of a document. + /// + [JsonPropertyName("showRankingScore")] + public bool ShowRankingScore { get; set; } + + /// + /// Display detailed ranking score information. + /// + [JsonPropertyName("showRankingScoreDetails")] + public bool ShowRankingScoreDetails { get; set; } + + /// + /// Exclude results with low ranking scores. + /// + [JsonPropertyName("rankingScoreThreshold")] + public decimal? RankingScoreThreshold { get; set; } + + /// + /// Return document vector data. + /// + [JsonPropertyName("retrieveVectors")] + public bool RetrieveVectors { get; set; } + } +} diff --git a/tests/Meilisearch.Tests/Datasets.cs b/tests/Meilisearch.Tests/Datasets.cs index 610454a2..9efb6080 100644 --- a/tests/Meilisearch.Tests/Datasets.cs +++ b/tests/Meilisearch.Tests/Datasets.cs @@ -17,6 +17,7 @@ internal static class Datasets public static readonly string MoviesForFacetingJsonPath = Path.Combine(BasePath, "movies_for_faceting.json"); public static readonly string MoviesWithIntIdJsonPath = Path.Combine(BasePath, "movies_with_int_id.json"); public static readonly string MoviesWithInfoJsonPath = Path.Combine(BasePath, "movies_with_info.json"); + public static readonly string MoviesWithVectorStoreJsonPath = Path.Combine(BasePath, "movies_with_vector_store.json"); public static readonly string ProductsForDistinctJsonPath = Path.Combine(BasePath, "products_for_distinct_search.json"); } diff --git a/tests/Meilisearch.Tests/Datasets/movies_with_vector_store.json b/tests/Meilisearch.Tests/Datasets/movies_with_vector_store.json new file mode 100644 index 00000000..26db300e --- /dev/null +++ b/tests/Meilisearch.Tests/Datasets/movies_with_vector_store.json @@ -0,0 +1,36 @@ +[ + { + "title": "Shazam!", + "release_year": 2019, + "id": "287947", + // Three semantic properties: + // 1. magic, anything that reminds you of magic + // 2. authority, anything that inspires command + // 3. horror, anything that inspires fear or dread + "_vectors": { "manual": [ 0.8, 0.4, -0.5 ] } + }, + { + "title": "Captain Marvel", + "release_year": 2019, + "id": "299537", + "_vectors": { "manual": [ 0.6, 0.8, -0.2 ] } + }, + { + "title": "Escape Room", + "release_year": 2019, + "id": "522681", + "_vectors": { "manual": [ 0.1, 0.6, 0.8 ] } + }, + { + "title": "How to Train Your Dragon: The Hidden World", + "release_year": 2019, + "id": "166428", + "_vectors": { "manual": [ 0.7, 0.7, -0.4 ] } + }, + { + "title": "All Quiet on the Western Front", + "release_year": 1930, + "id": "143", + "_vectors": { "manual": [ -0.5, 0.3, 0.85 ] } + } +] diff --git a/tests/Meilisearch.Tests/IndexFixture.cs b/tests/Meilisearch.Tests/IndexFixture.cs index 485a3d79..4ef0cbc4 100644 --- a/tests/Meilisearch.Tests/IndexFixture.cs +++ b/tests/Meilisearch.Tests/IndexFixture.cs @@ -173,6 +173,21 @@ public async Task SetUpIndexForRankingScoreThreshold(string indexUid) return index; } + public async Task SetUpIndexForSimilarDocumentsSearch(string indexUid) + { + var index = DefaultClient.Index(indexUid); + var movies = await JsonFileReader.ReadAsync>(Datasets.MoviesWithVectorStoreJsonPath); + var task = await index.AddDocumentsAsync(movies); + + var finishedTask = await index.WaitForTaskAsync(task.TaskUid); + if (finishedTask.Status != TaskInfoStatus.Succeeded) + { + throw new Exception("The documents were not added during SetUpIndexForSimilarDocumentsSearch. Impossible to run the tests."); + } + + return index; + } + public async Task DeleteAllIndexes() { var indexes = await DefaultClient.GetAllIndexesAsync(); diff --git a/tests/Meilisearch.Tests/MeilisearchClientTests.cs b/tests/Meilisearch.Tests/MeilisearchClientTests.cs index 21c4757b..f6dc6613 100644 --- a/tests/Meilisearch.Tests/MeilisearchClientTests.cs +++ b/tests/Meilisearch.Tests/MeilisearchClientTests.cs @@ -232,5 +232,39 @@ public void Deserialize_KnownTaskType_ReturnsEnumValue() Assert.Equal(TaskInfoType.IndexCreation, result); } + [Fact] + public async Task GetExperimentalFeatures() + { + await ResetExperimentalFeatures(); + + var features = await _defaultClient.GetExperimentalFeaturesAsync(); + + Assert.NotNull(features); + Assert.NotEmpty(features); + Assert.All(features, x => Assert.False(x.Value)); + } + + [Fact] + public async Task UpdateExperimentalFeatures() + { + await ResetExperimentalFeatures(); + + var currentFeatures = await _defaultClient.GetExperimentalFeaturesAsync(); + Assert.Contains(currentFeatures, x => x.Key == "vectorStore" && x.Value == false); + + var result = await _defaultClient.UpdateExperimentalFeatureAsync("vectorStore", true); + + Assert.Equal("vectorStore", result.Key); + Assert.True(result.Value); + + var updatedFeatures = await _defaultClient.GetExperimentalFeaturesAsync(); + Assert.Contains(updatedFeatures, x => x.Key == "vectorStore" && x.Value == true); + } + + private async Task ResetExperimentalFeatures() + { + foreach (var feature in await _defaultClient.GetExperimentalFeaturesAsync()) + await _defaultClient.UpdateExperimentalFeatureAsync(feature.Key, false); + } } } diff --git a/tests/Meilisearch.Tests/SearchSimilarDocumentsTests.cs b/tests/Meilisearch.Tests/SearchSimilarDocumentsTests.cs new file mode 100644 index 00000000..2ef7dc05 --- /dev/null +++ b/tests/Meilisearch.Tests/SearchSimilarDocumentsTests.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; + +using Xunit; + +namespace Meilisearch.Tests +{ + public abstract class SearchSimilarDocumentsTests : IAsyncLifetime where TFixture : IndexFixture + { + private readonly MeilisearchClient _client; + private Index _basicIndex; + + private readonly TFixture _fixture; + + public SearchSimilarDocumentsTests(TFixture fixture) + { + _fixture = fixture; + _client = fixture.DefaultClient; + } + + public async Task InitializeAsync() + { + await _fixture.DeleteAllIndexes(); + _basicIndex = await _fixture.SetUpIndexForSimilarDocumentsSearch("BasicIndexWithVectorStore-SearchTests"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task SearchSimilarDocuments() + { + await _client.UpdateExperimentalFeatureAsync("vectorStore", true); + + //TODO: add embedder + + var response = await _basicIndex.SearchSimilarDocuments("13"); + Assert.Single(response.Hits); + } + } +} diff --git a/tests/Meilisearch.Tests/ServerConfigs/BaseUriServer.cs b/tests/Meilisearch.Tests/ServerConfigs/BaseUriServer.cs index 1009590a..f0d5973c 100644 --- a/tests/Meilisearch.Tests/ServerConfigs/BaseUriServer.cs +++ b/tests/Meilisearch.Tests/ServerConfigs/BaseUriServer.cs @@ -54,6 +54,14 @@ public MeilisearchClientTests(ConfigFixture fixture) : base(fixture) } } + [Collection(CollectionFixtureName)] + public class SearchSimilarDocumentsTests : SearchSimilarDocumentsTests + { + public SearchSimilarDocumentsTests(ConfigFixture fixture) : base(fixture) + { + } + } + [Collection(CollectionFixtureName)] public class SearchTests : SearchTests {