Build a Mac App with AI Memory
Build a native macOS chat app with a local LLM and semantic memory.
Introduction
30 Second Summary
AI assistants forget everything between sessions. Every time you close a chat window or start a new conversation, you lose all the context you built up. What if your AI could remember what you have told it before and use that knowledge to give better answers?
In this project, you will build a native macOS app that chats with a local AI model running entirely on your machine. The app remembers past conversations and retrieves relevant knowledge by meaning, creating an assistant that gets smarter over time with zero API costs.
What You'll Build
A native macOS chat application called MemoryAssistant that connects to a locally-running LLM via Ollama, persists all conversations using SwiftData, and builds a semantic knowledge base powered by Apple's NLEmbedding framework.
By the end of this project, you'll have:
- A SwiftUI Mac app with a sidebar of chat sessions and a streaming chat interface.
- An Ollama service layer that streams responses token-by-token from a local model running on your machine.
- A semantic knowledge base that stores and searches text by meaning using 512-dimensional sentence vectors.
- Secret Mission: Wire the knowledge base into every chat request so the model automatically receives relevant past context before generating a response.
Are there any prerequisites?
This project assumes you have experience writing Swift code and are familiar with SwiftUI basics. You will also need a Mac running macOS 14 (Sonoma) or newer with an Apple Silicon chip.
Set Up Ollama and Create the Xcode Project
Building a Mac app that talks to a local AI model starts with two foundations: the model server that runs the LLM, and the Xcode project that becomes your app.
With Ollama already running your local model, this step focuses on creating the MemoryAssistant project in Xcode and organizing its folder structure for the code you'll write in the next steps.
In this step, get ready to:
- Confirm Ollama and the qwen2.5-coder:3b model are responding.
- Create the MemoryAssistant project in Xcode with SwiftUI and SwiftData.
- Organize the project into Models, Services, and Views folders with placeholder files.
Confirm Ollama and the model are ready
Before diving into the Xcode project, confirm that your local model server is responding correctly.
- Open your terminal and test the model by running this command:
ollama run qwen2.5-coder:3b "Say hello in Swift"
You should see the model generate a short Swift code snippet (something like a print("Hello") statement). The exact output varies each time.
What is Ollama?
Ollama is a local model server that runs large language models directly on your machine. It exposes a REST API at http://localhost:11434 that your app will use to send prompts and receive streaming responses.
The qwen2.5-coder:3b model is a 1.9GB coding-focused model small enough to run comfortably on an 8GB M1 Mac alongside Xcode.
Not seeing a response?
Check that Ollama is running by looking for its icon in your menu bar. If it is not there, open Ollama from your Applications folder.
If you see a connection error, the Ollama server may not have started yet. Give it a few seconds after launching, then try the command again.
help me troubleshoot why Ollama is not responding to my test prompt
Create the MemoryAssistant project in Xcode
Now that the model server is confirmed working, you'll create the Xcode project that will house your entire app.
- In Xcode, click File in the menu bar.
- Click New, then Project.
- Select the macOS tab at the top of the template chooser.
- Select App and click Next.
- Enter MemoryAssistant in the Product Name field.
- Set Interface to SwiftUI.
- Set Storage to SwiftData.
- Click Next, choose your Desktop as the save location, then click Create.
Why SwiftData?
SwiftData is Apple's modern persistence framework that integrates directly with SwiftUI through the @Model macro and @Query property wrapper. It handles saving sessions, messages, and knowledge entries without writing any SQL.
Next, set the deployment target so the app uses APIs available in macOS 14.0 and later.
- In the Project Navigator on the left, click the top-level MemoryAssistant project (the blue icon).
- In the center panel, make sure the MemoryAssistant target is selected under TARGETS.
- Click the General tab.
- Under Minimum Deployments, set the macOS version to 14.0.
Why macOS 14.0?
SwiftData requires macOS 14.0 (Sonoma) or newer. Since your machine runs macOS 15.7.3, you are well above this minimum. Setting the deployment target to 14.0 ensures the app can run on any Mac with Sonoma or later.
The app will use Apple's NaturalLanguage framework for generating text embeddings. Since it is a system framework, you only need to import it in code. No manual linking is required.
- Delete the Item.swift file that Xcode generated (right-click it in the Project Navigator, then click Delete and choose Move to Trash).
Why delete Item.swift?
Xcode's SwiftData template creates a sample Item model you don't need. You'll replace it with your own models (Session, Message, and KnowledgeEntry) in the next step.
Set up the project structure
Organizing code into folders keeps the project manageable as it grows. You'll create three groups: one for data models, one for services, and one for views.
- In the Project Navigator, right-click the MemoryAssistant folder (the yellow folder icon, not the blue project icon).
- Click New Group.
- Name the new group Models.
- Repeat to create two more groups: Services and Views.
Now create placeholder files inside each group. These will hold the actual code you write in later steps.
- Right-click the Models group and click New File.
- Select Swift File and click Next.
- Name the file Session and click Create.
- Repeat to create Message.swift and KnowledgeEntry.swift in the Models group.
- In the Services group, create OllamaService.swift and KnowledgeService.swift.
- In the Views group, create ChatView.swift and SidebarView.swift.
- Drag the existing ContentView.swift from the root level into the Views group.
What does each group hold?
- Models holds your SwiftData model definitions (Session, Message, KnowledgeEntry).
- Services holds the classes that talk to Ollama and manage the knowledge base.
- Views holds the SwiftUI views that make up the user interface.
Replace the contents of each new placeholder file with a single comment so Xcode doesn't complain about empty files.
- Open each placeholder file (Session.swift, Message.swift, KnowledgeEntry.swift, OllamaService.swift, KnowledgeService.swift, ChatView.swift, SidebarView.swift) and replace all existing content with:
// placeholder
- Do the same for ContentView.swift in the Views group. Replace its entire contents with the same // placeholder comment.
Now update the main app entry point to register the SwiftData models you'll define in the next step.
- Open MemoryAssistantApp.swift and replace its entire contents with the following code:
import SwiftUI
import SwiftData
@main
struct MemoryAssistantApp: App {
var body: some Scene {
WindowGroup {
// Main view that will show sidebar + chat
ContentView()
}
// Register all SwiftData models for persistence
.modelContainer(for: [Session.self, Message.self, KnowledgeEntry.self])
}
}
What does this code do?
- The .modelContainer(for:) modifier creates a SwiftData database that can store Sessions, Messages, and KnowledgeEntries.
- Any view in the app can access this container through the @Environment(\.modelContext) property wrapper.
- The model types don't exist yet (they are just // placeholder comments), so Xcode will show build errors until you define them in Step 2.
Build errors are expected
The project will show errors because MemoryAssistantApp.swift references Session, Message, KnowledgeEntry, and ContentView which are currently just placeholder comments. These errors resolve in Step 2 once you write the actual model definitions.
- Save all files with Cmd+S.
- Verify your Project Navigator shows the correct folder structure: Models/ (3 files), Services/ (2 files), Views/ (3 files), and MemoryAssistantApp.swift at the root.
Files not appearing in the right group?
If a file ended up in the wrong group, drag it to the correct group in the Project Navigator. Make sure you are dragging within the navigator sidebar, not in Finder.
If you accidentally created a file outside the project, delete it and use right-click on the correct group to create a new one.
help me fix my Xcode project structure for MemoryAssistant
✔️ Awesome, I've got everything!
Great. Double check you have saved all files. Your project structure is ready for the actual model definitions coming in Step 2.
ⓧ I'd like to double check the full code
Here is the full MemoryAssistantApp.swift for comparison:
import SwiftUI
import SwiftData
@main
struct MemoryAssistantApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Session.self, Message.self, KnowledgeEntry.self])
}
}
All other files (Session.swift, Message.swift, KnowledgeEntry.swift, OllamaService.swift, KnowledgeService.swift, ChatView.swift, ContentView.swift, SidebarView.swift) should contain only:
// placeholder
Your project skeleton is in place. Next up, you'll fill in the data models and build the chat interface that lets you talk to the local LLM.
Build the Chat Interface
Your project compiles with empty placeholder files, but there's nothing to see yet. To turn MemoryAssistant into a real app, you need data models that define how sessions and messages are stored, and views that display them on screen.
In this step, you'll define the SwiftData models for sessions, messages, and knowledge entries. Then you'll build the chat interface and a sidebar for navigating between conversations.
In this step, get ready to:
- Define SwiftData models for sessions, messages, and knowledge entries.
- Build a chat view with a message list and text input.
- Create a sidebar with session navigation and a New Chat button.
Create the data models
SwiftData uses the @Model macro to turn regular Swift classes into persistent objects that survive app restarts. You need three models: a Session to group messages, a Message to store each chat turn, and a KnowledgeEntry to hold semantic vectors later.
- In Xcode, open Models/Session.swift from the project navigator.
- Replace the placeholder comment with the following code:
import Foundation
import SwiftData
@Model
final class Session {
var id: UUID
var title: String
var createdAt: Date
// Cascade delete removes all messages when a session is deleted
@Relationship(deleteRule: .cascade, inverse: \Message.session)
var messages: [Message]
init(title: String = "New Chat") {
self.id = UUID()
self.title = title
self.createdAt = Date()
self.messages = []
}
}
What does this code do?
The @Model macro tells SwiftData to persist this class to disk automatically.
The @Relationship line creates a one-to-many link between Session and Message. The .cascade delete rule means deleting a session also deletes all its messages. The inverse parameter tells SwiftData which property on Message points back to Session.
- Save Models/Session.swift.
- You'll see a compile error about Message not being found. That's expected because you haven't defined it yet.
- Open Models/Message.swift and replace the placeholder with this code:
import Foundation
import SwiftData
@Model
final class Message {
var id: UUID
var role: String
var content: String
var createdAt: Date
// Links each message back to its parent session
var session: Session?
init(role: String, content: String, session: Session? = nil) {
self.id = UUID()
self.role = role
self.content = content
self.createdAt = Date()
self.session = session
}
}
What does this code do?
Each Message stores a role (either "user" or "assistant") and the message content text.
The optional session property is the inverse side of the relationship you defined on Session. SwiftData uses this to link messages back to their parent session automatically.
- Save Models/Message.swift.
- The compile error on Session.swift should now resolve since both sides of the relationship exist.
Still seeing a compile error?
Make sure both files use var (not let) for all properties. SwiftData requires all @Model properties to be mutable.
Also check that import SwiftData is at the top of each file. Help me fix my SwiftData model compile error
- Open Models/KnowledgeEntry.swift and replace the placeholder with this code:
import Foundation
import SwiftData
@Model
final class KnowledgeEntry {
var id: UUID
var content: String
// Stores the 512-dimensional embedding vector as raw bytes
var embedding: Data
var createdAt: Date
init(content: String, embedding: Data) {
self.id = UUID()
self.content = content
self.embedding = embedding
self.createdAt = Date()
}
}
What does this code do?
KnowledgeEntry stores a piece of text alongside its embedding vector. The embedding is a 512-dimensional array of doubles, stored as Data (raw bytes) because SwiftData can persist Data natively.
You'll use this model in Step 4 when you build the semantic knowledge base. For now, it just needs to exist so the app's model container can register it.
- Save Models/KnowledgeEntry.swift.
- Press Cmd+B to build the project. It should compile with no errors now that all three models are defined.
✔️ It compiled successfully
All three models are in place. Next you'll build the views that display them.
ⓧ I see build errors
- Check that all three model files have import Foundation and import SwiftData at the top.
- Verify every property is declared with var, not let.
- Make sure the @Relationship keypath is exactly \Message.session with a backslash, not a forward slash.
- Try Product > Clean Build Folder (Cmd+Shift+K) then build again.
✔️ Awesome, I've got everything!
Great. Make sure all three model files are saved before moving on.
ⓧ I'd like to double check the full code
import Foundation
import SwiftData
@Model
final class Session {
var id: UUID
var title: String
var createdAt: Date
@Relationship(deleteRule: .cascade, inverse: \Message.session)
var messages: [Message]
init(title: String = "New Chat") {
self.id = UUID()
self.title = title
self.createdAt = Date()
self.messages = []
}
}
import Foundation
import SwiftData
@Model
final class Message {
var id: UUID
var role: String
var content: String
var createdAt: Date
var session: Session?
init(role: String, content: String, session: Session? = nil) {
self.id = UUID()
self.role = role
self.content = content
self.createdAt = Date()
self.session = session
}
}
import Foundation
import SwiftData
@Model
final class KnowledgeEntry {
var id: UUID
var content: String
var embedding: Data
var createdAt: Date
init(content: String, embedding: Data) {
self.id = UUID()
self.content = content
self.embedding = embedding
self.createdAt = Date()
}
}
Build the chat view with message list and input
The chat view is where the learner interacts with the LLM. It needs a scrollable message list with user messages on the right and assistant messages on the left, plus a text input field at the bottom.
- Open Views/ChatView.swift and replace the placeholder with the following code:
import SwiftUI
import SwiftData
struct ChatView: View {
let session: Session
@Environment(\.modelContext) private var modelContext
@State private var inputText = ""
@State private var isGenerating = false
@State private var streamingContent = ""
var body: some View {
VStack(spacing: 0) {
// Scrollable message list
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(session.messages.sorted(by: { $0.createdAt < $1.createdAt })) { message in
MessageBubble(message: message, onSaveKnowledge: {})
.id(message.id)
}
}
.padding(.vertical)
}
}
Divider()
// Input field and send button
HStack {
TextField("Type a message...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
Button(action: {}) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isGenerating)
}
.padding()
}
}
}
// Displays a single message bubble aligned by role
struct MessageBubble: View {
let message: Message
let onSaveKnowledge: () -> Void
var body: some View {
HStack {
if message.role == "user" { Spacer() }
Text(message.content)
.padding(12)
.background(message.role == "user" ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.cornerRadius(12)
if message.role == "assistant" { Spacer() }
}
.padding(.horizontal)
}
}
What does this code do?
ChatView takes a Session and displays its messages in a scrollable list. The ScrollViewReader allows you to programmatically scroll to new messages later.
The send button is disabled when the input is empty or when the model is generating a response. The button action is empty for now. You'll wire it to Ollama in the next step.
MessageBubble uses a simple Spacer() trick. User messages get a spacer on the left (pushing them right), and assistant messages get a spacer on the right (pushing them left).
- Save Views/ChatView.swift.
- The project won't build yet because ContentView still has placeholder content. You'll fix that next.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
import SwiftUI
import SwiftData
struct ChatView: View {
let session: Session
@Environment(\.modelContext) private var modelContext
@State private var inputText = ""
@State private var isGenerating = false
@State private var streamingContent = ""
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(session.messages.sorted(by: { $0.createdAt < $1.createdAt })) { message in
MessageBubble(message: message, onSaveKnowledge: {})
.id(message.id)
}
}
.padding(.vertical)
}
}
Divider()
HStack {
TextField("Type a message...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
Button(action: {}) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isGenerating)
}
.padding()
}
}
}
struct MessageBubble: View {
let message: Message
let onSaveKnowledge: () -> Void
var body: some View {
HStack {
if message.role == "user" { Spacer() }
Text(message.content)
.padding(12)
.background(message.role == "user" ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.cornerRadius(12)
if message.role == "assistant" { Spacer() }
}
.padding(.horizontal)
}
}
Build the sidebar with session list
A Mac app needs a sidebar for navigation. You'll create a NavigationSplitView that shows sessions on the left and the selected session's chat on the right.
- Open Views/SidebarView.swift and replace the placeholder with this code:
import SwiftUI
import SwiftData
struct SidebarView: View {
let sessions: [Session]
@Binding var selectedSession: Session?
let onNewSession: () -> Void
var body: some View {
VStack {
// List of sessions with selection binding
List(selection: $selectedSession) {
Section("Sessions") {
ForEach(sessions) { session in
Text(session.title)
.tag(session)
}
}
}
}
// New Chat button pinned to the top of the sidebar
.safeAreaInset(edge: .top) {
Button(action: onNewSession) {
Label("New Chat", systemImage: "plus.circle.fill")
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}
What does this code do?
SidebarView receives its data from the parent (sessions, selection binding, and a closure to create new sessions). This keeps it simple and testable.
The .safeAreaInset(edge: .top) modifier pins the New Chat button above the list so it's always visible, even when you scroll.
- Save Views/SidebarView.swift.
- Now open Views/ContentView.swift and replace the placeholder with this code:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
// Fetch all sessions, newest first
@Query(sort: \Session.createdAt, order: .reverse) private var sessions: [Session]
@State private var selectedSession: Session?
var body: some View {
NavigationSplitView {
SidebarView(
sessions: sessions,
selectedSession: $selectedSession,
onNewSession: createSession
)
} detail: {
if let session = selectedSession {
ChatView(session: session)
} else {
Text("Select or create a session to start chatting")
.foregroundStyle(.secondary)
}
}
.frame(minWidth: 700, minHeight: 500)
}
// Creates a new session and selects it
private func createSession() {
let session = Session(title: "Chat \(sessions.count + 1)")
modelContext.insert(session)
try? modelContext.save()
selectedSession = session
}
}
What does this code do?
ContentView is the app's root view. The @Query property wrapper automatically fetches all Session objects from SwiftData, sorted by creation date (newest first).
The NavigationSplitView creates the two-pane layout standard in Mac apps. The sidebar on the left shows sessions. The detail pane on the right shows the selected session's ChatView.
The createSession() function creates a new Session, inserts it into SwiftData, saves immediately, and selects it so the chat view appears.
- Save Views/ContentView.swift.
- Press Cmd+R to build and run the app.
- You should see a window with a sidebar on the left showing a New Chat button and an empty "Sessions" section.
- Click New Chat to create a session. The detail pane on the right should show the chat interface with an empty message area and a text input field.
App crashes on launch?
If you see a crash mentioning the model container, make sure MemoryAssistantApp.swift still has .modelContainer(for: [Session.self, Message.self, KnowledgeEntry.self]) referencing all three model types.
If it was working before and crashes after adding the models, try Product > Clean Build Folder (Cmd+Shift+K) and run again. Help me debug my SwiftData app crash on launch
✔️ Awesome, I've got everything!
Great. Double check you've saved both view files.
ⓧ I'd like to double check the full code
import SwiftUI
import SwiftData
struct SidebarView: View {
let sessions: [Session]
@Binding var selectedSession: Session?
let onNewSession: () -> Void
var body: some View {
VStack {
List(selection: $selectedSession) {
Section("Sessions") {
ForEach(sessions) { session in
Text(session.title)
.tag(session)
}
}
}
}
.safeAreaInset(edge: .top) {
Button(action: onNewSession) {
Label("New Chat", systemImage: "plus.circle.fill")
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Session.createdAt, order: .reverse) private var sessions: [Session]
@State private var selectedSession: Session?
var body: some View {
NavigationSplitView {
SidebarView(
sessions: sessions,
selectedSession: $selectedSession,
onNewSession: createSession
)
} detail: {
if let session = selectedSession {
ChatView(session: session)
} else {
Text("Select or create a session to start chatting")
.foregroundStyle(.secondary)
}
}
.frame(minWidth: 700, minHeight: 500)
}
private func createSession() {
let session = Session(title: "Chat \(sessions.count + 1)")
modelContext.insert(session)
try? modelContext.save()
selectedSession = session
}
}
Your app now has a working layout with data persistence. Sessions are saved to disk, so they'll still be there if you quit and relaunch. Next up, you'll connect the chat to Ollama so the send button actually generates responses from your local LLM.
Connect to Ollama with Streaming
You have a chat interface that looks great, but it does not actually talk to anything yet. In this step, you will connect your app to the local Ollama server and stream responses token-by-token into the chat.
Ollama exposes a REST API at http://localhost:11434 that accepts chat requests and streams back responses in NDJSON format. Your app will send conversation history to this endpoint and display each token as it arrives, creating the familiar "typing" effect.
In this step, get ready to:
- Build an OllamaService class that streams chat responses from the local model.
- Wire the send button to call the service and display streaming responses in real time.
Build the Ollama service
The Ollama chat API uses a streaming format called NDJSON (newline-delimited JSON). Each line the server sends is a complete JSON object containing a message.content field with the next token and a done field indicating whether generation is complete.
- Open Services/OllamaService.swift in Xcode.
- Replace the placeholder comment with the following data model structs:
import Foundation
struct ChatMessage: Codable {
let role: String
let content: String
}
struct OllamaChatRequest: Codable {
let model: String
let messages: [ChatMessage]
let stream: Bool
}
struct OllamaChatResponse: Codable {
let message: ResponseMessage?
let done: Bool
struct ResponseMessage: Codable {
let role: String
let content: String
}
}
What do these structs do?
ChatMessage represents a single message in the conversation (either from the user or the assistant).
OllamaChatRequest is the JSON body you send to Ollama's /api/chat endpoint. It includes the model name, the full conversation history, and a flag to enable streaming.
OllamaChatResponse decodes each NDJSON line from the server. The message?.content holds the token text, and done tells you when the model has finished generating.
- Add the OllamaService class below the structs you just added:
class OllamaService {
private let baseURL = "http://localhost:11434"
private let model = "qwen2.5-coder:3b"
func chat(messages: [ChatMessage]) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let url = URL(string: "\(baseURL)/api/chat")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let chatRequest = OllamaChatRequest(
model: model,
messages: messages,
stream: true
)
request.httpBody = try JSONEncoder().encode(chatRequest)
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
continuation.finish(throwing: URLError(.badServerResponse))
return
}
for try await line in bytes.lines {
guard !line.isEmpty else { continue }
guard let data = line.data(using: .utf8) else { continue }
let decoded = try JSONDecoder().decode(OllamaChatResponse.self, from: data)
if let content = decoded.message?.content, !content.isEmpty {
continuation.yield(content)
}
if decoded.done {
continuation.finish()
return
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
What does this service do?
The chat(messages:) method returns an AsyncThrowingStream of string tokens. This is Swift's way of providing a stream you can loop over with for try await.
Inside, it sends a POST request to Ollama and reads the response using URLSession.shared.bytes(for:). Each line in the response is decoded as JSON. The message.content field from each line is yielded to the stream, giving you one token at a time.
- Save OllamaService.swift.
- Build the project with Cmd+B to confirm it compiles without errors.
Seeing a build error?
Make sure your import Foundation is at the very top of the file. All three structs and the class should be in the same file.
help me fix a build error in OllamaService.swift
Wire the service into ChatView
The service is ready to stream tokens. Now you need to connect it to the send button so that typing a message triggers a call to Ollama and the response appears character-by-character in the chat.
Before updating ChatView, you need a minimal KnowledgeService stub so the project compiles. The full implementation comes in the next step.
- Open Services/KnowledgeService.swift and replace the placeholder comment with this temporary stub:
import Foundation
import SwiftData
class KnowledgeService {
func saveKnowledge(content: String, context: ModelContext) {
// Full implementation coming in Step 4
}
}
Why add a stub now?
The updated ChatView will reference KnowledgeService for the "Save as Knowledge" button. Without at least a class definition, Xcode refuses to compile. The method body is empty for now and will be filled in when you build the knowledge base.
- Open Views/ChatView.swift and replace the entire file contents with:
import SwiftUI
import SwiftData
struct ChatView: View {
let session: Session
@Environment(\.modelContext) private var modelContext
@State private var inputText = ""
@State private var isGenerating = false
@State private var streamingContent = ""
private let ollamaService = OllamaService()
private let knowledgeService = KnowledgeService()
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(session.messages.sorted(by: { $0.createdAt < $1.createdAt })) { message in
MessageBubble(message: message, onSaveKnowledge: {
knowledgeService.saveKnowledge(
content: message.content,
context: modelContext
)
})
.id(message.id)
}
if isGenerating && !streamingContent.isEmpty {
HStack {
Text(streamingContent)
.padding(12)
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Spacer()
}
.padding(.horizontal)
.id("streaming")
}
}
.padding(.vertical)
}
.onChange(of: streamingContent) {
proxy.scrollTo("streaming", anchor: .bottom)
}
}
Divider()
HStack {
TextField("Type a message...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.onSubmit {
sendMessage()
}
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isGenerating)
.keyboardShortcut(.return, modifiers: .command)
}
.padding()
}
}
private func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isGenerating else { return }
inputText = ""
isGenerating = true
streamingContent = ""
let userMessage = Message(role: "user", content: text, session: session)
modelContext.insert(userMessage)
session.messages.append(userMessage)
Task {
do {
let chatMessages = session.messages
.sorted { $0.createdAt < $1.createdAt }
.map { ChatMessage(role: $0.role, content: $0.content) }
let stream = ollamaService.chat(messages: chatMessages)
for try await token in stream {
streamingContent += token
}
let assistantMessage = Message(
role: "assistant",
content: streamingContent,
session: session
)
modelContext.insert(assistantMessage)
session.messages.append(assistantMessage)
try? modelContext.save()
streamingContent = ""
isGenerating = false
} catch {
streamingContent = "Error: \(error.localizedDescription)"
isGenerating = false
}
}
}
}
struct MessageBubble: View {
let message: Message
let onSaveKnowledge: () -> Void
var body: some View {
HStack {
if message.role == "user" { Spacer() }
VStack(alignment: message.role == "user" ? .trailing : .leading) {
Text(message.content)
.padding(12)
.background(message.role == "user" ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.cornerRadius(12)
if message.role == "user" {
Button("Save as Knowledge") {
onSaveKnowledge()
}
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
if message.role == "assistant" { Spacer() }
}
.padding(.horizontal)
}
}
What changed in ChatView?
- Two service instances (ollamaService and knowledgeService) are stored as properties so the view can call them.
- The sendMessage() function inserts the user's message into SwiftData, converts the session history into ChatMessage structs, and streams the response from Ollama token-by-token.
- A streaming preview block appears below existing messages while the model generates. The .onChange(of: streamingContent) modifier auto-scrolls to keep the latest text visible.
- MessageBubble now includes a "Save as Knowledge" button under user messages, ready for the knowledge service in the next step.
- Save both KnowledgeService.swift and ChatView.swift.
- Build and run the app with Cmd+R.
- Create a new session and type a message like Hello, tell me about Swift.
- You should see the model's response stream in character-by-character, with text appearing progressively in a gray bubble.
Not seeing a response stream in?
- Make sure Ollama is running. Check for the Ollama icon in your menu bar. If it is not there, open Ollama from your Applications folder.
- Verify the model is loaded by running ollama list in Terminal. You should see qwen2.5-coder:3b in the output.
- If you see "Error: URLError" in the chat, Ollama's server may not be responding on port 11434. Quit and relaunch Ollama.
help me debug why my MemoryAssistant app is not receiving streaming responses from Ollama
- Close the app, reopen it, and select the session you just used.
- Your messages should still be there, confirming SwiftData persistence is working.
✔️ Awesome, I've got everything!
Great. Double check you have saved both OllamaService.swift and ChatView.swift.
ⓧ I'd like to double check the full code
Here is the full Services/OllamaService.swift:
import Foundation
struct ChatMessage: Codable {
let role: String
let content: String
}
struct OllamaChatRequest: Codable {
let model: String
let messages: [ChatMessage]
let stream: Bool
}
struct OllamaChatResponse: Codable {
let message: ResponseMessage?
let done: Bool
struct ResponseMessage: Codable {
let role: String
let content: String
}
}
class OllamaService {
private let baseURL = "http://localhost:11434"
private let model = "qwen2.5-coder:3b"
func chat(messages: [ChatMessage]) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let url = URL(string: "\(baseURL)/api/chat")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let chatRequest = OllamaChatRequest(
model: model,
messages: messages,
stream: true
)
request.httpBody = try JSONEncoder().encode(chatRequest)
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
continuation.finish(throwing: URLError(.badServerResponse))
return
}
for try await line in bytes.lines {
guard !line.isEmpty else { continue }
guard let data = line.data(using: .utf8) else { continue }
let decoded = try JSONDecoder().decode(OllamaChatResponse.self, from: data)
if let content = decoded.message?.content, !content.isEmpty {
continuation.yield(content)
}
if decoded.done {
continuation.finish()
return
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
Here is the full Views/ChatView.swift:
import SwiftUI
import SwiftData
struct ChatView: View {
let session: Session
@Environment(\.modelContext) private var modelContext
@State private var inputText = ""
@State private var isGenerating = false
@State private var streamingContent = ""
private let ollamaService = OllamaService()
private let knowledgeService = KnowledgeService()
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(session.messages.sorted(by: { $0.createdAt < $1.createdAt })) { message in
MessageBubble(message: message, onSaveKnowledge: {
knowledgeService.saveKnowledge(
content: message.content,
context: modelContext
)
})
.id(message.id)
}
if isGenerating && !streamingContent.isEmpty {
HStack {
Text(streamingContent)
.padding(12)
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Spacer()
}
.padding(.horizontal)
.id("streaming")
}
}
.padding(.vertical)
}
.onChange(of: streamingContent) {
proxy.scrollTo("streaming", anchor: .bottom)
}
}
Divider()
HStack {
TextField("Type a message...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.onSubmit {
sendMessage()
}
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isGenerating)
.keyboardShortcut(.return, modifiers: .command)
}
.padding()
}
}
private func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isGenerating else { return }
inputText = ""
isGenerating = true
streamingContent = ""
let userMessage = Message(role: "user", content: text, session: session)
modelContext.insert(userMessage)
session.messages.append(userMessage)
Task {
do {
let chatMessages = session.messages
.sorted { $0.createdAt < $1.createdAt }
.map { ChatMessage(role: $0.role, content: $0.content) }
let stream = ollamaService.chat(messages: chatMessages)
for try await token in stream {
streamingContent += token
}
let assistantMessage = Message(
role: "assistant",
content: streamingContent,
session: session
)
modelContext.insert(assistantMessage)
session.messages.append(assistantMessage)
try? modelContext.save()
streamingContent = ""
isGenerating = false
} catch {
streamingContent = "Error: \(error.localizedDescription)"
isGenerating = false
}
}
}
}
struct MessageBubble: View {
let message: Message
let onSaveKnowledge: () -> Void
var body: some View {
HStack {
if message.role == "user" { Spacer() }
VStack(alignment: message.role == "user" ? .trailing : .leading) {
Text(message.content)
.padding(12)
.background(message.role == "user" ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.cornerRadius(12)
if message.role == "user" {
Button("Save as Knowledge") {
onSaveKnowledge()
}
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
if message.role == "assistant" { Spacer() }
}
.padding(.horizontal)
}
}
Your app is now a fully functional local chat client. Next up, you will build the semantic knowledge base that lets your assistant remember and retrieve information by meaning.
Build the Semantic Knowledge Base
Your chat app can talk, but it forgets everything between sessions. Each conversation starts from zero with no memory of what you told it before.
In this step, you'll build a semantic knowledge base using Apple's NLEmbedding framework. This converts text into 512-dimensional vectors that capture meaning, letting you search by concept rather than exact keywords.
In this step, get ready to:
- Implement the knowledge service with embedding, storage, and cosine similarity search.
- Test saving knowledge entries from chat messages.
- Add a semantic search interface to the sidebar.
Create the knowledge service with NLEmbedding
The knowledge service is the brain of your app's memory system. It handles three responsibilities: converting text into vectors, storing those vectors alongside their content, and searching across all stored entries by similarity.
Why NLEmbedding instead of an external model?
NLEmbedding ships with macOS. It produces 512-dimensional sentence vectors on-device instantly with zero downloads, no Python, and no external services.
The trade-off is quality. Dedicated embedding models like bge-m3 produce higher-fidelity vectors. For a personal knowledge base with user-authored content, NLEmbedding is more than sufficient.
- Open Services/KnowledgeService.swift in Xcode.
- Delete the placeholder comment and replace the entire file with this code:
import Foundation
import NaturalLanguage
import SwiftData
class KnowledgeService {
// Load Apple's built-in sentence embedding model for English
private let sentenceEmbedding: NLEmbedding?
init() {
self.sentenceEmbedding = NLEmbedding.sentenceEmbedding(for: .english)
}
// Convert any text string into a 512-dimensional vector
func embed(_ text: String) -> [Double]? {
guard let embedding = sentenceEmbedding else { return nil }
return embedding.vector(for: text)
}
}
What does this code do?
NLEmbedding.sentenceEmbedding(for: .english) loads Apple's pre-trained sentence embedding model. This model runs entirely on-device using the Neural Engine.
The embed(_:) method passes any text string through this model and returns an array of 512 doubles. Each number captures one dimension of the sentence's meaning.
Vectors are arrays of doubles, but SwiftData stores binary blobs as Data. You need conversion helpers and a way to compare two vectors mathematically.
- Add these three methods inside the KnowledgeService class, below the embed(_:) method:
// Convert a vector of doubles into binary Data for SwiftData storage
func vectorToData(_ vector: [Double]) -> Data {
vector.withUnsafeBufferPointer { buffer in
Data(buffer: buffer)
}
}
// Convert stored binary Data back into a vector of doubles
func dataToVector(_ data: Data) -> [Double] {
data.withUnsafeBytes { buffer in
Array(buffer.bindMemory(to: Double.self))
}
}
// Measure how similar two vectors are (1.0 = identical, 0.0 = unrelated)
func cosineSimilarity(_ a: [Double], _ b: [Double]) -> Double {
guard a.count == b.count, !a.isEmpty else { return 0 }
var dot = 0.0
var normA = 0.0
var normB = 0.0
for i in 0..<a.count {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
let denominator = sqrt(normA) * sqrt(normB)
guard denominator > 0 else { return 0 }
return dot / denominator
}
What does this code do?
- vectorToData(_:) converts a [Double] array into raw bytes so SwiftData can persist it in the KnowledgeEntry.embedding field.
- dataToVector(_:) reverses the process, reading the bytes back into a usable array.
- cosineSimilarity(_:_:) computes the angle between two vectors. A score of 1.0 means the sentences are semantically identical. A score near 0 means they are unrelated.
Why manual cosine similarity?
NLEmbedding sentence embeddings are dynamic and have no fixed vocabulary. Apple's built-in nearest-neighbor search does not apply to sentence embeddings. You must store vectors yourself and compute similarity manually.
The final piece connects the embedding logic to SwiftData for persistence and retrieval.
- Add these two methods at the bottom of the KnowledgeService class, below cosineSimilarity:
// Embed content and save it as a knowledge entry in SwiftData
func saveKnowledge(content: String, context: ModelContext) {
guard let vector = embed(content) else { return }
let entry = KnowledgeEntry(content: content, embedding: vectorToData(vector))
context.insert(entry)
try? context.save()
}
// Search all entries by cosine similarity and return the top results
func search(query: String, entries: [KnowledgeEntry], topK: Int = 5) -> [(entry: KnowledgeEntry, score: Double)] {
guard let queryVector = embed(query) else { return [] }
let scored = entries.map { entry in
let entryVector = dataToVector(entry.embedding)
let score = cosineSimilarity(queryVector, entryVector)
return (entry: entry, score: score)
}
return scored
.sorted { $0.score > $1.score }
.prefix(topK)
.map { $0 }
}
What does this code do?
- saveKnowledge(content:context:) embeds the text, wraps it in a KnowledgeEntry model object alongside the raw vector data, and persists it to SwiftData.
- search(query:entries:topK:) embeds the query, compares it against every stored entry using cosine similarity, then returns the top results sorted by relevance score.
- Save Services/KnowledgeService.swift.
- Press Cmd+B to build the project. You should see a successful build with no errors.
Seeing a build error?
- Make sure you imported both NaturalLanguage and SwiftData at the top of the file.
- Verify the closing brace } for the class appears after the search method.
- Check that your deployment target is macOS 14.0 or higher. NLEmbedding sentence embeddings require it.
help me fix a build error in KnowledgeService.swift
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import Foundation
import NaturalLanguage
import SwiftData
class KnowledgeService {
private let sentenceEmbedding: NLEmbedding?
init() {
self.sentenceEmbedding = NLEmbedding.sentenceEmbedding(for: .english)
}
func embed(_ text: String) -> [Double]? {
guard let embedding = sentenceEmbedding else { return nil }
return embedding.vector(for: text)
}
func vectorToData(_ vector: [Double]) -> Data {
vector.withUnsafeBufferPointer { buffer in
Data(buffer: buffer)
}
}
func dataToVector(_ data: Data) -> [Double] {
data.withUnsafeBytes { buffer in
Array(buffer.bindMemory(to: Double.self))
}
}
func cosineSimilarity(_ a: [Double], _ b: [Double]) -> Double {
guard a.count == b.count, !a.isEmpty else { return 0 }
var dot = 0.0
var normA = 0.0
var normB = 0.0
for i in 0..<a.count {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
let denominator = sqrt(normA) * sqrt(normB)
guard denominator > 0 else { return 0 }
return dot / denominator
}
func saveKnowledge(content: String, context: ModelContext) {
guard let vector = embed(content) else { return }
let entry = KnowledgeEntry(content: content, embedding: vectorToData(vector))
context.insert(entry)
try? context.save()
}
func search(query: String, entries: [KnowledgeEntry], topK: Int = 5) -> [(entry: KnowledgeEntry, score: Double)] {
guard let queryVector = embed(query) else { return [] }
let scored = entries.map { entry in
let entryVector = dataToVector(entry.embedding)
let score = cosineSimilarity(queryVector, entryVector)
return (entry: entry, score: score)
}
return scored
.sorted { $0.score > $1.score }
.prefix(topK)
.map { $0 }
}
}
Add knowledge extraction from conversations
Now that the knowledge service is implemented, the "Save as Knowledge" button already wired into your chat view will start working. This button calls knowledgeService.saveKnowledge(content:context:) under the hood.
- Run the app by pressing Cmd+R.
- Create a new chat session.
- Type a message like I always use MVVM in my projects and press Cmd+Return to send it.
- Wait for the model to respond, then send another message: My app targets iOS 17+.
- Click the Save as Knowledge button below each of your sent messages to store them.
You should see the button respond to your click without any errors. The entries are now persisted in SwiftData.
App crashes when clicking Save as Knowledge?
- Make sure KnowledgeEntry.self is included in the model container in MemoryAssistantApp.swift. It should already be there from your model setup.
- Verify that NLEmbedding.sentenceEmbedding(for: .english) returns a non-nil value. If it returns nil, your macOS version may be too old.
help me debug why Save as Knowledge crashes my MemoryAssistant app
Add a knowledge search view
The knowledge entries are stored, but you have no way to find them yet. You will add a search field to the sidebar that embeds your query and compares it against all stored entries using cosine similarity.
- Open Views/SidebarView.swift in Xcode.
- Replace the entire file contents with this updated code:
import SwiftUI
import SwiftData
struct SidebarView: View {
let sessions: [Session]
@Binding var selectedSession: Session?
let onNewSession: () -> Void
// State for the knowledge search feature
@State private var searchText = ""
@State private var searchResults: [(entry: KnowledgeEntry, score: Double)] = []
@Environment(\.modelContext) private var modelContext
@Query private var knowledgeEntries: [KnowledgeEntry]
private let knowledgeService = KnowledgeService()
var body: some View {
VStack {
List(selection: $selectedSession) {
Section("Sessions") {
ForEach(sessions) { session in
Text(session.title)
.tag(session)
}
}
// Show search results when a query has been submitted
if !searchResults.isEmpty {
Section("Knowledge Results") {
ForEach(searchResults, id: \.entry.id) { result in
VStack(alignment: .leading) {
Text(result.entry.content)
.lineLimit(2)
Text("Relevance: \(String(format: "%.1f%%", result.score * 100))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
// Search input field at the bottom of the sidebar
HStack {
TextField("Search knowledge...", text: $searchText)
.textFieldStyle(.roundedBorder)
.onSubmit {
performSearch()
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
.safeAreaInset(edge: .top) {
Button(action: onNewSession) {
Label("New Chat", systemImage: "plus.circle.fill")
}
.buttonStyle(.borderedProminent)
.padding()
}
}
// Embed the search query and find the most relevant knowledge entries
private func performSearch() {
guard !searchText.isEmpty else {
searchResults = []
return
}
searchResults = knowledgeService.search(
query: searchText,
entries: knowledgeEntries,
topK: 5
)
}
}
What does this code do?
- @Query private var knowledgeEntries automatically fetches all knowledge entries from SwiftData whenever the store changes.
- The TextField at the bottom of the sidebar accepts a search query. Pressing Return triggers performSearch().
- performSearch() embeds the query, computes cosine similarity against every stored entry, and displays the top 5 results with their relevance scores as percentages.
- Save Views/SidebarView.swift.
- Run the app with Cmd+R.
- Send a few messages and click Save as Knowledge on each one (for example, "I always use MVVM in my projects" and "My app targets iOS 17+").
- In the search field at the bottom of the sidebar, type architecture pattern and press Return.
You should see the MVVM entry appear in the Knowledge Results section with a high relevance score. The search found it by meaning, not by matching the exact words in the stored text.
No results appearing after searching?
- Make sure you clicked Save as Knowledge on at least one message before searching. Simply sending messages does not store them as knowledge.
- Check that you pressed Return after typing your search query. The search runs on submit, not on every keystroke.
- If results appear but with very low relevance scores, try a query more closely related to your stored entries.
help me debug why knowledge search returns no results in my MemoryAssistant sidebar
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before continuing.
ⓧ I'd like to double check the full code
import SwiftUI
import SwiftData
struct SidebarView: View {
let sessions: [Session]
@Binding var selectedSession: Session?
let onNewSession: () -> Void
@State private var searchText = ""
@State private var searchResults: [(entry: KnowledgeEntry, score: Double)] = []
@Environment(\.modelContext) private var modelContext
@Query private var knowledgeEntries: [KnowledgeEntry]
private let knowledgeService = KnowledgeService()
var body: some View {
VStack {
List(selection: $selectedSession) {
Section("Sessions") {
ForEach(sessions) { session in
Text(session.title)
.tag(session)
}
}
if !searchResults.isEmpty {
Section("Knowledge Results") {
ForEach(searchResults, id: \.entry.id) { result in
VStack(alignment: .leading) {
Text(result.entry.content)
.lineLimit(2)
Text("Relevance: \(String(format: "%.1f%%", result.score * 100))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
HStack {
TextField("Search knowledge...", text: $searchText)
.textFieldStyle(.roundedBorder)
.onSubmit {
performSearch()
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
.safeAreaInset(edge: .top) {
Button(action: onNewSession) {
Label("New Chat", systemImage: "plus.circle.fill")
}
.buttonStyle(.borderedProminent)
.padding()
}
}
private func performSearch() {
guard !searchText.isEmpty else {
searchResults = []
return
}
searchResults = knowledgeService.search(
query: searchText,
entries: knowledgeEntries,
topK: 5
)
}
}
Secret mission
Smart Context Injection
Your app has a knowledge base, but it just sits there waiting for manual searches. What if every chat message automatically pulled in relevant memories? Wire the knowledge base into every request so the model receives personalized context before generating a response.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. The main resource to consider is the qwen2.5-coder:3b model taking up ~1.9GB of disk space and Ollama using ~3GB of RAM while active.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Ollama application (running locally, serving the LLM).
- qwen2.5-coder:3b model (~1.9GB stored in ~/.ollama).
- MemoryAssistant Xcode project (local files).
Keep everything running
No action needed. Choose this if you plan to keep building on your MemoryAssistant app or want to keep chatting with the local model.
- Ollama stays in your menu bar and the model remains cached in memory.
- Uses ~3GB RAM while Ollama is active.
- Your Xcode project stays wherever you saved it, ready to open anytime.
Pause - I'll come back to this later
Shut down Ollama to free up RAM. Your model files and Xcode project stay on disk so you can resume later.
- Quit the Ollama app from the menu bar icon. The model unloads from memory immediately.
- When you want to resume, restart Ollama by running:
open -a Ollama
- Your MemoryAssistant project and all SwiftData (sessions, messages, knowledge entries) remain intact on disk.
Delete - I don't want to use this again
Remove all project resources completely. This frees up disk space and removes Ollama from your system.
- Remove the qwen2.5-coder:3b model by running:
ollama rm qwen2.5-coder:3b
- Uninstall Ollama by removing the application and its data:
rm -rf /Applications/Ollama.app
rm -rf /usr/local/bin/ollama
rm -rf ~/.ollama
- Delete your MemoryAssistant Xcode project by moving it to the Trash in Finder, or by running rm -rf on the project folder from your terminal.
Nice Work!
Nice Work!
You just built a native macOS AI assistant that runs entirely on your machine. MemoryAssistant connects to a local LLM, remembers what you tell it, and searches past knowledge by meaning.
You've learned how to:
- Build a SwiftUI Mac app that streams token-by-token responses from a local Ollama model using URLSession and NDJSON parsing.
- Persist chat sessions and messages with SwiftData, using @Model and @Query for seamless SwiftUI integration.
- Create a semantic knowledge base using Apple's NLEmbedding framework to store 512-dimensional sentence vectors and search them with cosine similarity.
- Secret Mission: Wire automatic context injection so the model receives relevant past knowledge before generating each response.
Ready to quiz yourself?