Environment setup
Xcode 27. Add Core AI. Look up a text embedding preset in apple/coreai-models utility models, or a Foundation Models embedding helper. Confirm the exact load API in the installed SDK. WWDC26 session 326 shows custom models behind familiar session types. An embedder may be an AIModel function, not a CoreAILanguageModel.
Dense search finds "time of the dentist" when the note says "cleaning at 3pm." It is weak on exact ids. That is why BM25 stays.
import CoreAI
// Illustrative. Confirm AIModel / NDArray names in the installed SDK.
struct DenseIndex {
var rows: [(chunk: NoteChunk, vector: [Float])] = []
let embed: @Sendable (String) async throws -> [Float]
mutating func build(_ chunks: [NoteChunk]) async throws {
rows = []
for chunk in chunks {
rows.append((chunk, try await embed(chunk.text)))
}
}
func search(_ query: String, limit: Int = 8) async throws -> [(NoteChunk, Float)] {
let q = try await embed(query)
return rows
.map { ($0.chunk, cosine($0.vector, q)) }
.sorted { $0.1 > $1.1 }
.prefix(limit)
.map { $0 }
}
}
Prepare the embedder once. Re-embed a document when it changes. Write the model name and dimension beside the index file, same contract as the semantic cache course.
If your SDK has no on-device embedder, ship BM25 plus SpotlightSearchTool and write that limit in the README. Do not call a hosted embedding API.
SystemLanguageModel to "return a vector." That is not an embedding API.Next, merge the two lists and rerank.
Key concepts
- Embed each chunk once, embed the query, rank by cosine similarity.
- Use a Core AI catalog embedder, not
CoreAILanguageModeland not a chat prompt that returns a vector. - Dense finds meaning when words do not match. It is weak on exact ids.
- Write model name and dimension beside the index file.
Takeaways
- Do not prompt
SystemLanguageModelto return a vector. - If no on-device embedder exists, ship BM25 plus Spotlight and document the limit.
- Do not call a hosted embedding API.