Repository navigation
fix(query): evaluate Near/Within for real and refine indexed Near to the circle (#165) - #173
Conversation
…the circle (EntglDb#165) The Near and Within LINQ markers returned true when evaluated in memory, so every document matched inside an ||, under a negation, or on any other path that falls back to the compiled predicate. Indexed Near returned the whole bounding box of the circle, including points up to sqrt(2) x radius away. - Near computes the great-circle distance and Within tests the box, matching BLQL's $geoNear and $geoWithin. - RTreeIndex.SearchNear checks each bounding-box hit against the exact distance, using the indexed point, so indexed Near stays an exact plan. Typed and dynamic NearAsync use it. - VectorSearch throws NotSupportedException when evaluated per document: top-k membership depends on the whole collection. The planner lets a vector term drive any && plan and post-filters with the VectorSearch call replaced by true, so `VectorSearch(...) && x` keeps working, while `VectorSearch(...) || x` now fails loudly instead of matching everything. - Two spatial terms on the same index are no longer merged into a B-Tree range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. The in-memory Near/Within match BLQL ($geoNear uses the same Haversine and <=, GeoWithinFilter and GeoBox.Intersects are inclusive), SearchNear on the leaf box is sound since every insert path stores GeoBox.FromPoint, WithoutCall finds its node (same lambda instance through TryOptimize and FetchAsync), and Count/Any/First go through the same plan. 518/518 targeted tests pass. One regression to fix and one pre-existing hole that this PR's description claims to cover.
1. Regression: Where(x => x.Emb.VectorSearch(q, k)).GroupBy(...) now throws NotSupportedException (BTreeQueryProvider.cs:302-322, :652-670).
FetchAsync serves the WHERE from the HNSW index, but when model.HasComplexOperators is set (GroupBy, Join, Sum/Min/Max with selector) or OrderBy follows a Select, ExecuteViaEnumerableRewriter rewrites the whole original expression, including the Where, to Enumerable.* and re-runs it over the fetched list. Before, VectorSearch returned true so the re-applied WHERE was a no-op; now it throws from VectorSearchExtensions.cs:20. Verified with a probe test: passes at 45dbc8c, fails at 224b332. Fix: when the WHERE was already applied, drop it or replace its VectorSearch nodes with true before the rewrite (the plan's VectorSearchCall is available from FetchAsync). Please add tests for Where(VectorSearch).GroupBy(...) and Where(VectorSearch).Sum(selector).
2. Near(a) || Near(b) on the same spatial index still returns nothing (IndexOptimizer.cs:201-236, :475-490).
A spatial/vector OptimizationResult has IsRange = false and null MinValue/MaxValue, so TryGetPointValues yields [null] and the OrElse branch merges the two terms into an InValues = [null] B-Tree probe on the R-Tree index name, which returns 0 rows. Pre-existing, but the PR fixes the && analogue (:180-181) and says Near inside || is now correct. Add !IsSpatialSearch && !IsVectorSearch to the OrElse merge guard (or make TryGetPointValues fail on a null MinValue) so it falls through to the in-memory path.
3. Breaking change. The old XML doc promised VectorSearch "always returns true when evaluated in memory"; FindAll().Where(...), AsEnumerable().Where(...) and FindAsync on a collection without a vector index now throw. I agree the throw is the right design, but it should ship in a minor (5.3.0) with a CHANGELOG "breaking" entry, not a patch.
Smaller: v1.VectorSearch(..) && v2.VectorSearch(..) throws with a message telling the user to combine with &&, which they did (IndexOptimizer.cs:171-178), detect two vector plans and give a clearer message. About 460 of the 508 diff lines in IndexOptimizer.cs are CRLF normalisation; please keep the file's line endings or do that in a separate commit so blame stays readable. The residual predicate is compiled per FetchAsync call (DocumentCollection.cs:3525-3528), unlike the cached GetCompiled().
Within with min.Longitude > max.Longitude: matching nothing on both paths is consistent; let's document that Within does not cross the antimeridian and leave it there.
Posted from the wrong account by mistake; the maintainer review from @mrdevrobot supersedes this.
- The in-memory replay of a query whose WHERE FetchAsync already applied (GroupBy, Join, aggregates with a selector, OrderBy after Select) treats VectorSearch as satisfied: the index served it. Before, the replay re-evaluated it and threw. - The OrElse merge no longer turns two spatial or vector terms on one index into an IN probe on that index, so Near(a) || Near(b) is evaluated in memory and returns documents near either point. - The VectorSearch error says a query can contain only one VectorSearch. - The vector residual predicate is compiled lazily, like GetCompiled(). - Within documents that the box does not wrap across the antimeridian. - IndexOptimizer.cs keeps its original mixed line endings; only the real changes remain in its diff. BREAKING CHANGE: VectorSearch throws NotSupportedException when it is evaluated per document (outside an index plan, inside ||, or on a property without a vector index) instead of returning true for every document. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, good catch on the replay. Addressed in 97bfb7d; the branch also merges 1. 2. 3. Breaking change. Agreed. The commit carries a Smaller:
Full suite on macOS: 2406 passed, 0 failed, 4 skipped. #174 is updated on top of this. 🤖 Generated with Claude Code |
mrdevrobot
left a comment
There was a problem hiding this comment.
Points 2, 3 and 4 are fixed, and the line-ending churn is gone. Point 1 needs another pass: the fix trades the exception for silently wrong results.
BTreeQueryProvider.cs:314-317 replaces every VectorSearch in the whole expression with true whenever model.WhereClause != null. That condition doesn't check whether the WHERE contained a VectorSearch, and the rewrite isn't limited to the WHERE that FetchAsync applied. Probes, with 3 documents A = [0,0,0], A = [1,1,1], B = [50,50,50]:
| query | result now | correct |
|---|---|---|
Where(VS(q,2)).Select(x => new { x.Title, ok = x.Emb.VectorSearch(q2,1) }).OrderBy(a => a.Title) |
ok = true for both A |
throw |
Where(Title != null).Select(x => new { ok = VS(q,1) }).OrderBy(...) |
ok = true for all 3 |
throw |
Where(Title != null).GroupBy(Title).Select(g => g.Count(x => VS(q,1))) |
A = 2, B = 1 | throw |
Where(Title != null).Sum(x => VS(q,1) ? 1 : 0) |
3 | throw |
Where(Title != null).Count(x => VS(q,1)) |
3 | throw |
All five threw NotSupportedException before this commit, which is the behaviour we want. Please neutralise VectorSearch only inside the Queryable.Where predicates on the root chain that were folded into model.WhereClause. Simpler still, drop those Where nodes from the replay, since FetchAsync already applied them. Every other VectorSearch should keep throwing. Please add the probes above as regression tests.
Related but older, not for this PR: BTreeExpressionVisitor.VisitWhere (~L57-88) merges any later Where into WhereClause, including one on a Join inner sequence. Where(Title == "B").Join(VectorItems.Where(y => VS(q,1)), ...) returns [] instead of ["B/A"] both before and after. Worth an issue.
On release: agreed on the BREAKING CHANGE: footer. versionize would make the next auto release 6.0.0, so we'll release with --release-as 5.3.0 as you suggest.
…ed (EntglDb#165) - The in-memory replay drops the root-chain Where calls folded into the WHERE that FetchAsync applied, as long as only filtering, ordering and paging operators lie between them and the root. It no longer replaces every VectorSearch in the expression with true, so a VectorSearch in a projection, group, aggregate selector or Count predicate throws again. - Regression tests for the five probes from the review, and for a VectorSearch Where followed by Select and OrderBy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Point 1, second pass, is in db38b78.
Join inner Release: noted, |
mrdevrobot
left a comment
There was a problem hiding this comment.
Verified db38b78. All five probes from the last review now throw NotSupportedException, and Where(VectorSearch).GroupBy(...) / .Sum(selector) still use the index result. DropAppliedWheres only drops single-parameter Where calls on the root chain below subset-preserving operators, which are exactly the ones folded into the fetch filter, so it can't let unfiltered rows through. The new tests fail with the fix reverted. Full suite on main with #169/#170 merged: 2434 / 0 / 4 skipped.
Noted, not blocking: a VectorSearch Where after a non-subset-preserving operator, e.g. Select(x => x).Where(VS), now throws instead of using the index. That's the safe direction.
While probing I hit the older VisitWhere folding on main that #186 describes: Where(p1).Take(2).Where(p2) returns rows LINQ-to-Objects doesn't, because the second Where is applied before the paging. Not caused by this PR.
Approving and merging. The squash keeps your BREAKING CHANGE: footer; the release will go out as 5.3.0.
…glDb#180) With EntglDb#173 on main, Where(x => x.Title != null).Count(x => x.Embedding.VectorSearch(Q, 1)) has its predicate folded into the WHERE, so the index serves the VectorSearch instead of the replay throwing. - Rename VectorSearch_InCountPredicate_AfterOtherWhere_Throws to VectorSearch_InCountPredicate_AfterOtherWhere_IsFoldedIntoTheWhere and assert the same count as Where(x => x.Title != null && x.Embedding.VectorSearch(Q, 1)).Count(). - VectorSearch_InSumSelector_AfterOtherWhere_Throws (not folded) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ites, single-pass B-Tree insert (#179) (#180) * perf: index-aware LINQ terminals with predicates, single-copy page writes, single-pass B-Tree insert (#179) - Count/LongCount and First/FirstOrDefault/Single/SingleOrDefault/Any with a predicate fold it into the WHERE clause when no Select/Take/Skip comes before, so the index planner sees it. With only a WHERE in front, element terminals fetch one document (two for Single*) instead of loading every match and running EnumerableRewriter. - Transaction.AddWrite passes the page straight to StorageEngine.WritePage, which already copies it, instead of allocating a 16 KB defensive copy per write. - Unique B-Tree inserts check for duplicates on the leaf they already found instead of a separate TryFind traversal, and reuse that leaf buffer. TryFind uses a pooled buffer instead of a zeroed 16 KB stackalloc, and leaf inserts shift entries with Span.CopyTo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(query): fold terminal predicates only after Where/OrderBy/ThenBy (#180) Address review of #180: - Count/LongCount/First*/Single*/Any fold their predicate into the WHERE clause only when every operator between the collection root and the terminal is a single-parameter Where or OrderBy*/ThenBy*, and the predicate takes the collection's entity type. Fixes the InvalidCastException after GroupBy/OfType/Cast/SelectMany/Join/Zip and the wrong results after SkipWhile/TakeWhile (and the indexed Where overload). - Regression tests for GroupBy (Count/Any/First), SkipWhile, TakeWhile, OfType/Cast, the fold allowlist, and Count(p => p.V.VectorSearch(q, k)). - Transaction.AddWrite XML doc no longer claims a defensive copy. - AllocateOverflowPage passes the pooled buffer to AddWrite without a redundant ToArray(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(query): expect Count(VectorSearch) after Where to be folded (#180) With #173 on main, Where(x => x.Title != null).Count(x => x.Embedding.VectorSearch(Q, 1)) has its predicate folded into the WHERE, so the index serves the VectorSearch instead of the replay throwing. - Rename VectorSearch_InCountPredicate_AfterOtherWhere_Throws to VectorSearch_InCountPredicate_AfterOtherWhere_IsFoldedIntoTheWhere and assert the same count as Where(x => x.Title != null && x.Embedding.VectorSearch(Q, 1)).Count(). - VectorSearch_InSumSelector_AfterOtherWhere_Throws (not folded) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(query): don't replay a terminal predicate folded into the WHERE (#180) OrderBy(x => x.Title).First(x => x.Embedding.VectorSearch(Q, 1)) folded the predicate into the WHERE, but the OrderBy sent the element terminal through the fallback, whose replay evaluated First(p) again and threw for VectorSearch. The same happened for Any, and for Count/LongCount after ThenBy (which the direct pipeline does not model). - QueryModel.FoldedTerminal records the terminal whose predicate was folded; the replay swaps it for its parameterless overload (First(p) -> First()) next to DropAppliedWheres. FetchAsync already applied p, and folding requires only Where/OrderBy/ThenBy in front. - Last/LastOrDefault fold their predicate too (Last(p) is Where(p).Last()); they always use the fallback since they need every match. - All(p) is still never folded and still evaluated in the replay, as are predicates after operators that block folding (SkipWhile, Take, GroupBy, ...). - Tests: OrderBy/ThenBy + First/FirstOrDefault/Last/LastOrDefault/Single/SingleOrDefault/ Any/Count/LongCount with VectorSearch against the equivalent Where(...).OrderBy(...) query, the same terminals with a plain predicate against LINQ-to-Objects, and unfolded shapes (All, SkipWhile, Take, GroupBy) that still throw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #165.
Problem
GeoSpatialExtensions.NearandWithinreturnedtruewhen evaluated in memory, which was only correct while the planner turned them into an R-Tree search. Inside an||, under a negation, or on any other path that falls back to the compiled predicate, every document matched. IndexedNearsearched the circle's bounding box and never checked the distance, so it also returned points up to √2 × radius away. BLQL's$geoNearalready computed the real distance, so LINQ and BLQL disagreed on the same data.VectorSearchalso returnedtrue, soVectorSearch(...) || xmatched every document.Fix
Near/WithinNearcomputes the great-circle distance withSpatialMath.DistanceKm;Withintests the box with its edges included. Both matchGeoNearFilter/GeoWithinFilterin BLQL and the R-Tree's inclusiveIntersects.RTreeIndex.SearchNearsearches the bounding box, then checks each hit against the exact distance. It uses the leaf entry's box, which for an indexed point is the point itself, so no document has to be loaded and the indexedNearplan staysExact.CollectionSecondaryIndex.Near(typedNearAsyncand LINQ) andDynamicCollection.NearAsyncuse it.VectorSearchNotSupportedExceptionwhen evaluated per document. Whether a document is among the k nearest depends on the whole collection, so there is no correct per-row answer.VectorSearch(...) && xworking, the planner lets a vector term drive any&&plan. Before,xcould win when it had its own index, and the post-filter then evaluatedVectorSearchastrue. The post-filter for a vector plan is compiled with theVectorSearchcall replaced bytrue(IndexOptimizer.WithoutCall), since the index already satisfied it.VectorSearch(...) || xcannot be served and now throws instead of returning everything.Planner
Near(...) && Within(...)) were merged into a B-Tree range on the spatial index. One drives the plan now, and the other is applied as a post-filter.Tests
NearWithinCorrectnessTestsadds 11 tests. 10 fail onmain:Nearuses the great-circle distance,Withinincludes the edges,VectorSearchthrows.Near || xandWithin || xon an indexed property, and indexedNearexcluding a box corner via LINQ,NearAsyncandCount.!Near(...), which runs as a full scan.Near && Withinon the same index returns their intersection.VectorSearch || xthrows.DynamicCollection.NearAsyncexcludes the box corner.The 11th,
Title == "B" && VectorSearch(...), guards the new post-filter. It passes onmainbecauseVectorSearchreturnedtruethere.The existing spatial, vector, optimizer and BLQL tests (508) still pass. Full suite on macOS: 2390 passed, 3 failed, 4 skipped. The 3 failures are the known
MultiProcessWalSharedMemoryTests.wal-shmlock timeouts.Breaking change (release as 5.3.0)
Queries that evaluated
VectorSearchoutside an index plan used to return every document; they now throwNotSupportedException. This includes||, a negation, a property without a vector index, andFindAll().Where(...)/AsEnumerable().Where(...)in memory. The old XML doc promised it "always returns true when evaluated in memory".Suggested squash-message footer:
Not in scope
Nearacross the ±180° meridian and over the poles (Nearmisses points across the ±180° meridian and over the poles #166). The exact-distance check here only trims what the bounding box returns.$nearVectorstill matches every document and is not routed to the HNSW index.Merges cleanly with #169, #170 and #172.
🤖 Generated with Claude Code