# feat(search): return the winning semantic passage - #63
Merged
xerj-org merged 3 commits intoJul 28, 2026
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
XERJ already scores a long
semantic_textdocument 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
_passagepseudo-field.fields._passage[0].fields: ["_passage"]and returns the result as hit-level_passage.pageis copied into the passage result, preserving page-local PDF provenance.fieldsare normalized consistently._source, GET, field discovery, full-text indexing, aggregations, and Painless scripts.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.
pageis omitted when the source does not contain a numericpage.Usage
No mapping change is required beyond the existing
semantic_textfield:Index normally, then opt in through
fields:The same request may use
"fields": "_passage"or"fields": [{"field": "_passage"}]. Ordinary searches that omit_passagepreserve their previous response shape and do not materialize passage text.For native search, add
"fields": ["_passage"]to the request. Native hits contain_passagedirectly instead of the Elasticsearch-compatiblefields._passagearray.Honest scope and limitations
_passageis 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_passagemay 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.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.
Command:
Observed output:
This is a controlled metadata-overhead measurement, not an end-to-end index-size claim.
Manual reproduction
semantic_textfield plus numericpage.refresh=true."fields": ["_passage"].fields._passage[0].textis the exact source slice bounded bystart_offsetandend_offset,ordinalidentifies the winning generated passage,fieldnames the original semantic field, andpagematches the source.fieldsand confirm_passageis absent._passagefrom a hybrid or multi-kNN query and confirm HTTP 400 includes the concrete alternatives: use one semantic/kNN clause or omit_passagefor fusion.The repository integration test performs the update, flush/merge, restart, source-filter, Unicode-offset, and response-hiding sequence deterministically:
The router tests exercise all accepted
fieldsforms, scroll continuation, both multi-kNN clause orders, and both hybrid child orders: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 ignoredcargo test -p xerj-engine passage_ -- --test-threads=1: 10 passage tests passed across unit and integration suites, 0 failedcargo test -p xerj-api passage_: 6 passed, 0 failedcargo clippy -p xerj-query -p xerj-engine -p xerj-api --all-targets -- -D warnings: passedcargo fmt --all --check: passedgit diff --check: passedThe 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.