Carrying Frame and Extent Metadata Into Every Chunk

Attach the projection, bounding extent and feature identifiers to each retrieval chunk so a fragment retrieved out of context is still self-describing and filterable.

A chunk is retrieved alone. Whatever context sat around it in the source document — the header declaring the projection, the caption naming the survey, the sentence explaining the units — is gone by the time a model reads it. Metadata is how that context survives the trip. This guide covers computing and attaching the four fields that make a spatial chunk self-describing, as the final stage of chunk-boundary strategies for spatial corpora.

When to Use This Approach

Always, for any corpus containing coordinates. The only real decision is how much to compute rather than copy, and that decision turns on whether the source can be trusted.

Source declares Do this Why
A frame, consistently Copy it, verify against a sample coordinate Cheap, and the check catches header drift
A frame, inconsistently Resolve per chunk, record the evidence One header cannot speak for a merged document
Nothing Resolve from context and flag confidence A guess recorded as fact is the worst outcome
A frame and an extent Recompute the extent from the geometry anyway Declared extents go stale; geometry does not

The last row is the one people skip. A document’s declared extent describes what the author thought the file contained, often before an edit removed half of it, and a chunk’s extent must describe what the chunk actually holds — otherwise the spatial pre-filter in spatial context retrieval and reranking is filtering on fiction.

What a chunk loses when it leaves its documentA source document carries a projection header, a units note and a survey caption above its features. After chunking, only the coordinates travel unless the context is copied into metadata.source documentheader: EPSG:27700note: distances in metrescaption: 2024 site survey…then four hundred featureschunk without metadatanumbers with no frame, era or unitschunk with metadataframe, extent, ids and period travel too
Everything above the features is context. It is obvious in the document and invisible in the chunk, which is why metadata is not an optimisation — it is the only mechanism that carries the document's assumptions to the point of use.

Implementation

The function parses each chunk’s geometries, computes the true extent from them, verifies the declared frame against that extent, and returns a record that carries everything a downstream filter or citation layer needs.

import logging
from dataclasses import dataclass, asdict
from typing import Iterable, Optional

from shapely import wkt
from shapely.errors import GEOSException
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from pyproj import CRS
from pyproj.exceptions import CRSError

log = logging.getLogger("chunk_metadata")


@dataclass(frozen=True)
class ChunkMeta:
    epsg: int
    bbox: tuple[float, float, float, float]
    feature_ids: tuple[str, ...]
    geometry_count: int
    frame_verified: bool          # False means the frame is copied, not confirmed
    notes: tuple[str, ...]


def _parse_all(literals: Iterable[str]) -> list[BaseGeometry]:
    """Parse what parses; log and skip what does not, never abort the chunk."""
    out = []
    for lit in literals:
        try:
            geom = wkt.loads(lit)
        except (GEOSException, ValueError) as exc:
            log.warning("unparseable geometry skipped during metadata build: %s", exc)
            continue
        if not geom.is_empty:
            out.append(geom)
    return out


def _frame_plausible(epsg: int, bbox) -> tuple[bool, Optional[str]]:
    """Check the extent sits inside the frame's declared area of use."""
    try:
        crs = CRS.from_epsg(epsg)
    except CRSError as exc:
        return False, f"frame will not construct: {exc}"
    if crs.area_of_use is None:
        return False, "frame declares no area of use"
    if not crs.is_geographic:
        return False, "projected frame — extent check needs a transform first"
    w, s, e, n = crs.area_of_use.bounds
    inside = (w - 1 <= bbox[0] and bbox[2] <= e + 1
              and s - 1 <= bbox[1] and bbox[3] <= n + 1)
    return inside, None if inside else "extent falls outside the frame's area of use"


def build_chunk_meta(
    geometry_literals: Iterable[str],
    feature_ids: Iterable[str],
    declared_epsg: int,
) -> ChunkMeta:
    """Compute self-describing metadata for one chunk. Never raises on data quality."""
    geoms = _parse_all(geometry_literals)
    ids = tuple(str(i) for i in feature_ids)
    notes: list[str] = []

    if not geoms:
        # Deterministic fallback: a chunk with no usable geometry still gets a record,
        # with a degenerate extent that no bounding-box filter will match by accident.
        notes.append("no parseable geometry in chunk")
        return ChunkMeta(declared_epsg, (0.0, 0.0, 0.0, 0.0), ids, 0, False, tuple(notes))

    try:
        bounds = unary_union(geoms).bounds
    except GEOSException as exc:
        log.warning("union failed, falling back to per-geometry bounds: %s", exc)
        xs = [b for g in geoms for b in (g.bounds[0], g.bounds[2])]
        ys = [b for g in geoms for b in (g.bounds[1], g.bounds[3])]
        bounds = (min(xs), min(ys), max(xs), max(ys))

    verified, problem = _frame_plausible(declared_epsg, bounds)
    if problem:
        notes.append(problem)

    return ChunkMeta(declared_epsg, tuple(round(v, 7) for v in bounds),
                     ids, len(geoms), verified, tuple(notes))

The degenerate extent for a geometry-free chunk is a deliberate choice over None. A null extent forces every consumer to branch, and one of them will forget; a zero-area extent at the origin simply never intersects a real query region, which fails safe. The notes field carries the reason so the chunk is not mistaken for a coding error.

