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
- Weighted sum. Normalize each score to 0...1 and add them. Fast, no extra model.
- On-device judge. Ask
SystemLanguageModelto 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. - Spotlight only. If you stayed on Path A, skip this merge and let
SpotlightSearchToolfeed 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.