From 68ba57589f5565b1c485bf0b7eaff4693d7267d3 Mon Sep 17 00:00:00 2001 From: Philip Proplesch Date: Fri, 28 Mar 2025 16:45:42 -0400 Subject: [PATCH 1/5] Add support for embedders --- .env | 2 +- .../Converters/EmbedderSourceConverter.cs | 51 +++++++++++ src/Meilisearch/Embedder.cs | 84 +++++++++++++++++++ src/Meilisearch/EmbedderDistribution.cs | 48 +++++++++++ src/Meilisearch/EmbedderSource.cs | 43 ++++++++++ src/Meilisearch/Index.Embedders.cs | 63 ++++++++++++++ src/Meilisearch/Meilisearch.csproj | 18 ++++ src/Meilisearch/Settings.cs | 6 ++ tests/Meilisearch.Tests/SettingsTests.cs | 57 ++++++++++++- 9 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 src/Meilisearch/Converters/EmbedderSourceConverter.cs create mode 100644 src/Meilisearch/Embedder.cs create mode 100644 src/Meilisearch/EmbedderDistribution.cs create mode 100644 src/Meilisearch/EmbedderSource.cs create mode 100644 src/Meilisearch/Index.Embedders.cs diff --git a/.env b/.env index 767307bf..5429cf3a 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -MEILISEARCH_VERSION=v1.9.0 +MEILISEARCH_VERSION=v1.13 PROXIED_MEILISEARCH=http://nginx/api/ MEILISEARCH_URL=http://meilisearch:7700 diff --git a/src/Meilisearch/Converters/EmbedderSourceConverter.cs b/src/Meilisearch/Converters/EmbedderSourceConverter.cs new file mode 100644 index 00000000..2ae54604 --- /dev/null +++ b/src/Meilisearch/Converters/EmbedderSourceConverter.cs @@ -0,0 +1,51 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Meilisearch.Converters +{ + internal class EmbedderSourceConverter : JsonConverter + { + public override EmbedderSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var enumValue = reader.GetString(); + if (Enum.TryParse(enumValue, true, out var embedderSource)) + { + return embedderSource; + } + } + + return EmbedderSource.Unknown; + } + + public override void Write(Utf8JsonWriter writer, EmbedderSource value, JsonSerializerOptions options) + { + string source; + switch (value) + { + case EmbedderSource.OpenAi: + source = "openAi"; + break; + case EmbedderSource.HuggingFace: + source = "huggingFace"; + break; + case EmbedderSource.Ollama: + source = "ollama"; + break; + case EmbedderSource.Rest: + source = "rest"; + break; + case EmbedderSource.UserProvided: + source = "userProvided"; + break; + case EmbedderSource.Unknown: + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + + writer.WriteStringValue(source); + } + } +} diff --git a/src/Meilisearch/Embedder.cs b/src/Meilisearch/Embedder.cs new file mode 100644 index 00000000..2f528f19 --- /dev/null +++ b/src/Meilisearch/Embedder.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + /// + /// Embedder configuration. + /// + public class Embedder + { + /// + /// Gets or sets the source. + /// + [JsonPropertyName("source")] + public EmbedderSource Source { get; set; } + + /// + /// Gets or sets the URL. + /// + [JsonPropertyName("url")] + public string Url { get; set; } + + /// + /// Gets or sets the API key. + /// + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } + + /// + /// Gets or sets the model. + /// + [JsonPropertyName("model")] + public string Model { get; set; } + + /// + /// Gets or sets the document template. + /// + [JsonPropertyName("documentTemplate")] + public string DocumentTemplate { get; set; } + + /// + /// Gets or sets the document template max bytes. + /// + [JsonPropertyName("documentTemplateMaxBytes")] + public int? DocumentTemplateMaxBytes { get; set; } + + /// + /// Gets or sets the dimensions. + /// + [JsonPropertyName("dimensions")] + public int? Dimensions { get; set; } + + /// + /// Gets or sets the revision. + /// + [JsonPropertyName("revision")] + public string Revision { get; set; } + + /// + /// Gets or sets the distribution. + /// + [JsonPropertyName("distribution")] + public EmbedderDistribution Distribution { get; set; } + + /// + /// Gets or sets the request. + /// + [JsonPropertyName("request")] + public Dictionary Request { get; set; } + + /// + /// Gets or sets the response. + /// + [JsonPropertyName("response")] + public Dictionary Response { get; set; } + + /// + /// Gets or sets whether the vectors should be compressed. + /// + [JsonPropertyName("binaryQuantized")] + public bool? BinaryQuantized { get; set; } + } +} diff --git a/src/Meilisearch/EmbedderDistribution.cs b/src/Meilisearch/EmbedderDistribution.cs new file mode 100644 index 00000000..d60443e7 --- /dev/null +++ b/src/Meilisearch/EmbedderDistribution.cs @@ -0,0 +1,48 @@ +using System; +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + /// + /// Embedder distribution. + /// + public class EmbedderDistribution + { + /// + /// Creates a new instance of . + /// + /// Mean value between 0 and 1. + /// Sigma value between 0 and 1. + /// + public EmbedderDistribution(double mean, double sigma) + { + if (mean < 0 || mean > 1) + { + throw new ArgumentOutOfRangeException(nameof(mean), "Mean must be between 0 and 1."); + } + + if (sigma < 0 || sigma > 1) + { + throw new ArgumentOutOfRangeException(nameof(sigma), "Sigma must be between 0 and 1."); + } + } + + /// + /// Gets or sets the mean. + /// + [JsonPropertyName("mean")] + public double Mean { get; set; } + + /// + /// Gets or sets the sigma. + /// + [JsonPropertyName("sigma")] + public double Sigma { get; set; } + + /// + /// Creates a new instance of with a uniform distribution. + /// + /// + public static EmbedderDistribution Uniform() => new EmbedderDistribution(0.5, 0.5); + } +} diff --git a/src/Meilisearch/EmbedderSource.cs b/src/Meilisearch/EmbedderSource.cs new file mode 100644 index 00000000..26ba99f3 --- /dev/null +++ b/src/Meilisearch/EmbedderSource.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +using Meilisearch.Converters; + +namespace Meilisearch +{ + /// + /// Embedder source. + /// + [JsonConverter(typeof(EmbedderSourceConverter))] + public enum EmbedderSource + { + /// + /// OpenAI + /// + OpenAi, + + /// + /// Hugging Face + /// + HuggingFace, + + /// + /// Ollama + /// + Ollama, + + /// + /// REST + /// + Rest, + + /// + /// User-provided + /// + UserProvided, + + /// + /// Unknown + /// + Unknown + } +} diff --git a/src/Meilisearch/Index.Embedders.cs b/src/Meilisearch/Index.Embedders.cs new file mode 100644 index 00000000..c050eeb6 --- /dev/null +++ b/src/Meilisearch/Index.Embedders.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +using Meilisearch.Extensions; + +namespace Meilisearch +{ + public partial class Index + { + /// + /// Gets the embedders setting. + /// + /// The cancellation token for this call. + /// Returns the embedders setting. + public async Task> GetEmbeddersAsync(CancellationToken cancellationToken = default) + { + return await _http + .GetFromJsonAsync>( + $"indexes/{Uid}/settings/embedders", + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Updates the embedders setting. + /// + /// Collection of embedders. + /// The cancellation token for this call. + /// Returns the task info of the asynchronous task. + public async Task UpdateEmbeddersAsync(Dictionary embedders, CancellationToken cancellationToken = default) + { + var responseMessage = + await _http.PatchAsJsonAsync( + $"indexes/{Uid}/settings/embedders", + embedders, + Constants.JsonSerializerOptionsRemoveNulls, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return await responseMessage.Content + .ReadFromJsonAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Resets the embedders setting. + /// + /// The cancellation token for this call. + /// Returns the task info of the asynchronous task. + public async Task ResetEmbeddersAsync(CancellationToken cancellationToken = default) + { + var response = await _http + .DeleteAsync($"indexes/{Uid}/settings/embedders", cancellationToken) + .ConfigureAwait(false); + + return await response.Content + .ReadFromJsonAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/src/Meilisearch/Meilisearch.csproj b/src/Meilisearch/Meilisearch.csproj index e13645d9..e31c58bc 100644 --- a/src/Meilisearch/Meilisearch.csproj +++ b/src/Meilisearch/Meilisearch.csproj @@ -68,5 +68,23 @@ Index.cs + + Index.cs + + + Index.cs + + + Index.cs + + + Index.cs + + + Index.cs + + + Index.cs + diff --git a/src/Meilisearch/Settings.cs b/src/Meilisearch/Settings.cs index a926cc88..195af5f8 100644 --- a/src/Meilisearch/Settings.cs +++ b/src/Meilisearch/Settings.cs @@ -103,5 +103,11 @@ public class Settings /// [JsonPropertyName("searchCutoffMs")] public int? SearchCutoffMs { get; set; } + + /// + /// Gets or sets the embeddings attribute. + /// + [JsonPropertyName("embedders")] + public Dictionary Embedders { get; set; } } } diff --git a/tests/Meilisearch.Tests/SettingsTests.cs b/tests/Meilisearch.Tests/SettingsTests.cs index 395bc54a..87d1b4ca 100644 --- a/tests/Meilisearch.Tests/SettingsTests.cs +++ b/tests/Meilisearch.Tests/SettingsTests.cs @@ -63,7 +63,8 @@ public SettingsTests(TFixture fixture) Pagination = new Pagination { MaxTotalHits = 1000 - } + }, + Embedders = new Dictionary() }; } @@ -663,6 +664,57 @@ public async Task ResetSearchCutoffMsAsync() await AssertGetEquality(_index.GetSearchCutoffMsAsync, _defaultSettings.SearchCutoffMs); } + [Fact] + public async Task GetEmbeddersAsync() + { + await AssertGetEquality(_index.GetEmbeddersAsync, _defaultSettings.Embedders); + } + + [Fact] + public async Task UpdateEmbeddersAsync() + { + var newEmbedders = new Dictionary + { + { + "default", + new Embedder + { + Source = EmbedderSource.HuggingFace, + Model = "BAAI/bge-base-en-v1.5", + DocumentTemplate = "A movie titled '{{doc.name}}' with the following genre {{doc.genre}}", + DocumentTemplateMaxBytes = 400 + } + } + }; + + await AssertUpdateSuccess(_index.UpdateEmbeddersAsync, newEmbedders); + await AssertGetEquality(_index.GetEmbeddersAsync, newEmbedders); + } + + [Fact] + public async Task ResetEmbeddersAsync() + { + var newEmbedders = new Dictionary + { + { + "default", + new Embedder + { + Source = EmbedderSource.HuggingFace, + Model = "BAAI/bge-base-en-v1.5", + DocumentTemplate = "A movie titled '{{doc.name}}' with the following genre {{doc.genre}}", + DocumentTemplateMaxBytes = 400 + } + } + }; + + await AssertUpdateSuccess(_index.UpdateEmbeddersAsync, newEmbedders); + await AssertGetEquality(_index.GetEmbeddersAsync, newEmbedders); + + await AssertResetSuccess(_index.ResetEmbeddersAsync); + await AssertGetEquality(_index.GetEmbeddersAsync, _defaultSettings.Embedders); + } + private static Settings SettingsWithDefaultedNullFields(Settings inputSettings, Settings defaultSettings) { return new Settings @@ -682,7 +734,8 @@ private static Settings SettingsWithDefaultedNullFields(Settings inputSettings, Pagination = inputSettings.Pagination ?? defaultSettings.Pagination, ProximityPrecision = inputSettings.ProximityPrecision ?? defaultSettings.ProximityPrecision, Dictionary = inputSettings.Dictionary ?? defaultSettings.Dictionary, - SearchCutoffMs = inputSettings.SearchCutoffMs ?? defaultSettings.SearchCutoffMs + SearchCutoffMs = inputSettings.SearchCutoffMs ?? defaultSettings.SearchCutoffMs, + Embedders = inputSettings.Embedders ?? defaultSettings.Embedders }; } From 4f8d55b1df03b39d2a15c9dd1e95a232865e428a Mon Sep 17 00:00:00 2001 From: Philip Proplesch Date: Fri, 28 Mar 2025 19:28:16 -0400 Subject: [PATCH 2/5] Add support for vector search --- src/Meilisearch/HybridSearch.cs | 19 +++++++++++ src/Meilisearch/SearchQuery.cs | 21 ++++++++++-- tests/Meilisearch.Tests/Datasets.cs | 1 + .../Datasets/movies_for_vector.json | 32 ++++++++++++++++++ tests/Meilisearch.Tests/IndexFixture.cs | 33 +++++++++++++++++++ tests/Meilisearch.Tests/Models/VectorMovie.cs | 20 +++++++++++ tests/Meilisearch.Tests/SearchTests.cs | 23 +++++++++++++ 7 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/Meilisearch/HybridSearch.cs create mode 100644 tests/Meilisearch.Tests/Datasets/movies_for_vector.json create mode 100644 tests/Meilisearch.Tests/Models/VectorMovie.cs diff --git a/src/Meilisearch/HybridSearch.cs b/src/Meilisearch/HybridSearch.cs new file mode 100644 index 00000000..fcb7ecfb --- /dev/null +++ b/src/Meilisearch/HybridSearch.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + public class HybridSearch + { + /// + /// Gets or sets the embedder. + /// + [JsonPropertyName("embedder")] + public string Embedder { get; set; } + + /// + /// Gets or sets the semantic ratio. + /// + [JsonPropertyName("semanticRatio")] + public double SemanticRatio { get; set; } + } +} diff --git a/src/Meilisearch/SearchQuery.cs b/src/Meilisearch/SearchQuery.cs index fcfcbf19..1069b687 100644 --- a/src/Meilisearch/SearchQuery.cs +++ b/src/Meilisearch/SearchQuery.cs @@ -124,7 +124,6 @@ public class SearchQuery [JsonPropertyName("limit")] public int? Limit { get; set; } - /// /// Gets or sets hitsPerPage. /// @@ -144,9 +143,27 @@ public class SearchQuery public string Distinct { get; set; } /// - /// Gets or sets rankingScoreThreshold, a number between 0.0 and 1.0. + /// Gets or sets rankingScoreThreshold, a number between 0.0 and 1.0. /// [JsonPropertyName("rankingScoreThreshold")] public decimal? RankingScoreThreshold { get; set; } + + /// + /// Gets or sets the hybrid search settings. + /// + [JsonPropertyName("hybrid")] + public HybridSearch Hybrid { get; set; } + + /// + /// Gets or sets the vector. + /// + [JsonPropertyName("vector")] + public double[] Vector { get; set; } + + /// + /// Gets or sets whether to retrieve vectors. + /// + [JsonPropertyName("retrieveVectors")] + public bool RetrieveVectors { get; set; } } } diff --git a/tests/Meilisearch.Tests/Datasets.cs b/tests/Meilisearch.Tests/Datasets.cs index 610454a2..bf54faaa 100644 --- a/tests/Meilisearch.Tests/Datasets.cs +++ b/tests/Meilisearch.Tests/Datasets.cs @@ -15,6 +15,7 @@ internal static class Datasets public static readonly string MoviesWithStringIdJsonPath = Path.Combine(BasePath, "movies_with_string_id.json"); public static readonly string MoviesForFacetingJsonPath = Path.Combine(BasePath, "movies_for_faceting.json"); + public static readonly string MoviesForVectorJsonPath = Path.Combine(BasePath, "movies_for_vector.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"); diff --git a/tests/Meilisearch.Tests/Datasets/movies_for_vector.json b/tests/Meilisearch.Tests/Datasets/movies_for_vector.json new file mode 100644 index 00000000..71087bef --- /dev/null +++ b/tests/Meilisearch.Tests/Datasets/movies_for_vector.json @@ -0,0 +1,32 @@ +[ + { + "title": "Shazam!", + "release_year": 2019, + "id": "287947", + "_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..c31acbb9 100644 --- a/tests/Meilisearch.Tests/IndexFixture.cs +++ b/tests/Meilisearch.Tests/IndexFixture.cs @@ -4,6 +4,8 @@ using System.Text.Json; using System.Threading.Tasks; +using Meilisearch.Tests.Models; + using Xunit; namespace Meilisearch.Tests @@ -109,6 +111,37 @@ public async Task SetUpIndexForFaceting(string indexUid) return index; } + public async Task SetUpIndexForVectorSearch(string indexUid) + { + var index = DefaultClient.Index(indexUid); + + var task = await index.UpdateEmbeddersAsync(new Dictionary + { + { "manual", new Embedder { Source = EmbedderSource.UserProvided, Dimensions = 3 } } + }); + + var finishedTask = await index.WaitForTaskAsync(task.TaskUid); + if (finishedTask.Status != TaskInfoStatus.Succeeded) + { + throw new Exception($"The documents were not added during SetUpIndexForVectorSearch.\n" + + $"Impossible to run the tests.\n" + + $"{JsonSerializer.Serialize(finishedTask.Error)}"); + } + + var products = await JsonFileReader.ReadAsync>(Datasets.MoviesForVectorJsonPath); + task = await index.AddDocumentsAsync(products, primaryKey: "id"); + + finishedTask = await index.WaitForTaskAsync(task.TaskUid); + if (finishedTask.Status != TaskInfoStatus.Succeeded) + { + throw new Exception($"The documents were not added during SetUpIndexForVectorSearch.\n" + + $"Impossible to run the tests.\n" + + $"{JsonSerializer.Serialize(finishedTask.Error)}"); + } + + return index; + } + public async Task SetUpIndexForNestedSearch(string indexUid) { var index = DefaultClient.Index(indexUid); diff --git a/tests/Meilisearch.Tests/Models/VectorMovie.cs b/tests/Meilisearch.Tests/Models/VectorMovie.cs new file mode 100644 index 00000000..7db1d09f --- /dev/null +++ b/tests/Meilisearch.Tests/Models/VectorMovie.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Meilisearch.Tests.Models +{ + public class VectorMovie + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("release_year")] + public int ReleaseYear { get; set; } + + [JsonPropertyName("_vectors")] + public Dictionary Vectors { get; set; } + } +} diff --git a/tests/Meilisearch.Tests/SearchTests.cs b/tests/Meilisearch.Tests/SearchTests.cs index 328b2143..be56e8a4 100644 --- a/tests/Meilisearch.Tests/SearchTests.cs +++ b/tests/Meilisearch.Tests/SearchTests.cs @@ -3,6 +3,8 @@ using FluentAssertions; +using Meilisearch.Tests.Models; + using Xunit; namespace Meilisearch.Tests @@ -12,6 +14,7 @@ public abstract class SearchTests : IAsyncLifetime where TFixture : In private Index _basicIndex; private Index _nestedIndex; private Index _indexForFaceting; + private Index _indexForVectorSearch; private Index _indexWithIntId; private Index _productIndexForDistinct; private Index _indexForRankingScoreThreshold; @@ -28,6 +31,7 @@ public async Task InitializeAsync() await _fixture.DeleteAllIndexes(); // Test context cleaned for each [Fact] _basicIndex = await _fixture.SetUpBasicIndex("BasicIndex-SearchTests"); _indexForFaceting = await _fixture.SetUpIndexForFaceting("IndexForFaceting-SearchTests"); + _indexForVectorSearch = await _fixture.SetUpIndexForVectorSearch("IndexForVector-SearchTests"); _indexWithIntId = await _fixture.SetUpBasicIndexWithIntId("IndexWithIntId-SearchTests"); _nestedIndex = await _fixture.SetUpIndexForNestedSearch("IndexForNestedDocs-SearchTests"); _productIndexForDistinct = await _fixture.SetUpIndexForDistinctProductsSearch("IndexForDistinctProducts-SearchTests"); @@ -554,5 +558,24 @@ public async Task CustomSearchWithRankingScoreThreshold() movies.Hits.First().Id.Should().Be("13"); movies.Hits.First().Name.Should().Be("Harry Potter"); } + + [Fact] + public async Task CustomSearchWithVector() + { + var searchQuery = new SearchQuery + { + Hybrid = new HybridSearch + { + Embedder = "manual", + SemanticRatio = 1.0f + }, + Vector = new[] { 0.1, 0.6, 0.8 }, + }; + + var movies = await _indexForVectorSearch.SearchAsync(string.Empty, searchQuery); + + Assert.Equal("522681", movies.Hits.First().Id); + Assert.Equal("Escape Room", movies.Hits.First().Title); + } } } From bac47c41cf6385a1256925e7e9243ae1380ef52e Mon Sep 17 00:00:00 2001 From: Philip Proplesch Date: Fri, 28 Mar 2025 23:26:43 -0400 Subject: [PATCH 3/5] Implement similar document search --- src/Meilisearch/Index.SimilarDocuments.cs | 33 +++++++++ src/Meilisearch/Meilisearch.csproj | 3 + src/Meilisearch/SimilarDocumentsQuery.cs | 85 +++++++++++++++++++++++ src/Meilisearch/SimilarDocumentsResult.cs | 72 +++++++++++++++++++ tests/Meilisearch.Tests/SearchTests.cs | 18 +++++ 5 files changed, 211 insertions(+) create mode 100644 src/Meilisearch/Index.SimilarDocuments.cs create mode 100644 src/Meilisearch/SimilarDocumentsQuery.cs create mode 100644 src/Meilisearch/SimilarDocumentsResult.cs diff --git a/src/Meilisearch/Index.SimilarDocuments.cs b/src/Meilisearch/Index.SimilarDocuments.cs new file mode 100644 index 00000000..7fcbb020 --- /dev/null +++ b/src/Meilisearch/Index.SimilarDocuments.cs @@ -0,0 +1,33 @@ +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Meilisearch +{ + public partial class Index + { + /// + /// Search for similar documents. + /// + /// The query to search for similar documents. + /// The cancellation token for this call. + /// The type of the documents to return. + /// Returns the similar documents. + public async Task> SearchSimilarDocumentsAsync( + SimilarDocumentsQuery query, + CancellationToken cancellationToken = default) + { + var responseMessage = await _http + .PostAsJsonAsync( + $"indexes/{Uid}/similar", + query, + Constants.JsonSerializerOptionsRemoveNulls, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return await responseMessage.Content + .ReadFromJsonAsync>(cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/src/Meilisearch/Meilisearch.csproj b/src/Meilisearch/Meilisearch.csproj index e31c58bc..8bd70ff3 100644 --- a/src/Meilisearch/Meilisearch.csproj +++ b/src/Meilisearch/Meilisearch.csproj @@ -86,5 +86,8 @@ Index.cs + + Index.cs + diff --git a/src/Meilisearch/SimilarDocumentsQuery.cs b/src/Meilisearch/SimilarDocumentsQuery.cs new file mode 100644 index 00000000..a9969bcb --- /dev/null +++ b/src/Meilisearch/SimilarDocumentsQuery.cs @@ -0,0 +1,85 @@ +using System; +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + /// + /// Search query for similar documents. + /// + public class SimilarDocumentsQuery + { + /// + /// Creates a new instance of the class. + /// + /// + public SimilarDocumentsQuery(string id) + { + if (string.IsNullOrEmpty(id)) + { + throw new ArgumentNullException(nameof(id)); + } + + Id = id; + } + + /// + /// Gets the document id. + /// + [JsonPropertyName("id")] + public string Id { get; } + + /// + /// Gets or sets the embedder. + /// + [JsonPropertyName("embedder")] + public string Embedder { get; set; } + + /// + /// Gets or sets the attributes to retrieve. + /// + [JsonPropertyName("attributesToRetrieve")] + public string[] AttributesToRetrieve { get; set; } = { "*" }; + + /// + /// Gets or sets the offset. + /// + [JsonPropertyName("offset")] + public int Offset { get; set; } = 0; + + /// + /// Gets or sets the limit. + /// + [JsonPropertyName("limit")] + public int Limit { get; set; } = 20; + + /// + /// Gets or sets the filter. + /// + [JsonPropertyName("filter")] + public string Filter { get; set; } + + /// + /// Gets or sets whether to show the ranking score. + /// + [JsonPropertyName("showRankingScore")] + public bool ShowRankingScore { get; set; } + + /// + /// Gets or sets whether to show the ranking score details. + /// + [JsonPropertyName("showRankingScoreDetails")] + public bool ShowRankingScoreDetails { get; set; } + + /// + /// Gets or sets the ranking score threshold. + /// + [JsonPropertyName("rankingScoreThreshold")] + public int? RankingScoreThreshold { get; set; } + + /// + /// Gets or sets whether to retrieve the vectors. + /// + [JsonPropertyName("retrieveVectors")] + public bool RetrieveVectors { get; set; } + } +} diff --git a/src/Meilisearch/SimilarDocumentsResult.cs b/src/Meilisearch/SimilarDocumentsResult.cs new file mode 100644 index 00000000..504630de --- /dev/null +++ b/src/Meilisearch/SimilarDocumentsResult.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Meilisearch +{ + /// + /// Search result for similar documents. + /// + public class SimilarDocumentsResult + { + /// + /// Creates a new instance of the class. + /// + /// + /// + /// + /// + /// + /// + public SimilarDocumentsResult( + IReadOnlyCollection hits, + string id, + int processingTimeMs, + int offset, + int limit, + int estimatedTotalHits) + { + Hits = hits; + Id = id; + ProcessingTimeMs = processingTimeMs; + Offset = offset; + Limit = limit; + EstimatedTotalHits = estimatedTotalHits; + } + + /// + /// Gets the hits. + /// + [JsonPropertyName("hits")] + public IReadOnlyCollection Hits { get; } + + /// + /// Gets or sets the id. + /// + [JsonPropertyName("id")] + public string Id { get; } + + /// + /// Gets or sets the processing time in milliseconds. + /// + [JsonPropertyName("processingTimeMs")] + public int ProcessingTimeMs { get; } + + /// + /// Gets or sets the offset. + /// + [JsonPropertyName("offset")] + public int Offset { get; } + + /// + /// Gets or sets the limit. + /// + [JsonPropertyName("limit")] + public int Limit { get; } + + /// + /// Gets or sets the estimated total hits. + /// + [JsonPropertyName("estimatedTotalHits")] + public int EstimatedTotalHits { get; } + } +} diff --git a/tests/Meilisearch.Tests/SearchTests.cs b/tests/Meilisearch.Tests/SearchTests.cs index be56e8a4..a769fac1 100644 --- a/tests/Meilisearch.Tests/SearchTests.cs +++ b/tests/Meilisearch.Tests/SearchTests.cs @@ -577,5 +577,23 @@ public async Task CustomSearchWithVector() Assert.Equal("522681", movies.Hits.First().Id); Assert.Equal("Escape Room", movies.Hits.First().Title); } + + [Fact] + public async Task CustomSearchWithSimilarDocuments() + { + var query = new SimilarDocumentsQuery("143") + { + Embedder = "manual" + }; + + var movies = await _indexForVectorSearch.SearchSimilarDocumentsAsync(query); + + Assert.Collection(movies.Hits, + m => Assert.Equal("Escape Room", m.Title), + m => Assert.Equal("Captain Marvel", m.Title), + m => Assert.Equal("How to Train Your Dragon: The Hidden World", m.Title), + m => Assert.Equal("Shazam!", m.Title) + ); + } } } From e1ba0525c9dc36740afdb1f81b983eedbd9772b3 Mon Sep 17 00:00:00 2001 From: Philip Proplesch Date: Wed, 13 Aug 2025 22:20:35 -0400 Subject: [PATCH 4/5] Address review feedback --- .../Converters/EmbedderSourceConverter.cs | 5 ++- src/Meilisearch/Embedder.cs | 1 - src/Meilisearch/EmbedderDistribution.cs | 43 +++++++++++++------ src/Meilisearch/EmbedderSource.cs | 5 --- src/Meilisearch/SimilarDocumentsQuery.cs | 4 +- src/Meilisearch/SimilarDocumentsResult.cs | 10 ++--- .../Meilisearch.Tests.csproj | 32 +++++++------- 7 files changed, 58 insertions(+), 42 deletions(-) diff --git a/src/Meilisearch/Converters/EmbedderSourceConverter.cs b/src/Meilisearch/Converters/EmbedderSourceConverter.cs index 2ae54604..4d6e004e 100644 --- a/src/Meilisearch/Converters/EmbedderSourceConverter.cs +++ b/src/Meilisearch/Converters/EmbedderSourceConverter.cs @@ -15,9 +15,11 @@ public override EmbedderSource Read(ref Utf8JsonReader reader, Type typeToConver { return embedderSource; } + + throw new JsonException($"Invalid EmbedderSource value: '{enumValue}'."); } - return EmbedderSource.Unknown; + throw new JsonException($"Expected string for EmbedderSource, but found {reader.TokenType}."); } public override void Write(Utf8JsonWriter writer, EmbedderSource value, JsonSerializerOptions options) @@ -40,7 +42,6 @@ public override void Write(Utf8JsonWriter writer, EmbedderSource value, JsonSeri case EmbedderSource.UserProvided: source = "userProvided"; break; - case EmbedderSource.Unknown: default: throw new ArgumentOutOfRangeException(nameof(value), value, null); } diff --git a/src/Meilisearch/Embedder.cs b/src/Meilisearch/Embedder.cs index 2f528f19..d35b34a9 100644 --- a/src/Meilisearch/Embedder.cs +++ b/src/Meilisearch/Embedder.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/src/Meilisearch/EmbedderDistribution.cs b/src/Meilisearch/EmbedderDistribution.cs index d60443e7..b4df8c3e 100644 --- a/src/Meilisearch/EmbedderDistribution.cs +++ b/src/Meilisearch/EmbedderDistribution.cs @@ -8,36 +8,55 @@ namespace Meilisearch /// public class EmbedderDistribution { + private double _mean; + private double _sigma; + /// /// Creates a new instance of . /// /// Mean value between 0 and 1. /// Sigma value between 0 and 1. - /// public EmbedderDistribution(double mean, double sigma) { - if (mean < 0 || mean > 1) - { - throw new ArgumentOutOfRangeException(nameof(mean), "Mean must be between 0 and 1."); - } - - if (sigma < 0 || sigma > 1) - { - throw new ArgumentOutOfRangeException(nameof(sigma), "Sigma must be between 0 and 1."); - } + Mean = mean; + Sigma = sigma; } /// /// Gets or sets the mean. /// [JsonPropertyName("mean")] - public double Mean { get; set; } + public double Mean + { + get => _mean; + set + { + if (value < 0 || value > 1) + { + throw new ArgumentOutOfRangeException(nameof(Mean), "Mean must be between 0 and 1."); + } + + _mean = value; + } + } /// /// Gets or sets the sigma. /// [JsonPropertyName("sigma")] - public double Sigma { get; set; } + public double Sigma + { + get => _sigma; + set + { + if (value < 0 || value > 1) + { + throw new ArgumentOutOfRangeException(nameof(Sigma), "Sigma must be between 0 and 1."); + } + + _sigma = value; + } + } /// /// Creates a new instance of with a uniform distribution. diff --git a/src/Meilisearch/EmbedderSource.cs b/src/Meilisearch/EmbedderSource.cs index 26ba99f3..14b835ca 100644 --- a/src/Meilisearch/EmbedderSource.cs +++ b/src/Meilisearch/EmbedderSource.cs @@ -34,10 +34,5 @@ public enum EmbedderSource /// User-provided /// UserProvided, - - /// - /// Unknown - /// - Unknown } } diff --git a/src/Meilisearch/SimilarDocumentsQuery.cs b/src/Meilisearch/SimilarDocumentsQuery.cs index a9969bcb..53671679 100644 --- a/src/Meilisearch/SimilarDocumentsQuery.cs +++ b/src/Meilisearch/SimilarDocumentsQuery.cs @@ -38,7 +38,7 @@ public SimilarDocumentsQuery(string id) /// Gets or sets the attributes to retrieve. /// [JsonPropertyName("attributesToRetrieve")] - public string[] AttributesToRetrieve { get; set; } = { "*" }; + public string[] AttributesToRetrieve { get; set; } = new[] { "*" }; /// /// Gets or sets the offset. @@ -74,7 +74,7 @@ public SimilarDocumentsQuery(string id) /// Gets or sets the ranking score threshold. /// [JsonPropertyName("rankingScoreThreshold")] - public int? RankingScoreThreshold { get; set; } + public decimal? RankingScoreThreshold { get; set; } /// /// Gets or sets whether to retrieve the vectors. diff --git a/src/Meilisearch/SimilarDocumentsResult.cs b/src/Meilisearch/SimilarDocumentsResult.cs index 504630de..908fc449 100644 --- a/src/Meilisearch/SimilarDocumentsResult.cs +++ b/src/Meilisearch/SimilarDocumentsResult.cs @@ -40,31 +40,31 @@ public SimilarDocumentsResult( public IReadOnlyCollection Hits { get; } /// - /// Gets or sets the id. + /// Gets the id. /// [JsonPropertyName("id")] public string Id { get; } /// - /// Gets or sets the processing time in milliseconds. + /// Gets the processing time in milliseconds. /// [JsonPropertyName("processingTimeMs")] public int ProcessingTimeMs { get; } /// - /// Gets or sets the offset. + /// Gets the offset. /// [JsonPropertyName("offset")] public int Offset { get; } /// - /// Gets or sets the limit. + /// Gets the limit. /// [JsonPropertyName("limit")] public int Limit { get; } /// - /// Gets or sets the estimated total hits. + /// Gets the estimated total hits. /// [JsonPropertyName("estimatedTotalHits")] public int EstimatedTotalHits { get; } diff --git a/tests/Meilisearch.Tests/Meilisearch.Tests.csproj b/tests/Meilisearch.Tests/Meilisearch.Tests.csproj index d3672a1f..f84b7db6 100644 --- a/tests/Meilisearch.Tests/Meilisearch.Tests.csproj +++ b/tests/Meilisearch.Tests/Meilisearch.Tests.csproj @@ -6,33 +6,35 @@ - 1701;1702;CA1861 + 1701;1702;CA1861 - 1701;1702;CA1861 + 1701;1702;CA1861 - - - - + + + + - + - - - - - - - - + + + + + + + + + + From 256de5f92ea93fe1fa9b4766c6c2f56642c1d6e2 Mon Sep 17 00:00:00 2001 From: Philip Proplesch Date: Thu, 4 Sep 2025 22:07:30 -0400 Subject: [PATCH 5/5] Use `dynamic` for filter in similar document query --- src/Meilisearch/SimilarDocumentsQuery.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Meilisearch/SimilarDocumentsQuery.cs b/src/Meilisearch/SimilarDocumentsQuery.cs index 53671679..abf78bcc 100644 --- a/src/Meilisearch/SimilarDocumentsQuery.cs +++ b/src/Meilisearch/SimilarDocumentsQuery.cs @@ -56,7 +56,7 @@ public SimilarDocumentsQuery(string id) /// Gets or sets the filter. /// [JsonPropertyName("filter")] - public string Filter { get; set; } + public dynamic Filter { get; set; } /// /// Gets or sets whether to show the ranking score.