The union-failure fallback exists because unary_union is the one call here that can fail on geometry that individually parsed fine — self-intersecting rings that survive wkt.loads and confound the overlay. Taking the envelope of individual bounds gives the same answer for well-behaved input and a slightly loose answer for the pathological case, which is the right direction to be wrong in.

Declared extent against computed extentA document declares a wide regional extent while its remaining features occupy a small corner. Filtering on the declared extent retrieves the chunk for queries it cannot answer.declared extentwhat the header claimscomputedcomputed extent onlymatches the queries this chunkcan actually answerRecompute from geometry — a declared extent is a claim, not a measurement
False positives are quiet. An over-wide extent never errors; it just admits the chunk into result sets for a region it says nothing about, where it competes for context space against documents that do.

Validation & Testing

def test_extent_contains_every_geometry():
    meta = build_chunk_meta(LITERALS, IDS, 4326)
    for lit in LITERALS:
        g = wkt.loads(lit)
        assert g.bounds[0] >= meta.bbox[0] - 1e-6
        assert g.bounds[2] <= meta.bbox[2] + 1e-6


def test_geometryless_chunk_gets_a_degenerate_extent():
    meta = build_chunk_meta([], ["f-1"], 4326)
    assert meta.geometry_count == 0
    assert meta.bbox == (0.0, 0.0, 0.0, 0.0)
    assert "no parseable geometry" in " ".join(meta.notes)


def test_implausible_frame_is_flagged_not_rejected():
    # British coordinates declared as a Japanese frame: usable record, honest flag.
    meta = build_chunk_meta(["POINT(-3.19 55.95)"], ["f-2"], 6668)
    assert meta.frame_verified is False and meta.notes

The first test is a containment property and should be run over the whole corpus during an index build, not just over a fixture; it is cheap and it is the assertion that catches an off-by-one in extent rounding. Rounding to seven decimal places is roughly a centimetre, comfortably below any real query tolerance, and it keeps the metadata compact — but rounding inward would break containment, which is why the rounding happens after the bounds are taken and is tested for.

Gotchas & Edge Cases

Antimeridian-crossing extents. A chunk holding features either side of 180 degrees longitude produces an extent spanning almost the whole world, and every bounding-box filter then matches it. Detect the case — a longitude span above 180 degrees — and store two extents or a flag, rather than one meaningless rectangle.

An extent computed naively across the antimeridianTwo features either side of 180 degrees longitude produce a bounding box spanning nearly the whole world, which matches every spatial query. Storing two extents instead keeps the filter meaningful.Two features at longitude 179 and minus 179naive extent: minus 179 to 179 — matches every query on the planetthe two features are two degrees apart; the box is 358 degrees wideextent Aextent Btwo extents, each honest about what it covers
The widest possible box is the least useful one. Nothing errors, nothing is logged, and the chunk simply becomes a candidate for every spatially filtered query in the corpus.

Rounding that shrinks the box. Truncating rather than rounding, or rounding both corners the same way, can pull the maximum corner inside a geometry’s true bound. Round the minimum down and the maximum up if you round at all; the containment test above is what catches this.

Frames that are projected. The plausibility check short-circuits for projected frames because comparing metres against a degree-based area of use is meaningless. Transform a corner into geographic coordinates first if you want the check to apply, and be explicit that an unverified frame is not a wrong one.

Identifiers that are not stable. Feature identifiers assigned by the chunker — index positions, hashes of coordinate text — change whenever the document is re-exported, which breaks every citation made against the previous build. Prefer identifiers from the source, and where none exist, derive one from a stable attribute rather than from position.

Metadata that outgrows the chunk. A chunk holding two hundred features accumulates two hundred identifiers, which can rival the text in size and inflate storage across a large corpus. Cap the stored list and record the total count alongside it, so citation still works for small chunks and the count remains honest for large ones.

Frequently Asked Questions

Should the extent be stored as a geometry or as four numbers?

As a geometry if the store supports it, because that lets a real spatial index serve the filter and lets the predicate be exact. Four numbers work when the store only offers numeric range filters, and they are perfectly adequate for a rectangular pre-filter. What you should not do is store both and let them drift; if you need the numbers for convenience, derive them at read time from the geometry.

Does every chunk need a period as well as an extent?

Any corpus where the same place is described at different times, which is most of them. Without a period, a query about current conditions retrieves a 2009 survey and a 2024 one with equal enthusiasm, and the model has no basis for preferring either. The period does not need to be precise — a year is usually enough — but it does need to be present and to come from the document rather than from the ingestion date.

What if the source declares different frames in different sections?

Then the document has more than one frame and a single document-level value is wrong for some of it. Resolve per chunk, using the nearest preceding declaration, and record which declaration was used in the notes. This happens most often in merged or appended documents, and it is precisely the case where copying one header across every chunk silently corrupts a subset of the corpus.

Is it worth verifying the frame on every build, or once at ingestion?

Every build, because the check is cheap and the inputs change. A registry update can deprecate a code, an upstream correction can move a document's features, and a chunking change can redistribute geometry between chunks. Verification at ingestion tells you the frame was plausible on the day it arrived, which is a weaker statement than it sounds after a year of edits.