Skip to content

# feat(search): return the winning semantic passage - #63

Merged
xerj-org merged 3 commits into
xerj-org:mainfrom
probelabs:feat/passage-winner-provenance-main
Jul 28, 2026
Merged

# feat(search): return the winning semantic passage#63
xerj-org merged 3 commits into
xerj-org:mainfrom
probelabs:feat/passage-winner-provenance-main

Conversation

@buger

@buger buger commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Why

XERJ already scores a long semantic_text document by its best matching generated chunk, but the search response does not tell the caller which chunk produced the score. An agent must therefore receive or re-read the complete source document to recover the evidence behind a hit. This is especially costly for page-oriented PDF indices, where the desired response is usually the relevant passage plus the document and page identity.

What changes

This change records compact provenance for generated semantic chunks and exposes the winning passage only when the caller requests the _passage pseudo-field.

  • Ingest persists the original semantic field name and UTF-8 byte-offset pairs beside its derived chunk vectors.
  • Exact chunk scoring retains the winning zero-based chunk ordinal and uses a deterministic lowest-ordinal tie-break.
  • Response assembly slices the winning text from the authoritative source only for returned hits, so passage text is not stored twice.
  • Elasticsearch-compatible search returns the result as fields._passage[0].
  • Native search accepts fields: ["_passage"] and returns the result as hit-level _passage.
  • A numeric source page is copied into the passage result, preserving page-local PDF provenance.
  • Scalar, array, and Elasticsearch object forms of fields are normalized consistently.
  • Scroll continuations preserve opted-in passage provenance.
  • Engine-owned metadata is rejected in caller writes and hidden from _source, GET, field discovery, full-text indexing, aggregations, and Painless scripts.
  • Update, delete, flush, merge, restart, and source-filtering paths retain correct provenance.

The response object contains:

{
  "field": "body",
  "ordinal": 2,
  "start_offset": 896,
  "end_offset": 1408,
  "text": "The covenant remains in effect through the fourth quarter...",
  "page": 17
}

Offsets are UTF-8 byte offsets into the original semantic field. page is omitted when the source does not contain a numeric page.

Usage

No mapping change is required beyond the existing semantic_text field:

curl -sS -X PUT "$XERJ_URL/reports" -H 'content-type: application/json' -d '{
  "mappings": {
    "properties": {
      "body": {"type": "semantic_text"},
      "file": {"type": "keyword"},
      "page": {"type": "integer"}
    }
  }
}'

Index normally, then opt in through fields:

BODY="$(printf 'Routine quarterly operating context. %.0s' {1..80})The covenant remains in effect through the fourth quarter."

curl -sS -X PUT "$XERJ_URL/reports/_doc/q4?refresh=true" -H 'content-type: application/json' -d "$(jq -n --arg body "$BODY" '{
  body: $body,
  file: "acme-2025-q4.pdf",
  page: 17
}')"

curl -sS -X POST "$XERJ_URL/reports/_search" -H 'content-type: application/json' -d '{
  "fields": ["_passage"],
  "_source": {"includes": ["file", "page"]},
  "query": {
    "semantic": {
      "field": "body",
      "query": "What covenant applies in the fourth quarter?",
      "k": 10
    }
  }
}'

The same request may use "fields": "_passage" or "fields": [{"field": "_passage"}]. Ordinary searches that omit _passage preserve their previous response shape and do not materialize passage text.

For native search, add "fields": ["_passage"] to the request. Native hits contain _passage directly instead of the Elasticsearch-compatible fields._passage array.

Honest scope and limitations

  • This PR exposes provenance for the existing exact per-chunk max-sim scoring path; it does not introduce passage-node HNSW or make passage scoring approximate.
  • _passage is valid when one semantic clause owns the score, or when one kNN clause targets a generated semantic chunk-vector field. An arbitrary dense-vector kNN field has no generated passage metadata, so _passage may be absent. Multi-kNN and hybrid fusion combine independent contributions and do not have one honest winning passage, so those requests return an actionable HTTP 400 telling the caller to use one eligible semantic/kNN clause or omit _passage.
  • The built-in default embedder remains lexical feature hashing. Neural semantics still require explicitly enabling the neural embedding mode or configuring an embedding endpoint.
  • This adds provenance metadata, not another copy of the passage text. The original source and existing generated vectors remain the authoritative data.
  • Short single-passage values continue to use their existing single-vector behavior.

Storage measurement

The committed deterministic measurement uses 256 varied Unicode documents with variable passage counts. It compares identical stored-source batches with and without the field-name/offset metadata and also verifies that each authoritative text occurs exactly once.

Metric Total Per document
Raw JSON metadata delta 20,547 bytes 80.26 bytes
ZBS2 stored metadata delta 1,019 bytes 3.98 bytes

Command:

cd engine
cargo test -p xerj-engine index::chunk_embed_tests::varied_passage_metadata_reports_raw_and_stored_overhead -- --exact --nocapture

Observed output:

varied passage metadata delta: raw=20547 bytes (80.26/doc), stored=1019 bytes (3.98/doc)

This is a controlled metadata-overhead measurement, not an end-to-end index-size claim.

