← All courses ← Course home

Lesson 05 of 05

Rerank and hand hits to the session

Union BM25 and dense hits. Rerank that short list. Pack the winners into a Dynamic Profile. Keep the files on the phone.

Agent brief (llms.md)

Rerank works on tens of chunks, not thousands. First merge unique ids from both lanes. Then score the merge.

BM25Word hits
DenseMeaning hits
UnionShort list
RerankFinal pack

Honest rerank options

  1. Weighted sum. Normalize each score to 0...1 and add them. Fast, no extra model.
  2. On-device judge. Ask SystemLanguageModel to pick the three passages that answer the question. Use structured output if the SDK has @Generable. This costs a small generate. Do not send passages to a public model.
  3. Spotlight only. If you stayed on Path A, skip this merge and let SpotlightSearchTool feed the session. Still cap what you put in instructions.
func hybrid(
    bm25: [(NoteChunk, Double)],
    dense: [(NoteChunk, Float)],
    limit: Int = 4
) -> [NoteChunk] {
    func scale(_ raw: [Double]) -> [Double] {
        let peak = raw.max() ?? 1
        return raw.map { peak == 0 ? 0 : $0 / peak }
    }
    var scores: [String: Double] = [:]
    var byId: [String: NoteChunk] = [:]
    let lexical = scale(bm25.map(\.1))
    for (i, pair) in bm25.enumerated() {
        scores[pair.0.id, default: 0] += 0.5 * lexical[i]
        byId[pair.0.id] = pair.0
    }
    let semantic = scale(dense.map { Double($0.1) })
    for (i, pair) in dense.enumerated() {
        scores[pair.0.id, default: 0] += 0.5 * semantic[i]
        byId[pair.0.id] = pair.0
    }
    return scores.sorted { $0.value > $1.value }.prefix(limit).compactMap { byId[$0.key] }
}

Pass the four chunks to the context assembler as search-lane slices. The session sees them through one Dynamic Profile, the same pack pattern as the context packer.

import FoundationModels

// Illustrative. Confirm DynamicProfile in the installed SDK.
struct SearchPack: LanguageModelSession.DynamicProfile {
    let hits: [NoteChunk]

    var body: some LanguageModelSession.DynamicProfile {
        Profile {
            Instructions {
                "Answer using only these notes. If a fact is missing, say so."
                for hit in hits {
                    "[search] \(hit.text)"
                }
            }
        }
    }
}

let session = LanguageModelSession(profile: SearchPack(hits: winners))

Sources: WWDC26 246 for SpotlightSearchTool, WWDC26 326 for Core AI models, Core AI.

Done. You can find a private note with words, meaning, or Spotlight, and you can say which API did the work.

Key concepts

  • Union BM25 and dense hits by id. Rerank tens of chunks, not thousands.
  • Rerank can be a weighted sum or an on-device judge.
  • Pass winners to the context assembler as search-lane slices.
  • A Dynamic Profile can put those hits in Instructions.

Takeaways

  • Pass a small number of chunks, such as four, to the packer.
  • LanguageModelSession(profile:) is the hand-off pattern.
  • Cap what you put in instructions even on the Spotlight path.