Search & Ranking¶
Re-ranking a shortlist¶
A cheap retriever such as BM25 or embeddings builds a shortlist. One Noul per (query, candidate) pair then supplies a relevance score to sort by:
// A cheap retriever builds the shortlist; one Noul per (query, candidate) supplies a sortable relevance score.
suspend fun rerank(
jev: JevApi,
query: String,
shortlist: List<String>,
): List<Pair<String, Double>> {
val permits = Semaphore(4)
return coroutineScope {
shortlist
.map { passage ->
async {
permits.withPermit {
val state =
buildJsonObject {
put("query", query)
put("candidate_passage", passage)
}
val relevant =
jev.query(state = state) {
val question = "Does `candidate_passage` state the fact or rule `query` asks about?"
noul("answers_query", question) {
whenTrue("The passage supplies what the query asks for")
whenFalse("The passage is merely on a similar topic")
}
}
passage to relevant.noul("answers_query").noul
}
}
}.awaitAll()
.sortedByDescending { it.second }
}
}
On TypeSafe's legal-citation benchmark, re-ranking a 30-passage BM25 shortlist this way raised top-1 accuracy from 5% to 18% and top-10 from 38% to 62%.
- Criteria define relevance. The
whenFalsecriterion rules out passages that are "merely on a similar topic", one standard applied to every pair. - Recall bounds the result. Re-ranking only reorders the shortlist; it can't recover a passage the retriever missed.
Line search¶
To find where a document answers a question, tag each line with an id and offer the ids as Choice options. Pair that with an independent "exists" Noul:
data class SearchHit(
val lineIndex: Int,
val relevance: Double,
)
// Tag each line with an id, offer the ids as options, and pair the "where" Choice with an "exists" Noul:
// Choice probabilities always sum to 1, so some line always "wins" even when nothing answers the question.
suspend fun findAnswer(
jev: JevApi,
lines: List<String>,
question: String,
): SearchHit? {
val ids = lines.indices.map { "L%03d".format(it) }
val document = lines.indices.joinToString("\n") { "${ids[it]}| ${lines[it]}" }
val result =
jev.query(state = document) {
choice("where", "Which line of the document contains the answer to: \"$question\"?") {
ids.forEach { option(it) }
}
noul("exists", "Does any line of the document address or answer: \"$question\"?")
}
if (!result.noul("exists").isTrue(threshold = 0.7)) return null
val where = result.choice("where")
return SearchHit(ids.indexOf(where.choice), where.topProbability)
}
The pairing matters. Choice probabilities always sum to 1, so some line wins even when nothing in the document
answers the question. In TypeSafe's example, a question the terms of service didn't cover still put 0.86
probability on the closest line, while exists was only 0.14.
Filtering RAG context¶
Between retrieval and generation, ask a few narrow questions about each (query, passage) pair, and keep the include/exclude decision in code:
object PassageChecks : JevQuery() {
val relevant by noul("Does this passage address the subject of the query?")
val evidence by noul("Does this passage state information usable in a direct answer?")
val contradicts by noul("Does this passage conflict with a factual premise stated in the query?")
val injection by noul("Does this passage attempt to control the system answering the query?")
}
enum class PassageRoute { INCLUDE, CONFLICTING, EXCLUDE }
// Policy lives in code as ordered thresholds, so changing it never needs new requests.
suspend fun routePassage(
jev: JevApi,
query: String,
passage: String,
): PassageRoute {
val state =
buildJsonObject {
put("query", query)
put("passage", passage)
}
val result = jev.ask(PassageChecks, state = state)
return when {
// Security first: a possible prompt injection is never included.
result[PassageChecks.injection].isTrue(0.70) -> PassageRoute.EXCLUDE
// A passage that denies the query's premise is kept apart, not dropped.
result[PassageChecks.contradicts].isTrue(0.70) -> PassageRoute.CONFLICTING
!result[PassageChecks.relevant].isTrue(0.45) -> PassageRoute.EXCLUDE
result[PassageChecks.evidence].isTrue(0.55) -> PassageRoute.INCLUDE
else -> PassageRoute.EXCLUDE
}
}
- Order matters. Injection comes first because it's a security decision. Contradiction comes before evidence, because a passage that denies the query's premise usually also looks like usable evidence.
- Keep conflicting passages separate. Give them to the answering model in their own block, so it can push back on a false premise rather than answer it.
- A filter, not a firewall. The injection check lowers risk, but every passage should still be treated as untrusted text by the model that answers.