Skip to Content
GuidesSetting Up AI Search

Setting Up AI Search

This guide walks you through enabling semantic search for your project. By the end, you’ll have a working search endpoint that understands natural language.

Prerequisites

  • A Foir project with content (records in one or more models)
  • An API key with the appropriate scopes:
    • search:read for semantic search
  • At least one model with embeddings generated for its records (see Step 1)

What You’ll Build

A search experience that:

  1. Finds content by meaning, not just keywords
  2. Works across all your models
  3. Returns results ranked by semantic similarity

Step 1: How a Model Becomes Searchable

There is no separate switch to flip — a model becomes searchable as soon as its records have embeddings. You create those embeddings by generating them for a record (generateEmbedding, covered in Step 2) or by pushing a precomputed vector with writeEmbeddings. Each model’s embeddings are queried through its typed search<Model>s field, plus the generic searchRecords.

To see coverage in the Foir admin dashboard:

  1. Navigate to Settings > Models
  2. Select the model you care about
  3. Open the Embeddings tab

The Embeddings tab is a read-only overview: embedded records, records-with-embeddings vs. total, coverage percentage, and how many records are still pending. It only appears once the model has at least one embedding — until then there is nothing to show.

Tip: Coverage and pending counts are the fastest way to confirm a model is ready before you wire search into a live experience.

Step 2: Generate Embeddings

Embeddings are not produced automatically when you save a record — you trigger generation explicitly. Use the generateEmbedding mutation (or the foir embeddings CLI) to embed a record, then use the coverage probes (embeddingCoverage / recordsMissingEmbedding, shown under Auditing Coverage) to find any records still missing an embedding.

Generate for a Single Record

generateEmbedding enqueues an embedding job and returns a Boolean. It requires the search:semantic:write scope.

mutation { generateEmbedding( recordId: "rec_abc123" modelKey: "product" ) }

Verify Embeddings Exist

Records carry per-record embedding signals you can read directly (these need search:semantic:read):

query { product(id: "rec_abc123") { _id _hasEmbedding(key: "default") _embeddingContentHash(key: "default") } }

Note: Embedding generation happens asynchronously. After you trigger generation, there may be a brief delay before the embedding is available for search.

Step 3: Search Your Content

Each model that has embeddings gets a typed search<Model>s query. It returns { score, record } hits where record is the model’s normal type, so you can select any of its fields. Requires the search:read:<model> scope.

query { searchProducts(query: "comfortable shoes for long walks", first: 5) { score record { _id title price } } }

To search across model types, use the generic searchRecords query (scope search:read). Pass an optional modelKey to narrow it.

query { searchRecords(query: "return and refund information", first: 3) { recordId modelKey naturalKey score } }

Using the Results

Here’s a complete example in TypeScript:

const SEARCH_QUERY = ` query SearchProducts($query: String!, $first: Int) { searchProducts(query: $query, first: $first) { score record { _id title } } } `; async function searchProducts(userQuery: string) { const response = await fetch('https://api.foir.dev/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.FOIR_API_KEY!, }, body: JSON.stringify({ query: SEARCH_QUERY, variables: { query: userQuery, first: 10 }, }), }); const { data } = await response.json(); return data.searchProducts; } // Usage const results = await searchProducts("waterproof hiking boots"); // Returns products matching the intent, even if they don't // contain the exact words "waterproof hiking boots"

Step 4: Set Up Your API Key

Make sure your API key has the right scopes for the features you need:

FeatureRequired Scope
Per-model search (search<Model>s)search:read:<model>
Cross-model search (searchRecords)search:read
Coverage / digest queries, _hasEmbeddingsearch:semantic:read
Generate / write embeddingssearch:semantic:write

To update scopes:

  1. Go to Settings > API Keys in the admin dashboard
  2. Edit your API key
  3. Add the required scopes
  4. Save

Tips and Best Practices

Choosing Fields to Embed

  • Do include: title, description, body content, tags, category names
  • Don’t include: IDs, timestamps, internal slugs, boolean flags
  • More text generally produces better search results, but overly long content dilutes relevance

Interpreting Scores

Each hit carries a score. Use it to filter low-quality matches on the client side:

Score RangeQuality
0.9 – 1.0Near-exact semantic match
0.7 – 0.9Strong relevance
0.5 – 0.7Moderate relevance
Below 0.5Weak match — usually noise

Cap first to the number of results you’ll actually show, and drop hits below a score cutoff (around 0.6) that you tune to your content.

Narrowing the Search Space

When you know which model the user is looking for, query that model’s typed search<Model>s field directly — it only searches that model and returns its full typed record. For broader queries, searchRecords accepts an optional modelKey to restrict the generic search:

query { searchProducts(query: "eco-friendly packaging", first: 5) { score record { _id title } } }

Keeping Embeddings Fresh

  • Re-run generateEmbedding (or foir embeddings) after a record’s content changes to refresh its embedding — saving or publishing a record does not re-embed it on its own
  • If a record’s content hasn’t changed, re-embedding is skipped (deduplication is automatic)
  • Use the coverage queries below to confirm every record is embedded before going live with search

Auditing Coverage and Running a Sweep

If you generate embeddings from your own service (for example a background worker), you can audit coverage and repair gaps without re-embedding your whole corpus. All three queries are scoped to your own records.

Get a per-model summary of how many records are embedded versus pending:

query { embeddingCoverage(modelKey: "note") { modelKey totalRecords embeddedRecords pendingRecords lastEmbeddedAt } }

List exactly which records are missing an embedding for a key, paginated, so you can embed only the gaps:

query { recordsMissingEmbedding(modelKey: "note", key: "default", first: 100) { edges { node { recordId naturalKey } } pageInfo { hasNextPage endCursor } totalCount } }

Before re-embedding, read back the content hash you stored alongside each embedding. If it matches the current content’s hash, skip it:

query { embeddingDigests( modelKey: "note" key: "default" recordIds: ["rec_abc123", "rec_def456"] ) { recordId contentHash } }

When you write an embedding, pass the contentHash you computed so a later sweep can read it back:

mutation { writeEmbeddings(input: { entries: [{ recordId: "rec_abc123" key: "default" embedding: [0.0123, -0.0456, 0.0789] contentHash: "sha256:9f86d0…" }] }) }

Records carry the same signals as fields, handy for quick per-record checks:

query { note(id: "rec_abc123") { _id _hasEmbedding(key: "default") _embeddingContentHash(key: "default") } }

A typical sweep: call recordsMissingEmbedding to find gaps, embeddingDigests to skip records whose contentHash is unchanged, embed the rest, and write them back with writeEmbeddings (including the new contentHash).

Next Steps

Last updated on