Manual reproduction

  1. Start XERJ against a throwaway data directory and create an index with a long semantic_text field plus numeric page.
  2. Index a value longer than one chunk with a distinctive phrase in a later passage and use refresh=true.
  3. Search for that phrase with "fields": ["_passage"].
  4. Confirm that fields._passage[0].text is the exact source slice bounded by start_offset and end_offset, ordinal identifies the winning generated passage, field names the original semantic field, and page matches the source.
  5. Repeat the search without fields and confirm _passage is absent.
  6. Flush, restart XERJ on the same data directory, repeat the opted-in search, and confirm the same passage is returned.
  7. Update the document with a new distinctive phrase and page number, search again, and confirm the old passage is no longer returned.
  8. Request _passage from a hybrid or multi-kNN query and confirm HTTP 400 includes the concrete alternatives: use one semantic/kNN clause or omit _passage for fusion.

The repository integration test performs the update, flush/merge, restart, source-filter, Unicode-offset, and response-hiding sequence deterministically:

cd engine
cargo test -p xerj-engine semantic_passage_provenance_survives_update_merge_restart_and_source_filter -- --exact --nocapture

The router tests exercise all accepted fields forms, scroll continuation, both multi-kNN clause orders, and both hybrid child orders:

cd engine
cargo test -p xerj-api passage_

Validation

Current branch base and fetched upstream main are both 761b47dfcb5f8fcc57bc929385c422bcffbe130b; no rebase changed production code.

  • cargo test -p xerj-query: 125 passed, 0 failed; 1 doc-test ignored
  • cargo test -p xerj-engine passage_ -- --test-threads=1: 10 passage tests passed across unit and integration suites, 0 failed
  • cargo test -p xerj-api passage_: 6 passed, 0 failed
  • cargo clippy -p xerj-query -p xerj-engine -p xerj-api --all-targets -- -D warnings: passed
  • cargo fmt --all --check: passed
  • git diff --check: passed
  • Release ES-YAML hard gate on this exact branch/base: 1360 passed, 0 failed, 3 skipped

The earlier full package runs on this exact branch/base also passed: 227 engine unit tests, 10 chaos tests, 65 ES integration tests, 101 engine integration tests with 1 ignored, and 61 API tests.

buger added 3 commits July 28, 2026 07:41
Semantic chunking improves document-level ranking, but callers could not inspect which chunk produced a kNN hit. Agents therefore had to receive or re-read the whole source document to recover evidence, losing both provenance and context efficiency.

Persist compact field and byte-offset metadata alongside generated semantic chunks. Exact chunk scoring retains the winning ordinal and reconstructs the passage from the authoritative source only when callers opt in with fields: ["_passage"]. The ES-compatible and native APIs return text, source field, ordinal, and page metadata without duplicating passage text in storage.

Keep the metadata internal everywhere else: reject reserved-field writes, exclude it from FTS/schema discovery/aggregations/Painless, and strip it from GET and search _source responses. Updates, deletes, flushes, merges, restarts, source filtering, Unicode offsets, and deterministic ties are covered by integration and unit tests.

Measured metadata overhead across 256 varied Unicode documents was 20,547 raw bytes (80.26 B/document) and 1,019 bytes after ZBS2 compression (3.98 B/document). The authoritative passage text remains stored exactly once.

Validation:

- cargo test -p xerj-query: 124 passed

- cargo test -p xerj-engine -- --test-threads=1: all suites passed (227 unit, 10 chaos, 65 ES integration, 101 integration with 1 ignored)

- cargo test -p xerj-api: 61 passed

- cargo clippy -p xerj-query -p xerj-engine -p xerj-api --all-targets -- -D warnings

- cargo fmt --all --check

- release ES-YAML hard gate: 1360 passed, 0 failed, 3 skipped
The initial passage-provenance implementation normalized only string-array fields at the engine boundary and dropped the pseudo-field on scroll continuations. ES object-form field requests therefore looked valid at the HTTP layer but never asked the engine to materialize a passage, while page two of a scroll discarded an already-materialized passage.

Normalize scalar, string-array, and object-form fields once through the query parser, forward the raw ES fields value into that parser, and render an opted-in passage on every scroll page. Multi-kNN and hybrid fusion now reject _passage with an actionable 400 because independent summed/fused contributions do not define one honest winning passage; the result no longer depends on child order.

Coverage exercises all three fields wire shapes through the HTTP router, a second scroll page, both multi-kNN clause orders, and both hybrid child orders. Query, engine, and API suites pass; strict Clippy, formatting, and diff checks pass.
Passage provenance has one honest owner only for a single semantic or kNN clause. The engine already rejected multi-kNN and hybrid fusion, but the regression coverage stopped at direct engine calls and did not prove that the ES-compatible router preserved the caller-fixable error instead of converting it to a server failure.

Exercise real Axum POST /{index}/_search requests for both multi-kNN clause orders and both hybrid child orders. Each assertion requires HTTP 400, the ambiguity reason, and concrete remediation: use one semantic/kNN clause or omit _passage for fusion.

Validation:

- cargo test -p xerj-api passage_: 6 passed

- cargo clippy -p xerj-api --all-targets -- -D warnings

- cargo fmt --all --check

- git diff --check
@xerj-org
xerj-org merged commit 6631e61 into xerj-org:main Jul 28, 2026
4 checks passed
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.

2 participants