diff --git a/dotnet/src/VectorData/AzureAISearch/AzureAISearchCollection.cs b/dotnet/src/VectorData/AzureAISearch/AzureAISearchCollection.cs index 6d6af2ad0f0c..7b841bf29f91 100644 --- a/dotnet/src/VectorData/AzureAISearch/AzureAISearchCollection.cs +++ b/dotnet/src/VectorData/AzureAISearch/AzureAISearchCollection.cs @@ -343,7 +343,7 @@ public override IAsyncEnumerable GetAsync(Expression pipeline = [searchQuery, projectionQuery]; + + // Add score threshold filter as a $match stage if specified + if (options.ScoreThreshold.HasValue) + { + pipeline.Add(CosmosMongoCollectionSearchMapping.GetScoreThresholdMatchQuery(ScorePropertyName, options.ScoreThreshold.Value)); + } const string OperationName = "Aggregate"; var cursor = await this.RunOperationAsync( diff --git a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoCollectionSearchMapping.cs b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoCollectionSearchMapping.cs index 8cd011218e09..53ef2fe29322 100644 --- a/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoCollectionSearchMapping.cs +++ b/dotnet/src/VectorData/CosmosMongoDB/CosmosMongoCollectionSearchMapping.cs @@ -166,4 +166,20 @@ public static BsonDocument GetProjectionQuery(string scorePropertyName, string d } }; } + + /// Returns a $match stage to filter results by score threshold. + /// + /// Cosmos MongoDB returns a similarity score where higher values mean more similar, + /// so we filter with $gte to keep results at or above the threshold. + /// + public static BsonDocument GetScoreThresholdMatchQuery(string scorePropertyName, double scoreThreshold) + => new() + { + { + "$match", new BsonDocument + { + { scorePropertyName, new BsonDocument { { "$gte", scoreThreshold } } } + } + } + }; } diff --git a/dotnet/src/VectorData/CosmosNoSql/CosmosNoSqlCollection.cs b/dotnet/src/VectorData/CosmosNoSql/CosmosNoSqlCollection.cs index 8a99611db3f9..34fd0d12b3e0 100644 --- a/dotnet/src/VectorData/CosmosNoSql/CosmosNoSqlCollection.cs +++ b/dotnet/src/VectorData/CosmosNoSql/CosmosNoSqlCollection.cs @@ -525,10 +525,12 @@ public override async IAsyncEnumerable> SearchAsync< null, this._model, vectorProperty.StorageName, + vectorProperty.DistanceFunction, null, ScorePropertyName, options.OldFilter, options.Filter, + options.ScoreThreshold, top, options.Skip, options.IncludeVectors); @@ -630,10 +632,12 @@ public async IAsyncEnumerable> HybridSearchAsync( ICollection? keywords, CollectionModel model, string vectorPropertyName, + string? distanceFunction, string? textPropertyName, string scorePropertyName, #pragma warning disable CS0618 // Type or member is obsolete VectorSearchFilter? oldFilter, #pragma warning restore CS0618 // Type or member is obsolete Expression>? filter, + double? scoreThreshold, int top, int skip, bool includeVectors) @@ -68,7 +70,7 @@ public static QueryDefinition BuildSearchQuery( #pragma warning disable CS0618 // VectorSearchFilter is obsolete // Build filter object. - var (whereClause, filterParameters) = (OldFilter: oldFilter, Filter: filter) switch + var (filterClause, filterParameters) = (OldFilter: oldFilter, Filter: filter) switch { { OldFilter: not null, Filter: not null } => throw new ArgumentException("Either Filter or OldFilter can be specified, but not both"), { OldFilter: VectorSearchFilter legacyFilter } => BuildSearchFilter(legacyFilter, model), @@ -82,6 +84,24 @@ public static QueryDefinition BuildSearchQuery( [VectorVariableName] = vector }; + // Add score threshold filter if specified. + // For similarity functions (CosineSimilarity, DotProductSimilarity), higher scores are better, so filter with >=. + // For distance functions (EuclideanDistance), lower scores are better, so filter with <=. + const string ScoreThresholdVariableName = "@scoreThreshold"; + string? scoreThresholdClause = null; + if (scoreThreshold.HasValue) + { + var comparisonOperator = distanceFunction switch + { + Microsoft.Extensions.VectorData.DistanceFunction.CosineSimilarity => ">=", + Microsoft.Extensions.VectorData.DistanceFunction.DotProductSimilarity => ">=", + Microsoft.Extensions.VectorData.DistanceFunction.EuclideanDistance => "<=", + _ => throw new NotSupportedException($"Score threshold is not supported for distance function '{distanceFunction}'.") + }; + scoreThresholdClause = $"{vectorDistanceArgument} {comparisonOperator} {ScoreThresholdVariableName}"; + queryParameters[ScoreThresholdVariableName] = scoreThreshold.Value; + } + // If Offset is not configured, use Top parameter instead of Limit/Offset // since it's more optimized. Hybrid search doesn't allow top to be passed as a parameter // so directly add it to the query here. @@ -92,9 +112,25 @@ public static QueryDefinition BuildSearchQuery( builder.AppendLine($"SELECT {topArgument}{selectClauseArguments}"); builder.AppendLine($"FROM {tableVariableName}"); - if (whereClause is not null) + if (filterClause is not null || scoreThresholdClause is not null) { - builder.Append("WHERE ").AppendLine(whereClause); + builder.Append("WHERE "); + + if (filterClause is not null) + { + builder.Append(filterClause); + if (scoreThresholdClause is not null) + { + builder.Append(AndConditionDelimiter); + } + } + + if (scoreThresholdClause is not null) + { + builder.Append(scoreThresholdClause); + } + + builder.AppendLine(); } builder.AppendLine($"ORDER BY {rankingArgument}"); diff --git a/dotnet/src/VectorData/InMemory/InMemoryCollection.cs b/dotnet/src/VectorData/InMemory/InMemoryCollection.cs index 5303d9abf66d..a9ea78f18dc6 100644 --- a/dotnet/src/VectorData/InMemory/InMemoryCollection.cs +++ b/dotnet/src/VectorData/InMemory/InMemoryCollection.cs @@ -339,6 +339,14 @@ _ when vectorProperty.EmbeddingGenerator is IEmbeddingGenerator x.HasValue).Select(x => x!.Value); + // Filter by score threshold if specified. + if (options.ScoreThreshold is double scoreThreshold) + { + nonNullResults = InMemoryCollectionSearchMapping.ShouldSortDescending(vectorProperty.DistanceFunction) + ? nonNullResults.Where(x => x.score >= scoreThreshold) + : nonNullResults.Where(x => x.score <= scoreThreshold); + } + // Sort the results appropriately for the selected distance function and get the right page of results . var sortedScoredResults = InMemoryCollectionSearchMapping.ShouldSortDescending(vectorProperty.DistanceFunction) ? nonNullResults.OrderByDescending(x => x.score) : diff --git a/dotnet/src/VectorData/MongoDB/MongoCollection.cs b/dotnet/src/VectorData/MongoDB/MongoCollection.cs index 443e114b02d0..2995f8acaf2d 100644 --- a/dotnet/src/VectorData/MongoDB/MongoCollection.cs +++ b/dotnet/src/VectorData/MongoDB/MongoCollection.cs @@ -415,7 +415,13 @@ public override async IAsyncEnumerable> SearchAsync< ScorePropertyName, DocumentPropertyName); - BsonDocument[] pipeline = [searchQuery, projectionQuery]; + List pipeline = [searchQuery, projectionQuery]; + + // Add score threshold filter as a $match stage if specified + if (options.ScoreThreshold.HasValue) + { + pipeline.Add(MongoCollectionSearchMapping.GetScoreThresholdMatchQuery(ScorePropertyName, options.ScoreThreshold.Value)); + } const string OperationName = "Aggregate"; using var cursor = await this.RunOperationWithRetryAsync( @@ -536,7 +542,7 @@ public async IAsyncEnumerable> HybridSearchAsync pipeline = [.. MongoCollectionSearchMapping.GetHybridSearchPipeline( vectorArray, keywords, this.Name, @@ -548,7 +554,13 @@ public async IAsyncEnumerable> HybridSearchAsync( int numCandidates, BsonDocument? filter) { + // Docs: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage var searchQuery = new BsonDocument { { "index", indexName }, @@ -127,6 +128,22 @@ public static BsonDocument GetProjectionQuery(string scorePropertyName, string d }; } + /// Returns a $match stage to filter results by score threshold. + /// + /// MongoDB Atlas Vector Search returns a similarity score where higher values mean more similar, + /// so we filter with $gte to keep results at or above the threshold. + /// + public static BsonDocument GetScoreThresholdMatchQuery(string scorePropertyName, double scoreThreshold) + => new() + { + { + "$match", new BsonDocument + { + { scorePropertyName, new BsonDocument { { "$gte", scoreThreshold } } } + } + } + }; + /// Returns a pipeline for hybrid search using vector search and full text search. public static BsonDocument[] GetHybridSearchPipeline( TVector vector, diff --git a/dotnet/src/VectorData/PgVector/PostgresCollection.cs b/dotnet/src/VectorData/PgVector/PostgresCollection.cs index 9473de31afd0..a0c2273fabc8 100644 --- a/dotnet/src/VectorData/PgVector/PostgresCollection.cs +++ b/dotnet/src/VectorData/PgVector/PostgresCollection.cs @@ -444,7 +444,7 @@ _ when vectorProperty.EmbeddingGenerator is IEmbeddingGenerator internal static void BuildGetNearestMatchCommand( NpgsqlCommand command, string schema, string tableName, CollectionModel model, VectorPropertyModel vectorProperty, object vectorValue, - VectorSearchFilter? legacyFilter, Expression>? newFilter, int? skip, bool includeVectors, int limit) + VectorSearchFilter? legacyFilter, Expression>? newFilter, int? skip, bool includeVectors, int limit, + double? scoreThreshold = null) { // Build column list with proper escaping StringBuilder columns = new(); @@ -501,6 +502,33 @@ internal static void BuildGetNearestMatchCommand( commandText = outerSql.ToString(); } + // Apply score threshold filter if specified. + // For similarity functions (higher = more similar), filter out results below the threshold. + // For distance functions (lower = more similar), filter out results above the threshold. + if (scoreThreshold.HasValue) + { + var scoreThresholdParamIndex = parameters.Count + 2; + var comparisonOp = distanceFunction switch + { + DistanceFunction.CosineSimilarity or DistanceFunction.DotProductSimilarity + => ">=", + + DistanceFunction.EuclideanDistance + or DistanceFunction.CosineDistance + or DistanceFunction.ManhattanDistance + or DistanceFunction.HammingDistance + => "<=", + + _ => throw new UnreachableException($"Unexpected distance function: {distanceFunction}") + }; + + StringBuilder outerSql = new(); + outerSql.Append("SELECT * FROM (").Append(commandText).Append(") AS scored WHERE ") + .AppendIdentifier(PostgresConstants.DistanceColumnName).Append(' ').Append(comparisonOp) + .Append(" $").Append(scoreThresholdParamIndex); + commandText = outerSql.ToString(); + } + command.CommandText = commandText; Debug.Assert(command.Parameters.Count == 0); @@ -510,6 +538,11 @@ internal static void BuildGetNearestMatchCommand( { command.Parameters.Add(new NpgsqlParameter { Value = parameter }); } + + if (scoreThreshold.HasValue) + { + command.Parameters.Add(new NpgsqlParameter { Value = scoreThreshold.Value }); + } } internal static void BuildSelectWhereCommand( diff --git a/dotnet/src/VectorData/Pinecone/PineconeCollection.cs b/dotnet/src/VectorData/Pinecone/PineconeCollection.cs index d545c6c2a887..84ed8e9d591a 100644 --- a/dotnet/src/VectorData/Pinecone/PineconeCollection.cs +++ b/dotnet/src/VectorData/Pinecone/PineconeCollection.cs @@ -432,6 +432,7 @@ public override async IAsyncEnumerable> SearchAsync< Verify.NotLessThan(top, 1); options ??= s_defaultVectorSearchOptions; + if (options.IncludeVectors && this._model.EmbeddingGenerationRequired) { throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); @@ -500,6 +501,12 @@ _ when vectorProperty.EmbeddingGenerator is IEmbeddingGenerator> SearchAsync< query: query, usingVector: this._hasNamedVectors ? vectorProperty.StorageName : null, filter: filter, + scoreThreshold: (float?)options.ScoreThreshold, limit: (ulong)top, offset: (ulong)options.Skip, vectorsSelector: vectorsSelector, @@ -740,8 +741,9 @@ public async IAsyncEnumerable> HybridSearchAsync this._qdrantClient.QueryAsync( this.Name, - prefetch: new List() { vectorQuery, keywordQuery }, + prefetch: [vectorQuery, keywordQuery], query: fusionQuery, + scoreThreshold: (float?)options.ScoreThreshold, limit: (ulong)top, offset: (ulong)options.Skip, vectorsSelector: vectorsSelector, diff --git a/dotnet/src/VectorData/Redis/RedisHashSetCollection.cs b/dotnet/src/VectorData/Redis/RedisHashSetCollection.cs index 7dc66f8880f7..b34e50234049 100644 --- a/dotnet/src/VectorData/Redis/RedisHashSetCollection.cs +++ b/dotnet/src/VectorData/Redis/RedisHashSetCollection.cs @@ -339,6 +339,7 @@ public override async IAsyncEnumerable> SearchAsync< Verify.NotLessThan(top, 1); options ??= s_defaultVectorSearchOptions; + if (options.IncludeVectors && this._model.EmbeddingGenerationRequired) { throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); @@ -404,6 +405,25 @@ _ when vectorProperty.EmbeddingGenerator is IEmbeddingGenerator result.Score.Value >= options.ScoreThreshold.Value, + DistanceFunction.CosineDistance or DistanceFunction.EuclideanSquaredDistance => result.Score.Value <= options.ScoreThreshold.Value, + _ => throw new InvalidOperationException($"Unexpected distance function: {distanceFunction}") + }; + + if (!passesThreshold) + { + continue; + } + } + yield return result; } } diff --git a/dotnet/src/VectorData/Redis/RedisJsonCollection.cs b/dotnet/src/VectorData/Redis/RedisJsonCollection.cs index ebed8c5bb6a5..80bf560e6563 100644 --- a/dotnet/src/VectorData/Redis/RedisJsonCollection.cs +++ b/dotnet/src/VectorData/Redis/RedisJsonCollection.cs @@ -439,6 +439,7 @@ public override async IAsyncEnumerable> SearchAsync< Verify.NotLessThan(top, 1); options ??= s_defaultVectorSearchOptions; + if (options.IncludeVectors && this._model.EmbeddingGenerationRequired) { throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); @@ -501,6 +502,25 @@ _ when vectorProperty.EmbeddingGenerator is IEmbeddingGenerator result.Score.Value >= options.ScoreThreshold.Value, + DistanceFunction.CosineDistance or DistanceFunction.EuclideanSquaredDistance => result.Score.Value <= options.ScoreThreshold.Value, + _ => throw new InvalidOperationException($"Unexpected distance function: {distanceFunction}") + }; + + if (!passesThreshold) + { + continue; + } + } + yield return result; } } diff --git a/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs b/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs index 139bcd91ed4c..573f5ec8485d 100644 --- a/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs +++ b/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs @@ -376,6 +376,7 @@ internal static SqlCommand SelectVector( command.Parameters.AddWithValue("@vector", vector); StringBuilder sb = new(200); + sb.Append("SELECT "); sb.AppendIdentifiers(model.Properties, includeVectors: options.IncludeVectors); sb.AppendLine(","); @@ -384,6 +385,7 @@ internal static SqlCommand SelectVector( sb.Append("FROM "); sb.AppendTableName(schema, tableName); sb.AppendLine(); + if (options.Filter is not null) { int startParamIndex = command.Parameters.Count; @@ -396,8 +398,23 @@ internal static SqlCommand SelectVector( { command.AddParameter(vectorProperty, $"@_{startParamIndex++}", parameter); } + sb.AppendLine(); } + + // If score threshold is specified, wrap in a subquery to filter on the pre-calculated score + // This avoids calculating VECTOR_DISTANCE() twice. + if (options.ScoreThreshold is not null) + { + // For SQL Server, all distance metrics return a distance (lower = more similar), so we filter with <=. + command.Parameters.AddWithValue("@scoreThreshold", options.ScoreThreshold!.Value); + + var innerQuery = sb.ToString(); + sb.Clear(); + sb.Append("SELECT * FROM (").Append(innerQuery).AppendLine(") AS [inner]"); + sb.AppendLine("WHERE [score] <= @scoreThreshold"); + } + sb.AppendFormat("ORDER BY [score] {0}", sorting); sb.AppendLine(); // Negative Skip and Top values are rejected by the VectorSearchOptions property setters. diff --git a/dotnet/src/VectorData/SqliteVec/SqliteCollection.cs b/dotnet/src/VectorData/SqliteVec/SqliteCollection.cs index a1b404d6ece2..0aaff587c04f 100644 --- a/dotnet/src/VectorData/SqliteVec/SqliteCollection.cs +++ b/dotnet/src/VectorData/SqliteVec/SqliteCollection.cs @@ -432,7 +432,8 @@ private async IAsyncEnumerable> EnumerateAndMapSearc conditions, includeDistance: true, extraWhereFilter: extraWhereFilter, - extraParameters: extraParameters); + extraParameters: extraParameters, + scoreThreshold: searchOptions.ScoreThreshold); using var reader = await connection.ExecuteWithErrorHandlingAsync( this._collectionMetadata, diff --git a/dotnet/src/VectorData/SqliteVec/SqliteCommandBuilder.cs b/dotnet/src/VectorData/SqliteVec/SqliteCommandBuilder.cs index 7cc94a90da64..2def8771c73f 100644 --- a/dotnet/src/VectorData/SqliteVec/SqliteCommandBuilder.cs +++ b/dotnet/src/VectorData/SqliteVec/SqliteCommandBuilder.cs @@ -299,7 +299,8 @@ public static DbCommand BuildSelectInnerJoinCommand( string? extraWhereFilter = null, Dictionary? extraParameters = null, int top = 0, - int skip = 0) + int skip = 0, + double? scoreThreshold = null) { const string SubqueryName = "subquery"; @@ -339,6 +340,19 @@ public static DbCommand BuildSelectInnerJoinCommand( builder.Append("INNER JOIN ").AppendIdentifier(dataTableName).Append(" ON ") .AppendIdentifier(vectorTableName).Append('.').AppendIdentifier(keyColumnName).Append(" = ") .AppendIdentifier(dataTableName).Append('.').AppendIdentifier(keyColumnName).AppendLine(); + + // SQLite vec only supports distance metrics (lower = more similar), so filter with <= + if (scoreThreshold.HasValue) + { + var scoreThresholdClause = new StringBuilder() + .AppendIdentifier(vectorTableName).Append('.').AppendIdentifier(DistancePropertyName).Append(" <= @scoreThreshold") + .ToString(); + whereClause = string.IsNullOrEmpty(whereClause) + ? scoreThresholdClause + : $"{whereClause} AND {scoreThresholdClause}"; + command.Parameters.Add(new SqliteParameter("@scoreThreshold", scoreThreshold.Value)); + } + builder.AppendWhereClause(whereClause); if (filterOptions is not null) diff --git a/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/HybridSearchOptions.cs b/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/HybridSearchOptions.cs index ec1bb646a42e..d63dce6867e1 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/HybridSearchOptions.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/HybridSearchOptions.cs @@ -67,4 +67,19 @@ public int Skip /// Gets or sets a value indicating whether to include vectors in the retrieval result. /// public bool IncludeVectors { get; set; } + + /// + /// Gets or sets the score threshold to filter results. + /// + /// + /// + /// The meaning of the score is a combination of the distance function configured for and the text + /// relevance score for the full-text search on . + /// + /// + /// The range of scores also depends on the distance function; for example, cosine similarity/distance scores + /// fall within 0 to 1, while Euclidean distance is unbounded. Scores can also differ between vector databases. + /// + /// + public double? ScoreThreshold { get; set; } } diff --git a/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/RecordSearchOptions.cs b/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/RecordSearchOptions.cs index 527c7a9c884d..23eea309be67 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/RecordSearchOptions.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/VectorSearch/RecordSearchOptions.cs @@ -56,4 +56,22 @@ public int Skip /// Gets or sets a value indicating whether to include vectors in the retrieval result. /// public bool IncludeVectors { get; set; } + + /// + /// Gets or sets the score threshold to filter results. + /// + /// + /// + /// The meaning of the score depends on the distance function configured for the vector property. + /// For similarity functions (e.g. , ), + /// higher scores indicate more similar results, and results with scores lower than the threshold will be filtered out. + /// For distance functions (e.g. , ), + /// lower scores indicate more similar results, and results with scores higher than the threshold will be filtered out. + /// + /// + /// The range of scores also depends on the distance function; for example, cosine similarity/distance scores + /// fall within 0 to 1, while Euclidean distance is unbounded. Scores can also differ between vector databases. + /// + /// + public double? ScoreThreshold { get; set; } } diff --git a/dotnet/src/VectorData/Weaviate/WeaviateCollection.cs b/dotnet/src/VectorData/Weaviate/WeaviateCollection.cs index de1d482633b5..542ad250fe16 100644 --- a/dotnet/src/VectorData/Weaviate/WeaviateCollection.cs +++ b/dotnet/src/VectorData/Weaviate/WeaviateCollection.cs @@ -434,6 +434,13 @@ public async IAsyncEnumerable> HybridSearchAsync( var vectorArray = JsonSerializer.Serialize(vector, jsonSerializerOptions); + // Weaviate nearVector supports distance parameter for thresholding. + // Distance works for all distance functions (lower values = more similar). + var distanceFilter = searchOptions.ScoreThreshold.HasValue + ? $"distance: {searchOptions.ScoreThreshold.Value}" + : string.Empty; + return $$""" { Get { @@ -53,6 +59,7 @@ public static string BuildSearchQuery( nearVector: { {{GetTargetVectorsQuery(hasNamedVectors, vectorPropertyName)}} vector: {{vectorArray}} + {{distanceFilter}} } ) { {{string.Join(" ", model.DataProperties.Select(p => p.StorageName))}} @@ -128,6 +135,7 @@ public static string BuildHybridSearchQuery( HybridSearchOptions searchOptions, bool hasNamedVectors) { + // https://docs.weaviate.io/weaviate/api/graphql/search-operators#hybrid var vectorsQuery = GetVectorsPropertyQuery(searchOptions.IncludeVectors, hasNamedVectors, model); #pragma warning disable CS0618 // VectorSearchFilter is obsolete diff --git a/dotnet/test/VectorData/CosmosNoSql.UnitTests/CosmosNoSqlCollectionQueryBuilderTests.cs b/dotnet/test/VectorData/CosmosNoSql.UnitTests/CosmosNoSqlCollectionQueryBuilderTests.cs index e374a1436b56..825e4015c192 100644 --- a/dotnet/test/VectorData/CosmosNoSql.UnitTests/CosmosNoSqlCollectionQueryBuilderTests.cs +++ b/dotnet/test/VectorData/CosmosNoSql.UnitTests/CosmosNoSqlCollectionQueryBuilderTests.cs @@ -48,10 +48,12 @@ public void BuildSearchQueryByDefaultReturnsValidQueryDefinition() keywords: null, this._model, vectorPropertyName, + distanceFunction: null, textPropertyName: null, ScorePropertyName, oldFilter: filter, filter: null, + scoreThreshold: null, 10, 5, includeVectors: true); @@ -93,10 +95,12 @@ public void BuildSearchQueryWithoutOffsetReturnsQueryDefinitionWithTopParameter( keywords: null, this._model, vectorPropertyName, + distanceFunction: null, textPropertyName: null, ScorePropertyName, oldFilter: filter, filter: null, + scoreThreshold: null, 10, 0, includeVectors: true); @@ -137,10 +141,12 @@ public void BuildSearchQueryWithInvalidFilterThrowsException() keywords: null, this._model, vectorPropertyName, + distanceFunction: null, textPropertyName: null, ScorePropertyName, oldFilter: filter, filter: null, + scoreThreshold: null, 10, 5, includeVectors: true)); @@ -159,10 +165,12 @@ public void BuildSearchQueryWithoutFilterDoesNotContainWhereClause() keywords: null, this._model, vectorPropertyName, + distanceFunction: null, textPropertyName: null, ScorePropertyName, oldFilter: null, filter: null, + scoreThreshold: null, 10, 5, includeVectors: true); @@ -244,10 +252,12 @@ public void BuildSearchQueryWithHybridFieldsReturnsValidHybridQueryDefinition() [keywordText], this._model, vectorPropertyName, + distanceFunction: null, textPropertyName, ScorePropertyName, oldFilter: filter, filter: null, + scoreThreshold: null, 10, 5, includeVectors: true); diff --git a/dotnet/test/VectorData/VectorData.ConformanceTests/DistanceFunctionTests.cs b/dotnet/test/VectorData/VectorData.ConformanceTests/DistanceFunctionTests.cs index ff755b62a130..0ca4fb12c9a9 100644 --- a/dotnet/test/VectorData/VectorData.ConformanceTests/DistanceFunctionTests.cs +++ b/dotnet/test/VectorData/VectorData.ConformanceTests/DistanceFunctionTests.cs @@ -57,7 +57,13 @@ protected virtual async Task Test( ReadOnlyMemory oppositeVector = new([-1, 0, 0, 0]); ReadOnlyMemory orthogonalVector = new([0f, -1f, -1f, 0f]); - double[] scoreDictionary = [expectedExactMatchScore, expectedOppositeScore, expectedOrthogonalScore]; + double[] scoreDictionary = + [ + expectedExactMatchScore, + expectedOppositeScore, + expectedOrthogonalScore + ]; + double[] expectedScores = [ scoreDictionary[resultOrder[0]], @@ -109,6 +115,49 @@ protected virtual async Task Test( Assert.Equal(Math.Round(expectedScores[i], 2), Math.Round(results[i].Score!.Value, 2)); } } + + await this.TestScoreThreshold(collection); + } + + protected virtual async Task TestScoreThreshold(VectorStoreCollection collection) + { + if (!fixture.TestStore.SupportsScoreThreshold) + { + await Assert.ThrowsAsync(async () => + { + await collection + .SearchAsync( + new ReadOnlyMemory([1, 0, 0, 0]), + top: 3, + new() { ScoreThreshold = 0.9 }) + .ToListAsync(); + }); + + return; + } + + // Fetch the top three records, then use the second's returned score as the threshold. + var results = await collection + .SearchAsync(new ReadOnlyMemory([1, 0, 0, 0]), top: 3) + .ToListAsync(); + + var threshold = results[1].Score; + + var filteredResults = await collection + .SearchAsync( + new ReadOnlyMemory([1, 0, 0, 0]), + top: 3, + new() { ScoreThreshold = threshold }) + .ToListAsync(); + + // Some providers use inclusive thresholds (>=), returning 2 results (first and second), + // while others use exclusive thresholds (>), returning only 1 result (first). + Assert.True(filteredResults.Count is 1 or 2); + Assert.Equal(results[0].Record.Key, filteredResults[0].Record.Key); + if (filteredResults.Count == 2) + { + Assert.Equal(results[1].Record.Key, filteredResults[1].Record.Key); + } } public abstract class Fixture : VectorStoreFixture diff --git a/dotnet/test/VectorData/VectorData.ConformanceTests/ModelTests/BasicModelTests.cs b/dotnet/test/VectorData/VectorData.ConformanceTests/ModelTests/BasicModelTests.cs index dec38aff8f1b..3acc39b0d780 100644 --- a/dotnet/test/VectorData/VectorData.ConformanceTests/ModelTests/BasicModelTests.cs +++ b/dotnet/test/VectorData/VectorData.ConformanceTests/ModelTests/BasicModelTests.cs @@ -452,6 +452,8 @@ public virtual async Task SearchAsync_with_Filter() fixture.TestData[1].AssertEqual(result.Record, includeVectors: false, fixture.TestStore.VectorsComparable); } + // For ScoreThreshold, see DistanceFunctionTests (to ensure we tests thresholds for each and every function) + #endregion Search protected VectorStoreCollection Collection => fixture.Collection; diff --git a/dotnet/test/VectorData/VectorData.ConformanceTests/Support/TestStore.cs b/dotnet/test/VectorData/VectorData.ConformanceTests/Support/TestStore.cs index e5d7aadfabca..f5a3924e464c 100644 --- a/dotnet/test/VectorData/VectorData.ConformanceTests/Support/TestStore.cs +++ b/dotnet/test/VectorData/VectorData.ConformanceTests/Support/TestStore.cs @@ -19,6 +19,12 @@ public abstract class TestStore /// returned cannot be compared with the original ones. /// public virtual bool VectorsComparable => true; + + /// + /// Whether the database supports filtering by score threshold in vector search. + /// + public virtual bool SupportsScoreThreshold => true; + public virtual string DefaultDistanceFunction => DistanceFunction.CosineSimilarity; public virtual string DefaultIndexKind => IndexKind.Flat; diff --git a/dotnet/test/VectorData/Weaviate.UnitTests/WeaviateQueryBuilderTests.cs b/dotnet/test/VectorData/Weaviate.UnitTests/WeaviateQueryBuilderTests.cs index a9aae4933abd..5f40bd91158c 100644 --- a/dotnet/test/VectorData/Weaviate.UnitTests/WeaviateQueryBuilderTests.cs +++ b/dotnet/test/VectorData/Weaviate.UnitTests/WeaviateQueryBuilderTests.cs @@ -64,6 +64,7 @@ public void BuildSearchQueryByDefaultReturnsValidQuery(bool hasNamedVectors) nearVector: { {{(hasNamedVectors ? "targetVectors: [\"descriptionEmbedding\"]" : string.Empty)}} vector: [31,32,33,34] + {{string.Empty}} } ) { HotelName HotelCode Tags