Store & Search

Search you data by meaning.

Turn your app’s data into an on-device semantic index, then search it with natural language.

A Primordial collection stores your app’s data locally and makes its text searchable by meaning, not just exact words.

Create a Primordial Client

Import Primordial and create a client. The identifier scopes collections and their stored data, so use a stable value for the same app account or workspace.

import Primordial

let primordial = PrimordialClient(
    identifier: "account-42" // Optional
)

Two PrimordialClient() with different identifiers do not share collections. If your app does not need separate accounts or workspaces, PrimordialClient() uses the stable default identifier.

Check Semantic Search Availability

A collection is a named group of records that Primordial stores and searches together.

Collection synchronization, search, related-record lookup, and reindexing all use the .semanticSearch capability. Check its status before presenting the feature or asking the user to download anything.

let requirement = await primordial.ai.status(
    for: .semanticSearch
)

print(requirement.isInstalled)
print(requirement.isAvailable)
print(requirement.downloadBytes)
print(requirement.requiredAvailableBytes)

isInstalled tells you whether the required AI model is already on the device. isAvailable also checks whether the current device can use it. Primordial loads an downloaded model when a collection operation needs it.

When a download is required, use downloadBytes and requiredAvailableBytes to explain the download and storage requirement in your own UI before continuing.

Activate Primordial

Activate the SDK before downloading the semantic-search model or working with a collection.

try await primordial.activate(
    .evaluationKey("pk_eval_your_key_here")
)

You can check primordial.isActivated when your UI needs to reflect activation state. Keep the evaluation key out of logs and user-visible error messages.

Prepare Semantic Search

makeAvailable checks storage, downloads embedding model or anything missing, verifies it, and reports progress as an asynchronous stream.

for try await progress in primordial.ai.makeAvailable(
    .semanticSearch
) {
    print(progress.fractionCompleted)
    print(progress.downloadedBytes)
    print(progress.totalBytes)
    print(progress.phase)
}

Calling makeAvailable again reuses an existing valid download.

Primordial supports background model downloads on iOS. Downloads can continue while your app is suspended, as long as it forwards background URL-session events. See Background downloads for the required app delegate code. If the user force-quits your app, the download is canceled.

Use Different Embedding Model

Primordial by default uses Paraphrase Multilingual MiniLM L12 v2. If you want to use different model, see Advanced API: Use a custom embedding model.

Create a Collection

A collection groups records that belong to the same searchable dataset. Opening the same collection name with the same client identifier returns access to the same durable local data.

let notes = try primordial.collection("research notes")

print(notes.name) // "research-notes"
print(notes.clientIdentifier) // "account-42"

Primordial normalizes collection names, including whitespace and capitalization. Keep the intended name stable and use separate collections when records have different purposes or retention rules.

Describe Your Data with Typed Fields

Every record has an ID and a set of fields. Your app chooses the ID, and Primordial uses it to find and update the same record later. Keep the ID non-empty, unique within the collection, and stable.

let record = PrimordialRecord(
    id: "9087978swiftactors", // generated by your app
    fields: [
        "title": .text("Swift actors"),
        "body": .text("Actors protect isolated mutable state."),
        "kind": .keyword("guide"),
        "tags": .stringList(["swift", "concurrency"]),
        "priority": .integer(2),
        "rating": .double(4.8),
        "published": .bool(true),
        "createdAt": .date(.now)
    ]
)
Field type Use it for Semantically indexed
.text Natural-language content that people should be able to find by meaning. Yes
.keyword A category, state, or other exact string value. No
.stringList Tags and other lists of exact string values. No
.integer, .double Counts, ranks, measurements, and numeric filters. No
.bool Flags such as published, favorite, or archived. No
.date Timestamps and date-range filters. No

Only .text fields are split into searchable passages and embedded. Every other type remains available for filtering, display, and source information. Use the same field type for a field in every record for example, always store priority as an integer.

Choose How Your App Supplies Records

Your app remains the source of truth. Primordial keeps a searchable projection of that data and does not replace your app database.

App data Use
A complete in-memory dataset replaceAll(with:)
Explicit inserts, updates, and removals apply(_:)
A durable database or another paged source synchronize(from:options:)
SwiftData models PrimordialSwiftDataSource

Replace a Complete Dataset

Use replaceAll(with:) when your app already has the complete set of records. Records omitted from the new snapshot are removed from the collection.

let currentRecords = [record]

for try await event in notes.replaceAll(with: currentRecords) {
    switch event {
    case .progress(let progress):
        print(progress.phase, progress.changedRecords)
    case .completed(let report):
        print(report.generatedEmbeddings)
        print(report.reusedEmbeddings)
    }
}

Search continues to use the previous complete snapshot until the replacement commits. A failed or cancelled replacement does not expose a partial dataset, and unchanged text can reuse its embedding.

Apply Individual Changes

Use apply(_:) when your app already knows which complete records changed. Update your source of truth first, then apply an upsert or removal to its searchable projection.

let changes: [PrimordialRecordChange] = [
    .upsert(record),
    .remove(id: "old-record")
]

for try await event in notes.apply(changes) {
    if case .completed(let report) = event {
        print(report.changedRecords, report.removedRecords)
    }
}

Partial field updates are intentionally not part of the collection API. Change the application record, then pass its complete current fields with .upsert.

Synchronize SwiftData

Use PrimordialSwiftDataSource to map a SwiftData model into searchable records. Mark the stable record ID with .preserveValueOnDeletion so removals can be read from SwiftData history.

import Primordial
import SwiftData

@Model
final class Note {
    @Attribute(.unique, .preserveValueOnDeletion)
    var id: UUID

    var title: String
    var body: String
    var modifiedAt: Date
}
let source = try PrimordialSwiftDataSource<Note>(
    identifier: "app.notes",
    schemaVersion: "1",
    container: modelContainer,
    recordID: \Note.id,
    encodeID: { $0.uuidString }
) { note in
    [
        "title": .text(note.title),
        "body": .text(note.body),
        "modifiedAt": .date(note.modifiedAt)
    ]
}

for try await event in notes.synchronize(from: source) {
    switch event {
    case .progress(let progress):
        print(progress.phase, progress.changedRecords)
    case .completed(let report):
        print(report.changedRecords, report.removedRecords)
    }
}

Continue using your normal ModelContext and @Query code. Primordial reads the supplied ModelContainer, creates the first snapshot, and uses saved history checkpoints on later synchronizations.

  • Keep identifier stable for the same logical data source.
  • Increase schemaVersion when the record or field mapping changes meaning.
  • The encoded record ID must be nonempty and unique.
  • Filtered SwiftData fetch descriptors are not supported.

Choose a Synchronization Mode

let options = PrimordialSynchronizationOptions(
    mode: .automatic,
    batchSize: 100
)

for try await _ in notes.synchronize(
    from: source,
    options: options
) { }
Mode Behavior
.automatic Uses incremental changes when possible and falls back to a complete snapshot when required.
.incremental Requires a valid saved checkpoint and fails instead of rebuilding automatically.
.snapshot Forces a complete snapshot from the source.

The default batch size is 100. Valid values are 1...1000; Primordial may reduce work per batch when the device is under resource pressure.

Inspect Synchronization Status

let status = try await notes.synchronizationStatus()

print(status.sourceIdentifier)
print(status.sourceSchemaVersion)
print(status.hasCheckpoint)
print(status.requiresSnapshot)
print(status.lastCompletedAt)
print(status.lastReport)

Use the status for diagnostics or application UI. Your app decides when to synchronize—for example, after model availability, after committing meaningful source changes, when the app becomes active, or from an app-owned background task when the system grants execution time.

Reset or Change the Source

A synchronized collection stays bound to its source identifier. Reset it before binding that collection name to a different logical source.

try await notes.reset()

Reset removes the collection's searchable records, synchronization checkpoint, and source binding.

Powerful Search by Meaning

Pass a natural-language query to search. You can choose which text fields participate, give important fields more weight, filter the eligible records, and limit the number of matches.

let matches = try await notes.search(
    "safe shared state",
    fields: ["title", "body"],
    weights: ["title": 1.5, "body": 1.0],
    filter: .field(
        "kind",
        .equals(.keyword("guide"))
    ),
    limit: 8
)

Each PrimordialMatch tells you which collection, record, field, and source range matched. It also includes the matching excerpt, the record’s stored fields, and a relevance score.

for match in matches {
    print(match.collection)
    print(match.recordID)
    print(match.field)
    print(match.excerpt)
    print(match.score)
    print(match.fields)
    print(match.sourceRange)
}

Filter Search Results

Filters are typed, so their values should match the field type stored in the collection. Combine filters with .all, .any, and .not when one condition is not enough.

let filter = PrimordialFilter.all([
    .field("kind", .equals(.keyword("guide"))),
    .field("priority", .lessThanOrEqual(.integer(2))),
    .field("tags", .contains("swift")),
    .not(.field("published", .equals(.bool(false))))
])

let matches = try await notes.search(
    "structured concurrency",
    filter: filter
)
Comparison Meaning
.equals, .notEquals Match or exclude one typed value.
.oneOf Match any value in a supplied list.
.lessThan, .lessThanOrEqual Keep values below a boundary.
.greaterThan, .greaterThanOrEqual Keep values above a boundary.
.contains Check text, keyword, or string-list content for a string.

Unknown fields, incompatible comparisons, invalid weights, and non-positive result limits fail explicitly instead of silently changing the query.

Find Related Records

Use related(to:) when you already have a record and want to find similar content. Primordial builds the query from that record’s selected text fields and leaves the source record out of the results.

let related = try await notes.related(
    to: "9087978swiftactors",
    fields: ["title", "body"],
    filter: .field("published", .equals(.bool(true))),
    limit: 4
)

Inspect a Collection

Collection statistics let your app report what is stored without reading private storage details.

let statistics = try await notes.statistics()

print(statistics.recordCount)
print(statistics.fieldCount)
print(statistics.chunkCount)
print(statistics.embeddingModelID)
print(statistics.embeddingDimensions)
print(statistics.chunkingVersion)

Rebuild a Collection

A collection records the embedding configuration used to build its index. If that configuration or the index format changes or you want to use different embedding model, Primordial reports an incompatibility instead of mixing unlike embeddings. Use reindex() to rebuild the index deliberately.

for try await progress in notes.reindex() {
    print(progress.phase)
    print(progress.fractionCompleted)
}

Handle Collection Errors

Invalid names, records, fields, queries, missing record IDs, incompatible indexes, and storage failures are reported as PrimordialCollectionError. Cancellation remains Swift’s CancellationError, so normal structured-concurrency cancellation handling still applies.

do {
    let matches = try await notes.search("local AI")
    print(matches)
} catch is CancellationError {
    print("Search cancelled")
} catch let error as PrimordialCollectionError {
    print(error.localizedDescription)
}

Advanced vector workflows

Managed collections are the recommended path for application records. If you deliberately need precomputed embeddings, multiple named representations, or direct semantic vector search, see Use a custom embedding model.