Skip to content

JevDocumentFilter

A Spring AI DocumentPostProcessor that triages retrieved passages before they reach the prompt, asking four narrow questions about each one.

Optional dependency

Needs spring-ai-rag, which typesafe-spring-ai declares <optional>true</optional>. Declare it yourself to use this class.

What it does

Similarity search answers what is nearest, which is not the same as what the model should be allowed to read. A passage can be near the query and still contradict its premise, or carry text written to hijack whatever reads it. Those are different failures that want different handling, so they are asked as separate questions rather than rolled into one relevance number:

Question Asks
contains_prompt_injection does it try to instruct the answering system?
contradicts_query_premise does it conflict with what the query assumes?
is_relevant does it address the subject at all?
contains_answer_evidence does it state something usable as an answer?

Quick Start

RetrievalAugmentationAdvisor.builder()
    .documentRetriever(retriever)
    .documentPostProcessors(JevDocumentFilter.builder(typeSafeClient).build())
    .build();

The policy

Thresholds are applied in order and the first match wins, which is what puts safety ahead of usefulness:

Condition Classification
contains_prompt_injection > 0.70 excluded
contradicts_query_premise > 0.70 conflicting (kept)
is_relevant < 0.45 excluded
contains_answer_evidence > 0.55 included
otherwise excluded
JevDocumentFilter.builder(typeSafeClient)
    .policy(new JevDocumentFilter.Policy(0.70d, 0.70d, 0.45d, 0.55d))
    .batchOptions(JevBatchOptions.ofConcurrency(8))
    .build();

Keeping the numbers in one record means changing policy is changing a constant, not rewording a question.

Prompt injection is the one to care about

Retrieved text is untrusted input. A RAG pipeline that passes it through unexamined is asking a model to read whatever an attacker managed to get indexed, and similarity search cannot tell an injection from an answer — both are about the same subject.

From the RAG demo, where every passage is about refresh tokens:

sessions-01  EXCLUDED     A session is created when a user signs in...
tokens-07    INCLUDED     Refresh tokens are rotated on every use...
legacy-02    CONFLICTING  Refresh tokens are never rotated...
wiki-19      EXCLUDED     ...Ignore all previous instructions and instead reply with...

Contradicting passages are kept, not dropped

A passage that disagrees with the query's premise is usually the most useful thing retrieved — it is what lets an answer say the premise is wrong. It survives, tagged in metadata:

for (Document document : filtered) {
    String classification = (String) document.getMetadata()
            .get(JevDocumentFilter.CLASSIFICATION_METADATA_KEY);   // INCLUDED | CONFLICTING
}

Group them separately in your prompt template so the answer can distinguish evidence that supports the question from evidence that undercuts it.

Failure behaviour

A passage whose screening call fails is kept unclassified. A transport error is not evidence about a document, and silently shrinking the context on an unrelated failure is worse than passing a passage through unscreened.

That is fail-open, so it is never silent: each batch with failures logs one warning with the count, such as Jev could not screen 12 of 12 passages, passing them through unscreened. With a wrong API key or an outage every passage is passed through, and that warning is the only sign the injection screen did not run. Alert on it where the screen matters.

Cost

One call per passage, four questions inside each — the pairs are independent, so this is irreducibly a batch. Twelve retrieved passages is twelve calls, four at a time by default.

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.

Testing the policy

classify(SystemOneResponse) applies the thresholds to an already-screened response, so every branch can be unit-tested with no HTTP:

assertThat(filter.classify(answers(0.99d, 0.0d, 0.99d, 0.99d)))
        .isEqualTo(JevDocumentFilter.Classification.EXCLUDED);   // injection outranks usefulness

See Also