Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/BLite.Core/Collections/DocumentCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3522,8 +3522,17 @@ Func<T, bool> GetCompiled() =>
{
if (indexOpt.IsVectorSearch)
{
// The index satisfied the VectorSearch term; the post-filter checks the rest.
// Compiled lazily, like GetCompiled(), only when a candidate needs it.
bool exact = indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact;
Func<T, bool>? residual = null;
await foreach (var item in VectorSearchAsync(indexOpt.IndexName, indexOpt.VectorQuery!, indexOpt.K, ct: ct))
if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; }
{
if (!exact && !(residual ??= ((System.Linq.Expressions.Expression<Func<T, bool>>)Query.IndexOptimizer.WithoutCall(whereClause, indexOpt.VectorSearchCall!)).Compile())(item))
continue;
yield return item;
if (++yielded >= fetchLimit) yield break;
}
}
else if (indexOpt.IsSpatialSearch)
{
Expand Down
3 changes: 1 addition & 2 deletions src/BLite.Core/DynamicCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -957,8 +957,7 @@ public async IAsyncEnumerable<BsonDocument> NearAsync(string indexName, (double
if (!_secondaryIndexes.TryGetValue(indexName, out var entry) || entry.Kind != DynamicIndexKind.Spatial || entry.Spatial == null)
throw new ArgumentException($"Spatial index '{indexName}' not found on collection '{_collectionName}'");

var queryBox = SpatialMath.BoundingBox(center.Latitude, center.Longitude, radiusKm);
foreach (var loc in entry.Spatial.Search(queryBox, null))
foreach (var loc in entry.Spatial.SearchNear(center.Latitude, center.Longitude, radiusKm, null))
{
var doc = ReadDocumentAt(loc, 0UL);
if (doc != null) yield return doc;
Expand Down
6 changes: 1 addition & 5 deletions src/BLite.Core/Indexing/CollectionSecondaryIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,7 @@ public IEnumerable<DocumentLocation> Near((double Latitude, double Longitude) ce
if (_spatialIndex == null)
throw new InvalidOperationException("This index is not a spatial index.");

var queryBox = SpatialMath.BoundingBox(center.Latitude, center.Longitude, radiusKm);
foreach (var loc in _spatialIndex.Search(queryBox, transaction))
{
yield return loc;
}
return _spatialIndex.SearchNear(center.Latitude, center.Longitude, radiusKm, transaction);
}

/// <summary>
Expand Down
13 changes: 9 additions & 4 deletions src/BLite.Core/Indexing/GeoSpatialExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,32 @@ public static class GeoSpatialExtensions
{
/// <summary>
/// Performs a geospatial proximity search (Near) on a coordinate tuple property.
/// This method is a marker for the LINQ query provider and is optimized using R-Tree indexes if available.
/// The LINQ query provider serves it from an R-Tree index when one exists; otherwise it is
/// evaluated per document using the great-circle (Haversine) distance.
/// </summary>
/// <param name="point">The coordinate tuple (Latitude, Longitude) property of the entity.</param>
/// <param name="center">The center point (Latitude, Longitude) for the proximity search.</param>
/// <param name="radiusKm">The radius in kilometers.</param>
/// <returns>True if the point is within the specified radius.</returns>
public static bool Near(this (double Latitude, double Longitude) point, (double Latitude, double Longitude) center, double radiusKm)
{
return true;
return Indexing.SpatialMath.DistanceKm(point.Latitude, point.Longitude, center.Latitude, center.Longitude) <= radiusKm;
}

/// <summary>
/// Performs a geospatial bounding box search (Within) on a coordinate tuple property.
/// This method is a marker for the LINQ query provider and is optimized using R-Tree indexes if available.
/// The LINQ query provider serves it from an R-Tree index when one exists; otherwise it is
/// evaluated per document. The box includes its edges and does not wrap across the ±180°
/// meridian: a <paramref name="min"/> longitude greater than the <paramref name="max"/>
/// longitude matches nothing.
/// </summary>
/// <param name="point">The coordinate tuple (Latitude, Longitude) property of the entity.</param>
/// <param name="min">The minimum (Latitude, Longitude) of the bounding box.</param>
/// <param name="max">The maximum (Latitude, Longitude) of the bounding box.</param>
/// <returns>True if the point is within the specified bounding box.</returns>
public static bool Within(this (double Latitude, double Longitude) point, (double Latitude, double Longitude) min, (double Latitude, double Longitude) max)
{
return true;
return point.Latitude >= min.Latitude && point.Latitude <= max.Latitude &&
point.Longitude >= min.Longitude && point.Longitude <= max.Longitude;
}
}
27 changes: 26 additions & 1 deletion src/BLite.Core/Indexing/RTreeIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ private uint InitializeNewIndex()
}

public IEnumerable<DocumentLocation> Search(GeoBox area, ITransaction? transaction = null)
{
foreach (var (_, location) in SearchEntries(area, transaction))
yield return location;
}

/// <summary>
/// Locations of the indexed points within <paramref name="radiusKm"/> of the center. The tree
/// is searched with the circle's bounding box, then each hit is checked against the
/// great-circle distance, so points in the box corners are left out.
/// </summary>
public IEnumerable<DocumentLocation> SearchNear(double latitude, double longitude, double radiusKm, ITransaction? transaction = null)
{
var box = SpatialMath.BoundingBox(latitude, longitude, radiusKm);
foreach (var (mbr, location) in SearchEntries(box, transaction))
{
if (SpatialMath.DistanceKm(mbr.MinLat, mbr.MinLon, latitude, longitude) <= radiusKm)
yield return location;
}
}

/// <summary>
/// Like <see cref="Search"/>, but also yields each leaf entry's box, which for an indexed
/// point is the point itself.
/// </summary>
private IEnumerable<(GeoBox Mbr, DocumentLocation Location)> SearchEntries(GeoBox area, ITransaction? transaction = null)
{
var stack = new Stack<uint>();
stack.Push(_rootPageId);
Expand All @@ -73,7 +98,7 @@ public IEnumerable<DocumentLocation> Search(GeoBox area, ITransaction? transacti
{
if (isLeaf)
{
yield return pointer;
yield return (mbr, pointer);
}
else
{
Expand Down
19 changes: 15 additions & 4 deletions src/BLite.Core/Indexing/VectorSearchExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,34 @@ public static class VectorSearchExtensions
{
/// <summary>
/// Performs a similarity search on a vector property.
/// This method is a marker for the LINQ query provider and is optimized using HNSW indexes if available.
/// This method is a marker for the LINQ query provider, which serves it from the property's
/// HNSW index. It must appear on its own or as a term of a top-level <c>&amp;&amp;</c>.
/// </summary>
/// <param name="vector">The vector property of the entity.</param>
/// <param name="query">The query vector to compare against.</param>
/// <param name="k">Number of nearest neighbors to return.</param>
/// <returns>True if the document is part of the top-k results (always returns true when evaluated in memory for compilation purposes).</returns>
/// <returns>Never returns.</returns>
/// <exception cref="NotSupportedException">
/// Always: whether a document is among the k nearest depends on the whole collection, so the
/// call cannot be evaluated one document at a time.
/// </exception>
public static bool VectorSearch(this float[] vector, float[] query, int k)
{
return true;
throw NotEvaluable();
}

/// <summary>
/// Performs a similarity search on a collection of vector properties.
/// Used for entities with multiple vectors per document.
/// </summary>
/// <exception cref="NotSupportedException">Always; see <see cref="VectorSearch(float[], float[], int)"/>.</exception>
public static bool VectorSearch(this IEnumerable<float[]> vectors, float[] query, int k)
{
return true;
throw NotEvaluable();
}

private static NotSupportedException NotEvaluable() => new(
"VectorSearch selects the k nearest neighbours of the whole collection and cannot be evaluated per document. " +
"Use it on a property with a vector index, either on its own or combined with other conditions using &&; " +
"a query can contain only one VectorSearch.");
}
50 changes: 50 additions & 0 deletions src/BLite.Core/Query/BTreeQueryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,12 @@ private async Task<TResult> ExecuteAsyncCore<TResult>(Expression expression, Can
// pipeline cannot model. We still benefit from index / BSON-scan fetch,
// then let EnumerableRewriter translate the remaining Queryable calls to
// Enumerable equivalents and compile the residual expression once.
// FetchAsync applied the WHERE, so the replay drops the root-chain Where calls folded into
// it. Re-evaluating them would change nothing except for VectorSearch, which only the index
// can evaluate. Every other VectorSearch in the expression is still evaluated and throws.
if (whereAlreadyApplied)
expression = DropAppliedWheres(expression).Expression;

if (model.HasComplexOperators)
return ExecuteViaEnumerableRewriter<TResult>(expression, sourceData);

Expand Down Expand Up @@ -636,6 +642,50 @@ private static bool AggregateValues<TResult>(

// ─── Expression-tree helpers ──────────────────────────────────────────────

/// <summary>
/// Removes the <c>Queryable.Where(x =&gt; ...)</c> calls on the root chain whose input is the
/// fetched documents or a subset of them (only filtering, ordering and paging operators lie
/// between the root and the Where). <see cref="BTreeExpressionVisitor"/> folds each of these
/// into <see cref="QueryModel.WhereClause"/>, which FetchAsync applied, so every element
/// reaching them already satisfies their predicate and dropping them leaves the result unchanged.
/// </summary>
/// <returns>
/// The rewritten expression, and whether its elements are still a subset of the fetched documents.
/// </returns>
private static (Expression Expression, bool IsFetchedSubset) DropAppliedWheres(Expression expression)
{
if (expression is ConstantExpression { Value: IQueryable })
return (expression, true);

if (expression is not MethodCallExpression call
|| call.Method.DeclaringType != typeof(Queryable)
|| call.Arguments.Count == 0)
return (expression, false);

var (source, isFetchedSubset) = DropAppliedWheres(call.Arguments[0]);

if (isFetchedSubset
&& call.Method.Name == nameof(Queryable.Where)
&& call.Arguments[1] is UnaryExpression { Operand: LambdaExpression { Parameters.Count: 1 } })
return (source, true);

var rewritten = source == call.Arguments[0]
? call
: call.Update(call.Object, [source, .. call.Arguments.Skip(1)]);
return (rewritten, isFetchedSubset && s_subsetPreservingOperators.Contains(call.Method.Name));
}

/// <summary>Queryable operators whose output elements are a subset of their source's.</summary>
private static readonly HashSet<string> s_subsetPreservingOperators =
[
nameof(Queryable.Where),
nameof(Queryable.OrderBy), nameof(Queryable.OrderByDescending),
nameof(Queryable.ThenBy), nameof(Queryable.ThenByDescending),
nameof(Queryable.Skip), nameof(Queryable.Take),
nameof(Queryable.SkipWhile), nameof(Queryable.TakeWhile),
nameof(Queryable.Distinct), nameof(Queryable.Reverse),
];

/// <summary>
/// Fallback for queries containing operators the direct pipeline cannot handle
/// (GroupBy, Join, Sum/Average/Min/Max with selectors, etc.).
Expand Down
46 changes: 44 additions & 2 deletions src/BLite.Core/Query/IndexOptimizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ public class OptimizationResult
public float[]? VectorQuery { get; set; }
public int K { get; set; }

/// <summary>
/// The <c>VectorSearch</c> call a vector plan serves from the index. It cannot be
/// evaluated per document, so a post-filter must treat it as satisfied; see
/// <see cref="WithoutCall"/>.
/// </summary>
public MethodCallExpression? VectorSearchCall { get; set; }

public bool IsSpatialSearch { get; set; }
public (double Latitude, double Longitude) SpatialPoint { get; set; }
public double RadiusKm { get; set; }
Expand Down Expand Up @@ -161,7 +168,17 @@ public enum FilterCompleteness
var left = OptimizeExpression(binary.Left, parameter, indexes, registry);
var right = OptimizeExpression(binary.Right, parameter, indexes, registry);

if (left != null && right != null && left.IndexName == right.IndexName)
// A vector term must drive the plan: the post-filter cannot evaluate it per document.
if (left?.IsVectorSearch == true || right?.IsVectorSearch == true)
{
var vector = left?.IsVectorSearch == true ? left : right!;
vector.FilterCompleteness = FilterCompleteness.PartialAnd;
vector.HasResiduePredicate = true;
return vector;
}

if (left != null && right != null && left.IndexName == right.IndexName
&& !left.IsSpatialSearch && !right.IsSpatialSearch)
{
// Both sides are covered by the same index — combine into a single range.
// Propagate the stricter completeness: if either branch required a post-filter
Expand Down Expand Up @@ -200,6 +217,8 @@ public enum FilterCompleteness

if (left != null && right != null &&
left.IndexName == right.IndexName &&
!left.IsSpatialSearch && !right.IsSpatialSearch &&
!left.IsVectorSearch && !right.IsVectorSearch &&
left.FilterCompleteness == FilterCompleteness.Exact &&
right.FilterCompleteness == FilterCompleteness.Exact &&
TryGetPointValues(left, out var leftValues) &&
Expand Down Expand Up @@ -381,7 +400,8 @@ notExpr.Operand is MemberExpression notMember &&
IndexName = index.Name,
IsVectorSearch = true,
VectorQuery = query,
K = k
K = k,
VectorSearchCall = mcall
};
}
}
Expand Down Expand Up @@ -432,6 +452,28 @@ notExpr.Operand is MemberExpression notMember &&
return null;
}

/// <summary>
/// Returns <paramref name="predicate"/> with <paramref name="call"/> replaced by <c>true</c>,
/// for post-filtering the candidates of an index that already satisfied that call.
/// </summary>
public static LambdaExpression WithoutCall(LambdaExpression predicate, Expression call)
=> Expression.Lambda(predicate.Type, new ReplaceNodeVisitor(call, Expression.Constant(true)).Visit(predicate.Body)!, predicate.Parameters);

private sealed class ReplaceNodeVisitor : ExpressionVisitor
{
private readonly Expression _target;
private readonly Expression _replacement;

public ReplaceNodeVisitor(Expression target, Expression replacement)
{
_target = target;
_replacement = replacement;
}

public override Expression? Visit(Expression? node)
=> ReferenceEquals(node, _target) ? _replacement : base.Visit(node);
}

private static bool TryGetPointValues(OptimizationResult result, out IReadOnlyList<object?> values)
{
if (result.InValues != null)
Expand Down
Loading
Loading