Skip to content

Spatial fields and geo queries on S2 cells - #3170

Open
meyburgh wants to merge 98 commits into
quickwit-oss:mainfrom
meyburgh:geo-fixes
Open

meyburgh wants to merge 98 commits into
quickwit-oss:mainfrom
meyburgh:geo-fixes

Conversation

@meyburgh

Copy link
Copy Markdown

Adds a spatial field type, indexed on the sphere with S2 cells, and the queries over it. This builds on @flatheadmill's geo work (see #2729, #2755): it is his fork's main, merged with current upstream main, plus a series of correctness, robustness and performance fixes on top.

What it adds

  • Schema and documents: a SPATIAL field option, a Geometry value, and GeoJSON ingest for points, lines, polygons (with holes), multi-geometries and GeometryCollection. Polygons are made valid on ingest (as ST_MakeValid does); a field can opt into RFC 7946 ring orientation with set_oriented() so polygons larger than a hemisphere stay expressible.
  • Index: src/spatial/ — an S2 cell index plus a streaming edge index per segment, written as the cells and edges files. Spatial fields are a built-in SegmentPlugin (following feat: Extensible segment components via plugin trait #2993), so segments of a schema with no spatial field have neither file. Merges interleave the source cell indexes.
  • Queries (SpatialQuery): intersects, contains, covered-by, within distance, between distances, kNN, latitude-longitude rectangles (covered_by_rect, intersects_rect), and spatial joins through an executor.
  • Query language: spatial predicates in the query grammar ($within, $between, $contains, …) and $query(...) for joins, documented on QueryParser.
  • Space usage reports both spatial files.
  • examples/geo_json.rs.

Changes on top of the original work

  • Merged upstream main and ported spatial storage onto the segment plugin system.
  • Fixed missed and false matches in contains, intersects, distance and kNN (shared vertices and edges, multi-geometries, unions of query polygons, deleted documents, cross-segment K).
  • Cell and edge formats use variable-length counts and 32-bit edge ids, so cells with more than 65535 geometries and polygons with more than 65535 vertices no longer overflow. Spatial files end with a format version and magic number, and corrupt input returns an error instead of panicking or sizing huge allocations.
  • Rectangle and cap queries decide points from the cell index without decoding them. Over three million random points, a box 170° wide went from 2.4 s (as a polygon) to 33 ms, and a 2000 km radius holding 75 thousand points from 53 ms to 3.6 ms.
  • Exact memory accounting in SpatialWriter, and appending values to a document's geometry set is no longer O(n²).
  • Randomized tests check Within, Between and kNN against brute-force distances.

Notes for review

  • New dependencies: robust, num-bigint, num-rational, num-traits (exact predicates).
  • Coordinates are longitude/latitude on the sphere; there is no support for GIS projections, as discussed in Geo-search #2729.
  • The Geometry value's in-memory type code moves from 13 to 14 to sit next to upstream's Custom variants.
  • It is large (95 files, ~21k lines, of which ~17k are new files under src/spatial/). The changes outside src/spatial/ and src/query/spatial_query.rs are small and are the best place to start.

flatheadmill and others added 30 commits November 4, 2025 01:26
Implement a SPATIAL flag for use in creating a spatial field.
Encodes triangles with the bounding box in the first four words,
enabling efficient spatial pruning during tree traversal without
reconstructing the full triangle. The remaining words contain an
additional vertex and packed reconstruction metadata, allowing exact
triangle recovery when needed.
The `triangulate` function takes a polygon with floating-point lat/lon
coordinates, converts to integer coordinates with millimeter precision
(using 2^32 scaling), performs constrained Delaunay triangulation, and
encodes the resulting triangles with boundary edge information for block
kd-tree spatial indexing.

It handles polygons with holes correctly, preserving which triangle
edges lie on the original polygon boundaries versus internal
tessellation edges.
Implemented byte-wise histogram selection to find median values without
comparisons, enabling efficient partitioning of spatial data during
block kd-tree construction. Processes values through multiple passes,
building histograms for each byte position after a common prefix,
avoiding the need to sort or compare elements directly.
Implemented a `Surveyor` that will evaluate the bounding boxes of a set
of triangles and determine the dimension with the maximum spread and the
shared prefix for the values of dimension with the maximum spread.
Implements dimension-major bit-packing with zigzag encoding for signed i32
deltas, enabling compression of spatially-clustered triangles from 32-bit
coordinates down to 4-19 bits per delta depending on spatial extent.
Implement an immutable bulk-loaded spatial index using recursive median
partitioning on bounding box dimensions. Each leaf stores up to 512
triangles with delta-compressed coordinates and doc IDs. The tree
provides three query types (intersects, within, contains) that use exact
integer arithmetic for geometric predicates and accumulate results in
bit sets for efficient deduplication across leaves.

The serialized format stores compressed leaf pages followed by the tree
structure (leaf and branch nodes), enabling zero-copy access through
memory-mapped segments without upfront decompression.
Lossless compression for floating-point lat/lon coordinates using XOR
delta encoding on IEEE 754 bit patterns with variable-length integer
encoding. Designed for per-polygon random access in the document store,
where each polygon compresses independently without requiring sequential
decompression.
The triangulation function in `triangle.rs` is now called
`delaunay_to_triangles` and it accepts the output of a Delaunay
triangulation from `i_triangle` and not a GeoRust multi-polygon. The
translation of user polygons to `i_triangle` polygons and subsequent
triangulation will take place outside of `triangle.rs`.
Implemented a geometry document field with a minimal `Geometry` enum.
Now able to add that Geometry from GeoJSON parsed from a JSON document.
Geometry is triangulated if it is a polygon, otherwise it is correctly
encoded as a degenerate triangle if it is a point or a line string.
Write accumulated triangles to a block kd-tree on commit.

Serialize the original `f64` polygon for retrieval from search.

Created a query method for intersection. Query against the memory mapped
block kd-tree. Return hits and original `f64` polygon.

Implemented a merge of one or more block kd-trees from one or more
segments during merge.

Updated the block kd-tree to write to a Tantivy `WritePtr` instead of
more generic Rust I/O.
Ended up using `select_nth_unstable_by_key` from the Rust standard
library instead.
Read node structures using `from_le_bytes` instead of casting memory.
After an inspection of columnar storage, it appears that this is the
standard practice in Rust and in the Tantivy code base. Left the
structure alignment for now in case it tends to align with cache
boundaries.
Addressed all `todo!()` markers created when adding `Spatial` field type
and `Geometry` value type to existing code paths:

- Dynamic field handling: `Geometry` not supported in dynamic JSON
  fields, return `unimplemented!()` consistently with other complex
  types.
- Fast field writer: Panic if geometry routed incorrectly (internal
  error.)
- `OwnedValue` serialization: Implement `Geometry` to GeoJSON
  serialization and reference-to-owned conversion.
- Field type: Return `None` for `get_index_record_option()` since
  spatial fields use BKD trees, not inverted index.
- Space usage tracking: Add spatial field to `SegmentSpaceUsage` with
  proper integration through `SegmentReader`.
- Spatial query explain: Implement `explain()` method following pattern of
  other binary/constant-score queries.

Fixed `MultiPolygon` deserialization bug: count total points across all
rings, not number of rings.

Added clippy expects for legitimate too_many_arguments cases in geometric
predicates.
This reverts commit 19eab16.

Restore radix select in order to implement a merge solution that will
not require a temporary file.
Existing code behaved as if the result of `select_nth_unstable_by_key`
was either a sorted array or the product of an algorithm that gathered
partition values as in the Dutch national flag problem. The existing
code was written knowing that the former isn't true and the latter isn't
advertised. Knowing, but not remembering. Quite the oversight.
Removed use of robust.
Change from all caps to the Rust camel case convention for enum
variants.
The return value is already a copy so the clone is unnecessary.
Resolve errors encountered when a segement lacks geo data when merging
and searching.
Within search only tests whether the triangle bounding box is within the
search bounding box. If the triangle bounding box is not within the
search bounding box then at least one line is outside the bounding box
and a vertex of the triangle is on that line.
Create a mechanism for configuring the index type. Will be used to
select between planar and unit sphere.
This was a new method added after the fork point. We can resolve a
rebase or main merge when we are closer to landing the pull request.
Bit-packing will compress better. We are going to need compressed f64
for both the document store and the index.
flatheadmill and others added 29 commits March 26, 2026 03:36
Moved plane.rs to plane/ directory. Added plane/math.rs with 2D predicates:
orient2d (via robust crate), segment crossing, point-in-polygon, point-to-segment
distance, segment-to-segment distance, edge length. Implemented origin_inside for
Plane as unconditionally false.
Added const DIMENSIONS to the Surface trait. EdgeWriter<S: Surface> and
EdgeReader<S: Surface> use S::DIMENSIONS to determine vertex byte size.
The writer writes S::DIMENSIONS f64 values per vertex. The reader decodes
the same count. All call sites specify Sphere for now.
Spatial writers recorded global stream offsets instead of field-relative
offsets. With two spatial fields the second field's directory points past
its slice. Capture the base offset per field and subtract.

Point geometries panicked during merge because vertices[edge_idx + 1]
assumes a next vertex. Fall back to v0 for the degenerate edge with an
assertion that edge_idx is zero.
read_set returns a cloned GeometrySet instead of decomposed vecs. The
merger no longer reconstructs EdgeSets with empty ring_offsets and
contains_hilbert_start: false. Polygons with holes survive merge with
their ring boundaries intact.

The third point geometry guard in collapse_siblings closes the last
occurrence of the vertices[edge_idx + 1] panic from the previous commit.
EdgeReader is pure deserialization. All methods are &self. No cache, no
HashMap, no LRU. Returns owned GeometrySets on every call.

EdgeCache wraps one or more EdgeReaders with an LRU cache keyed by
(segment, position). The query path uses segment 0 with a single reader.
The merge path uses real segment indices with a shared cache and one
eviction budget across all source segments.
ClippedShape carries GeometryId = (u16, u32) where the first element is
the segment index and the second is the geometry position. The cell index
reader fills in (0, position) on read. The cell index writer extracts the
position on write. The merge stamps the real segment index. The query path
passes zeros.

One type everywhere. No generic. Two extra bytes per shape.
Shapes carry their source identity through the pipeline. The merge
stamps the segment index onto source cells and never remaps. The
caller assigns new IDs at emission time in Hilbert encounter order,
writes edges first, then writes cells with the new IDs substituted.
The old interleave eagerly subdivided coarse cells when they overlapped finer
cells, creating many interior-only cells that the downstream stages then
recombined. The new interleave holds the coarse cell as a sponge and absorbs
finer cells into it. Subdivision only fires when the combined edge count
exceeds the threshold.

The collapse is a new stage that groups cells by parent in Hilbert order and
promotes groups up the level hierarchy when the short edge count allows. It
is generic over its inner iterator.

CellIndexMerge is now generic over the group iterator. The edge cache is
shared across all three pipeline stages through a RefCell rather than owned
by any one stage.
The geometry set on each HeapEntry is extended when cells are absorbed or
distributed during splits, replacing the full recount that ran on every
absorption.
EdgeCache now uses RefCell internally so callers take &EdgeCache instead of
&mut EdgeCache. The cache returns GeometryEntry which holds an Rc<GeometrySet>
and knows its member index. The Rc keeps vertex data alive across LRU
eviction, eliminating the vertex clone at every call site.

GeometryEntry.edge(index) returns the two endpoints of an edge, replacing
the repeated v0/v1 pattern with bounds check across interleave, collapse,
and merge.
CoarseEdge stores the edge endpoints alongside edge_index and max_level.
subdivide_cell reads from CoarseEdge instead of the edge cache. flatten
builds its edge unions from CoarseEdge vertices. Neither function accesses
the edge cache.
The indexer asks for the segment writer's memory usage after every document, and
SpatialWriter summed every accumulated geometry set on each call, so indexing a segment of n
geometries cost O(n^2). Over 1.6M points with a 1 GB writer budget, throughput fell from 36k to
3k docs/s per 400k documents; with the running total it holds at 31k.

The estimate now counts the heap each set holds (the set, its members, their vertex and ring
offset arrays) instead of 24 bytes per vertex. That undercounted a point by about 12x, at 24
bytes against the 296 it holds, and let segments overshoot the budget.
The merger opens a segment with no geometry for a spatial field as empty bytes, and open read
its footer from data[n - 8..n], so merging any such segment panicked the merge thread with
"attempt to subtract with overflow" and the merge failed. EdgeReader::open already guards
against short input the same way.
TopDocs is no longer a Collector by itself, so the example, and with it cargo test, did not
compile.
SpatialWriter::mem_usage now counts the spare capacity of the arrays that hold the sets, which
it missed by up to 10% for points, and to_geometry_set allocates every array at its exact
length, so a point holds 128 bytes instead of 296. Counting one set moves to
GeometrySet::mem_usage, with element sizes taken from each array's type.

CellIndexReader::open and EdgeReader::open fail with DataCorruption when the footer does not
locate a directory that ends where the footer begins, and get_field fails for a field that has
a cell index but no edge index, or the reverse. Before, an index shorter than its footer read
as empty, so a merge dropped that segment's geometry for good. The merger opens a segment
without geometry for the field with the new empty() readers instead.

The merger leaves a field that has no geometry in any segment out of the composites, as the
segment writer does, builds the inverted doc id mapping only for segments with geometry, and
logs its spatial metrics at debug level rather than printing them to stderr on every merge.
Both cell index writers share write_directory, and the clippy warnings in the touched code are
gone.

The merge tests now check which document the geometry lands on, merging the segments in commit
order, and that a field never written stays absent after the merge.
ClosestEdgeQuery missed geometries. A point query never measured its distance to the edges in a
stored cell that its own cell overlapped, a polygon containing part of the query was not at
distance zero, and a stored edge was only checked against the query edges in overlapping query
cells, which need not hold the closest one. It now checks each stored edge against every query
cell whose bounding cap could be closer than the best distance so far, counts the interiors of
both geometries as distance zero, and walks the cell index with CellIndexReader::locate instead
of decoding every cell under each queue cell. Within and kNN over 100k points went from about
8 ms to 30 µs per query, and the never_loop clippy error is gone. Between joins no longer stop
at the first geometry found, which may lie inside the inner distance, and kNN with k = 0 no
longer panics.

SpatialQuery's kNN runs through the executor, so K is applied across segments rather than per
segment, and hits are scored by distance. The executor returns an error instead of panicking
when it has no searcher, and explains only matching documents, with their score.

Contains on a multi-geometry requires every member to be contained, not just one, and segment
space usage counts the edge index along with the cell index.

A randomized test checks Within, Between and kNN, from points and from other geometries,
against brute force distances.
The cell index stores shape counts, edge counts and edge ids as variable length integers, and
edge ids are 32 bits wide, so a cell holding more than 65535 geometries and a polygon with more
than 65535 vertices no longer overflow and corrupt the index. The edge index directory records
the head of the set each skip block starts in, so finding a geometry's document reads at most
two skip blocks instead of walking back over every member of a large multi-geometry. Both
formats change, so spatial segments must be re-indexed. The edge and cell writers and the
merger return I/O errors instead of panicking, and the merge edge cache holds at most 2M
vertices instead of 100M.

Contains and intersects use every member of a multi-geometry query, give lines and points no
interior, and test candidate edges against the edges of every query index cell a covering cell
overlaps, not just one, which missed crossings for query polygons with more than a few dozen
vertices. Where boundaries meet only at shared vertices or along shared edges, the touching
edges' midpoints and points beside shared edges decide instead of an ambiguous shared vertex:
contains rejects a polygon that leaves the query through a shared vertex or surrounds a hole of
the query, and intersects matches identical polygons but not ones that only share boundary.
A document's geometry values make one geometry set, so contains needs all of them. The merge no
longer marks a line as containing a cell's center, and GeoJSON GeometryCollection parses.

Every executor stage leaves out deleted documents. A join opens each probed segment once,
builds the probe query once per outer document, stops probing at the first match, and does not
match a document to itself. kNN skips deleted documents before ranking, applies its boost to
the score it explains, and keeps the K nearest in a heap instead of sorting all distances on
every improvement. Empty distance ranges match nothing, and polygons overlapping a query point
no longer decode every shape in the cell once per polygon.

In the query language, a join keeps its own occur and its siblings'. The siblings only narrow
the join's outer documents, so NOT and OR clauses next to a join keep their meaning. Errors in
a $query(...) are the query's errors, a kNN join is rejected, a spatial predicate needs enough
coordinates, and the Debug output of a join no longer panics.

The unused collapse, merge and delta modules and the i_triangle dependency are gone.
PartialOrd for S1ChordAngle defers to Ord, which changes no comparison since no constructor
yields a NaN length. S2CellId uses Range::contains in its debug assertions and drops casts of
LOOKUP_BITS to the type it already has.
Where a candidate and the query share an edge, the interior sides of the shared edges decide
whether the candidate's interior lies on the query's side, instead of testing points beside the
edge, which a polygon thinner than their offset missed. A contains query of several polygons
verifies candidates against the exposed boundary of their union (polygon_union.rs), so that a
candidate lying across the members' shared edges, inside the union, is contained, and one
around a hole the members enclose together is not.

Appending a value to a document's geometry set no longer re-sums the whole set, which made
indexing a document of n values cost O(n^2). The memory usage adds the incoming members'
buffers and the growth of the set's member array, spare capacity included.
…queries.

Polygons are made valid as ST_MakeValid does. Repeated vertices are removed and unclosed rings
closed. A spike leaves its ring and stays as a line member, a ring whose vertices all lie on one
great circle bounds no area, and a polygon with no area is the point or line it is. Rings bound
the smaller region whichever way they run, by curvature with Kahan summation, and a ring close
to a hemisphere takes its smaller side by vote, so that a self-crossing ring bounds its lobes. A
field set_oriented() keeps RFC 7946's right-hand rule instead, so polygons larger than a
hemisphere stay expressible; a query can choose with SpatialQuery::with_ring_orientation.
Positions may carry an altitude, which is dropped, and both GeometryCollection spellings parse.

The cell index builder emits cells on a face its shapes cover without edges, and its exit pass
tests only edges of shapes with interiors. The merge clips edges to the cell's face with the
builder's clipping and exact bounds, instead of projecting endpoints off the face. Spatial files
end with a format version and magic number, so an index of another format fails to open with an
error that says to re-index, and corrupt counts can no longer size huge allocations.

Points and lines intersect what they meet: shared vertices and edges, a point on an edge, a line
along a polygon's boundary. Where a vertex lies inside another edge, contains and intersects
split the edges and decide by their pieces, as the union path does. Contains checks every ring
of the query, so polygons larger than a hemisphere get no false positives. $contains reads from
the field's side, as its join does: SpatialPredicate::Contains is now "contains the polygon",
CoveredBy is "inside it" and has the syntax $within(lon lat, ...). The region coverer refines
the coarsest cells first, as S2 does.

kNN ties break by document address, scores fall with rank so that a kNN clause lifts the
nearest documents, and without a searcher each segment keeps its own K. The query parser narrows
a kNN or join by the clauses every match must satisfy, however they are boosted or nested, and
by its optional siblings when nothing is required. The lenient parser reads spatial predicates,
and skips a malformed one with an error. Query coordinates must be finite and in range, explain
no longer seeks backward, scorers seek directly, and geometry values in fast or JSON fields are
skipped instead of panicking.

Edge caches budget bytes of heap rather than vertices, and grow to four times their largest set
so that large sets sharing cells do not evict each other; join probes share the query's budget.
Deprecated aliases keep set_outer and SegmentSpaceUsage::spatial, and the old space usage JSON
still reads. The spatial query syntax is documented on QueryParser.
…ystem.

Upstream turned the segment components into plugins (quickwit-oss#2993): postings, fast fields and the store
are SegmentPlugins, and a segment's files are SegmentComponent::Custom extensions. Spatial
fields join them as a built-in plugin. SpatialPlugin owns the "cells" and "edges" files: its
writer takes each document's geometry, remaps document ids when the segment is sorted, and
writes both composites at flush; its merge interleaves the source cells as the merger did; and it
reports the space usage of both files. Segments of a schema with no spatial field have neither.

The Geometry value keeps its own variants next to upstream's Custom ones, moving its in-memory
type code from 13 to 14.
…ecoding them.

SpatialQuery::covered_by_rect and intersects_rect find what a rectangle of longitude and latitude
covers, or meets: its sides are the meridians and the parallels through its corners, as a GeoJSON
bounding box has them. from_bounds makes the polygon through the corners, whose geodesic edges bow
toward the nearer pole, by nearly 8 degrees at the middle of an edge 90 degrees long at 60 north,
and run the short way around between corners more than 180 degrees apart. A rectangle may be of
any width, cross the antimeridian, reach a pole, which has every longitude, or be the whole
sphere. It is closed, and a vertex within rounding of its boundary is inside it.

A point is tested by its height and against the planes of the rectangle's two meridians, with no
trigonometry. An edge is tested by where its great circle is highest and lowest between its ends,
and a polygon also by the points the rectangle leaves out, or by a point of the rectangle within
it. S2LatLngRect gains S2's BoundaryIntersects for the edges that cross its sides.

A covering finds the candidates, and a covering cell within the rectangle decides what it holds
without decoding it: a geometry found only in such cells lies within the rectangle, since it is
connected. A point is read from its entry in the edge index, by EdgeReader::summary, and cells
are walked without decoding their edge indices, by CellIndexReader::visit_range.
SpatialPredicate::Within searches the cap around its point the same way, and decides as the
closest edge query did, which still serves distances between and nearest neighbors.

Over three million random points, a box 170 degrees wide took 2.4 s as a polygon and takes 33 ms
as a rectangle, and a 2000 km radius that holds 75 thousand takes 3.6 ms, from 53.

Prepared queries add their documents to a bit set, rather than list them.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-11T10:00:57.687213Z 0c573b7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants