JevDocumentReranker¶
A Spring AI DocumentPostProcessor that reorders retrieved documents by asking, one
document at a time, whether each could actually answer the query.
Optional dependency
Needs spring-ai-rag, which typesafe-spring-ai declares <optional>true</optional>.
Why rerank at all¶
Vector search ranks by embedding proximity, which is a good way to find candidates and a poor way to order them: a passage can be about the right subject and still not contain the answer. Asking a direct question about each candidate is a different measurement, and it is the one the final ordering should use.
The TypeSafe reranking cookbook reports top-1 accuracy moving from 5% to 18% on its evaluation set from this step alone.
Quick Start¶
RetrievalAugmentationAdvisor.builder()
.documentRetriever(retriever)
.documentPostProcessors(
JevDocumentFilter.builder(typeSafeClient).build(), // screen first
JevDocumentReranker.builder(typeSafeClient).topK(5).build())
.build();
Screening before ranking is the right order: there is no point spending a call ordering a passage that is about to be thrown out, and an injection should never reach a ranking prompt in the first place.
Builder Configuration¶
| Builder method | Type | Default | Description |
|---|---|---|---|
question(Noul) |
Noul |
a relevance question | Replace the question. Write it about query and passage, the two state fields. |
topK(int) |
int |
keep all | How many documents to keep after reordering. |
minimumScore(double) |
double |
0.0 |
Drop documents below a bar. At zero this is purely a reordering. |
batchOptions(JevBatchOptions) |
— | four at a time | How widely to fan the per-document calls out. |
Scores travel with the documents¶
Each scored document carries its value under SCORE_METADATA_KEY, so a later stage or a log
can see why the order came out as it did:
ranked.forEach(document -> System.out.printf("%.2f %s%n",
document.getMetadata().get(JevDocumentReranker.SCORE_METADATA_KEY),
document.getId()));
A custom question¶
The default asks whether the passage could answer the query. Domain-specific rerankings often want something narrower — the cookbook's own example asks whether a candidate passage could be from a cited legal precedent:
JevDocumentReranker.builder(typeSafeClient)
.question(Noul.builder()
.instructions(Map.of(
"question", "Could the `passage` be the precedent cited in the `query`?",
"focus", "Matching holding and posture, not merely similar subject matter."))
.whenTrue("The passage states the holding the query cites")
.whenFalse("A different case, or the same case on a different point")
.build())
.build();
Failure behaviour¶
A document whose call fails is kept, unscored, after every document that was scored. A
transport failure is not evidence that a passage is irrelevant, so dropping it would
silently shrink the context on an unrelated error — but it is not evidence of relevance
either, so an unjudged passage never outranks one this reranker actually measured. That
ordering is what stops a failed call from evicting a known-good passage under topK.
Unscored documents carry no SCORE_METADATA_KEY entry, so a later stage can tell "scored
zero" from "never scored".
Cost¶
One call per document. Reranking a top-20 candidate list is twenty calls, four at a time by
default — see Batches. Combining with
JevDocumentFilter first keeps that number down, since only the
survivors are ranked.
Reuse an executor in a server
Both post-processors default to JevBatchOptions.defaults(), whose executor is null,
so a thread pool is created and shut down on every process() call — twice per
request when filter and reranker are chained. In a server, pass a shared one:
JevBatchOptions shared = JevBatchOptions.ofConcurrency(8).withExecutor(myExecutor);
JevDocumentFilter.builder(typeSafeClient).batchOptions(shared).build();
JevDocumentReranker.builder(typeSafeClient).batchOptions(shared).build();
A caller-supplied executor is never shut down by the batch.
See Also¶
- JevDocumentFilter — screening before ordering
- Batches
- TypeSafe reranking cookbook