Skip to content

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 whenFalse criterion 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.

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.