Search

Hybrid lexical + semantic search in the browser.

Search

fortemi-react provides three search modes that mirror the fortemi server's search subsystem: full-text search (BM25), semantic vector search (pgvector cosine), and hybrid search (RRF fusion). All modes run entirely in-browser via PGlite.

Search Modes

The candidate remote adapter validates the Fortemi-owned search response schema before loading note details. Unknown response fields, malformed degradation and foreign chain/evidence identities are errors rather than silently discarded data. The remote query API remains q/mode/limit/tags with a100-hit maximum; the server's additional options are not enabled by schema adoption. `rest.receipt.json` records the unpublished REST candidate separately from earlier evidence-only receipts. Complete evidence capability and live/released cross-runtime acceptance remain unavailable; chain display indices are not native citation coordinates.

ModeHow It WorksRequiresStatus
textPostgreSQL `tsvector`/`tsquery` with BM25 rankingNothing (default)Fully implemented
semanticpgvector HNSW cosine distance on note embeddingsSemantic capability enabled, embeddings generatedImplemented
hybridBM25 + vector combined via Reciprocal Rank Fusion (k=60)Semantic capability + embeddingsImplemented

Quick Start

Text Search (React Hook)

The `useSearch` hook automatically dispatches to text, semantic, or hybrid search based on whether the semantic capability is ready:

import { useSearch } from '@fortemi/react'

function SearchPage() {
  const { data, loading, search, clear } = useSearch()

  const handleSearch = async (query: string) => {
    if (query.trim()) {
      await search(query)
    } else {
      clear()
    }
  }

  return (
    <div>
      <input onChange={(e) => handleSearch(e.target.value)} placeholder="Search notes..." />
      {loading && <p>Searching...</p>}
      {data?.results.map((result) => (
        <div key={result.id}>
          <h3>{result.title ?? 'Untitled'}</h3>
          <p dangerouslySetInnerHTML={{ __html: result.snippet }} />
          <small>
            Mode: {data.mode} | Rank: {result.rank.toFixed(3)} | Tags: {result.tags.join(', ')}
          </small>
        </div>
      ))}
      <p>{data?.total ?? 0} results (mode: {data?.mode})</p>
    </div>
  )
}

When semantic capability is enabled, `useSearch` automatically: 1. Checks `capabilityManager.isReady('semantic')` 2. Calls `getEmbedFunction()` to generate a query embedding 3. Passes the embedding to `SearchRepository.search()`, which dispatches to hybrid (if query text present) or semantic (if query empty)

When semantic is not enabled, only text search is used.

Search Mode Override

By default, `useSearch` auto-detects the best mode. You can force a specific mode:

await search('query', { mode: 'text' })      // Force text-only (BM25), ignore embeddings
await search('query', { mode: 'semantic' })   // Force semantic-only (requires capability)
await search('query', { mode: 'hybrid' })     // Force hybrid RRF (requires capability)
await search('query', { mode: 'auto' })       // Auto-detect (default)

When `mode` is `'semantic'` or `'hybrid'` and the semantic capability is not enabled, the hook throws an error. This allows UIs to disable these options when the capability isn't ready.

Search with Filters

await search('machine learning', {
  tags: ['ai', 'research'],        // filter by tags (ANY match)
  collection_id: 'col-uuid-here',  // filter by collection
  date_from: new Date('2026-01-01'), // filter by creation date range
  date_to: new Date('2026-03-31'),
  is_starred: true,                 // only starred notes
  is_archived: false,               // exclude archived notes
  format: 'markdown',               // filter by note format
  source: 'user',                   // filter by note source
  visibility: 'private',            // filter by visibility level
  limit: 10,                        // results per page (default: 20, max: 100)
  offset: 0,                        // pagination offset
  include_facets: true,             // include tag/collection aggregate counts
})

Typed Metadata Candidate (PGlite)

The unreleased #405 correction applies to `SearchRepository`, the PGlite `DataBackend`, and `searchTool`. `metadataPredicates` accepts at most eight AND clauses over `provider`, `model`, `role`, `event_kind`, `sensitivity`, and `import_run_id`. Operators are `eq`, `in` (at most 32 values), inclusive `range`, and `exists` (default true). Unknown paths/operators/fields and malformed input fail with `METADATA_PREDICATES_INVALID`; reversed ranges use `METADATA_RANGE_INVALID`.

Equality preserves JSON scalar types. Present null differs from a missing key. Ranges require same-type numeric or string bounds and use numeric or Unicode scalar order. Request strings are bounded to 256 characters without NUL; numbers must be finite within the JavaScript safe-integer magnitude. Import-run values must be nonempty strings of at most 200 characters.

Five paths read author metadata, not generated AI metadata. Import runs read source identity using the supplied `tenant_id` (default `default`) and the note's archive. All positive run clauses must match one identity. Explicit `archive_id` selects the note archive; explicit tenant selection uses matching source identity and admits identity-free native notes only for the default tenant. These options do not authenticate users or establish hosted authorization.

Migration 33 installs bounded typed indexes without modifying stored metadata. Source locator projections exclude foreign tenants/archives and nonmatching runs. Complete chunk/span citation reproducibility, third-party adapter conformance and released producer compatibility are still pending. This candidate does not advertise cross-backend parity or change any Knowledge Shard profile.

The PGlite backend accepts `mode: 'fts' | 'semantic' | 'hybrid'`. Semantic and hybrid require both `semanticAvailable: true` and a host-owned `embedQuery` function in `createPGliteBackend`; missing support fails before reads instead of falling back. The tool retains its existing mode names and `query_embedding`. The backend's tags require every listed tag and sources match any listed source, before ranking; repository/tool tags retain their existing ANY behavior.

`typedMetadataPredicates` in local backend selection is a candidate-v1 operation flag, not a promoted server contract. RecordStore, static and current remote adapters reject predicates with `BACKEND_METADATA_PREDICATES_UNSUPPORTED` and tenant/archive selection with `BACKEND_SEARCH_SCOPE_UNSUPPORTED` before I/O. Malformed predicates retain the shared validation errors above. Third-party backends with absent flags are unsupported. Complete `evidenceLocators` remains false even when partial source projections are returned.

Candidate Text Evidence (PGlite)

The unreleased citation candidate adds optional `SearchResult.evidence` and `BackendSearchHit.evidence`, separate from legacy `locators`. Each envelope has `version: '1.0.0'`, up to 64 locators, and explicit `omissions`. A locator binds the exact title, current body, completed attachment text, or winning stored embedding text using a native unit ID, full-text SHA-256, and half-open UTF-8 byte offsets. Whole-unit spans are intentional, not snippet offsets. Hybrid fusion preserves evidence from both ranking legs, including the winning chunk.

import { SearchRepository, parseSearchEvidenceSet } from '@fortemi/core'

const repository = new SearchRepository(db)
const scope = { archive_id: 'workspace-archive', tenant_id: 'default' }
const response = await repository.search('needle', { ...scope, mode: 'text' })
for (const hit of response.results) {
  if (!hit.evidence) continue
  const evidence = parseSearchEvidenceSet(hit.evidence, hit.id)
  for (const locator of evidence.locators) {
    const citedText = await repository.resolveEvidence(locator, scope)
    // Treat citedText as untrusted plain text, not HTML.
    console.log(citedText)
  }
}

`unavailable-unit` reports a match without reproducible in-budget unit evidence; `locator-limit` reports bounded truncation. Neither reason authorizes a fallback to unrelated current text. Text above 16 MiB is not silently truncated. Changed, deleted, purged, missing, or out-of-scope text rejects with `SEARCH_EVIDENCE_UNAVAILABLE`; malformed locators/scopes use `SEARCH_EVIDENCE_INVALID`. A digest is neither an access grant nor a promise of historical retention. Pass the current local scope when resolving, and handle unavailability independently of displaying the search result.

The remote adapter also validates and forwards this optional envelope as `BackendSearchHit.evidence`, across FTS/semantic/hybrid and the report-bearing semantic API. It validates every hit before requesting detail metadata; malformed or foreign-note evidence rejects the response with `RemoteBackendError` kind `invalid-response`. Older responses without evidence remain supported without invented locators. Later detail content does not replace the ranked text snapshot. The local `SearchRepository.resolveEvidence` API above is not a remote resolver; a remote locator is not permission to retrieve its text.

The remote candidate now exposes `remote.resolveEvidence(locator, options)`:

const citedText = await remote.resolveEvidence(hit.evidence.locators[0], {
  metadataPredicates: [{ path: 'provider', op: 'eq', value: 'example' }],
  includeArchived: false,
  signal: abortController.signal,
})

The request goes to the producer's current-storage resolver using the backend's existing auth/archive headers. Caller tenant/archive/visibility fields reject; they are not authorization. Invalid input fails before I/O. Responses require strict JSON, no-store and exact UTF-8 span length, with a 30-second operation ceiling and caller cancellation. `RemoteBackendError` distinguishes invalid request/response, transport, aborted and HTTP status failures; 404 means the requested current evidence is unavailable, not an instruction to fetch newer detail text. The producer checks full-text digest and current policy. Core cannot independently verify a full-unit digest using only its returned partial range.

This is an unpublished candidate. Complete `evidenceLocators` remains false; launched producer-to-consumer, production authentication, lifecycle/cache and released cross-runtime acceptance remain pending. Candidate schemas and their separate evidence, GET REST and resolution hash receipts ship under `schemas/metadata-search/candidate/1.0.0` for package verification, not as a promoted REST contract or Knowledge Shard profile.

Wrap terms in double quotes for exact phrase matching:

await search('"machine learning"')  // Uses phraseto_tsquery — matches exact phrase
await search('machine learning')    // Uses plainto_tsquery — matches both words (AND)

Phrase search is detected automatically when the query contains `"` characters.

Search History and Autocomplete

import { useSearchHistory, useSearchSuggestions } from '@fortemi/react'

function SearchWithSuggestions() {
  const { history, addEntry, clearHistory } = useSearchHistory()
  const { suggestions, getSuggestions, clearSuggestions } = useSearchSuggestions(history)

  const handleInput = (value: string) => {
    getSuggestions(value) // Get prefix-matched suggestions
  }

  const handleSearch = (query: string) => {
    addEntry(query) // Save to history
    clearSuggestions()
    // ... execute search
  }

  return (
    <div>
      <input onChange={(e) => handleInput(e.target.value)} />
      {suggestions.map((s) => (
        <div key={s.text} onClick={() => handleSearch(s.text)}>
          {s.text} <small>({s.source})</small>
        </div>
      ))}
    </div>
  )
}

`useSearchHistory` persists to localStorage (survives archive switches). `useSearchSuggestions` loads vocabulary from `ts_stat` on mount and merges with history for prefix-matched suggestions.

Faceted Results

When `include_facets: true` is passed, the response includes aggregate counts:

const result = await search('machine learning', { include_facets: true })

console.log(result.facets)
// {
//   tags: [{ tag: 'ai', count: 15 }, { tag: 'research', count: 8 }, ...],
//   collections: [{ id: 'col-1', name: 'Papers', count: 12 }, ...]
// }

Facets are computed from the full (unpaginated) result set for accurate counts.

Direct Repository Usage

For fine-grained control, use `SearchRepository` directly:

import { SearchRepository, getEmbedFunction } from '@fortemi/core'
import { useFortemiContext } from '@fortemi/react'

function AdvancedSearch() {
  const { db, capabilityManager } = useFortemiContext()

  const handleSearch = async (query: string) => {
    const semanticReady = capabilityManager.isReady('semantic')
    const repo = new SearchRepository(db, semanticReady)

    if (semanticReady) {
      const embedFn = getEmbedFunction()
      if (embedFn) {
        const [queryEmbedding] = await embedFn([query])
        // Hybrid search (text + vector)
        return repo.search(query, { limit: 20 }, queryEmbedding)
      }
    }

    // Text-only search
    return repo.search(query, { limit: 20 })
  }
}
import { searchTool } from '@fortemi/core'

// Via tool function (Zod-validated input)
const results = await searchTool(db, {
  query: 'knowledge management',
  mode: 'auto',     // 'text' | 'semantic' | 'hybrid' | 'auto'
  query_embedding: optionalQueryEmbedding,
  embeddingSetId: 'optional-embedding-set',
  limit: 20,
  offset: 0,
  tags: ['research'],
  collection_id: 'optional-id',
  date_from: '2026-01-01',
  is_starred: true,
  include_facets: true,
})

Text mode is always available. Forced `semantic` and `hybrid` modes require the host to pass `query_embedding`; without it the tool returns a descriptive error. `auto` uses semantic/hybrid search when a query embedding is supplied and falls back to deterministic text search otherwise. Bridge hosts should generate the query embedding through their semantic capability and pass the vector directly, which keeps heavyweight model dependencies out of the base core package.

Search Response Format

All search modes return the same `SearchResponse` shape, matching the fortemi server format:

interface SearchFacets {
  tags: { tag: string; count: number }[]
  collections: { id: string; name: string; count: number }[]
}

interface SearchResponse {
  results: SearchResult[]
  total: number          // total matching results (before pagination)
  query: string          // echo of the search query
  mode: 'text' | 'semantic' | 'hybrid'  // actual search mode used
  semantic_available: boolean  // true if semantic capability is loaded
  limit: number
  offset: number
  facets?: SearchFacets  // present when include_facets: true
}

interface SearchResult {
  id: string             // note UUIDv7
  title: string | null
  snippet: string        // highlighted excerpt (<mark> tags for text mode)
  rank: number           // relevance score (BM25 rank, cosine similarity, or RRF score)
  created_at: Date
  updated_at: Date
  tags: string[]         // note's tags included for display
  has_embedding?: boolean // true if note has a vector embedding (semantic search ready)
}

Full-Text Search Details

Indexing

Notes are indexed using PostgreSQL's built-in tsvector system:

  • Title — stored as a precomputed `tsv` column on the `note` table (weight A, higher priority)
  • Content — computed at query time from `note_revised_current.content` (weight B)
  • Language — English dictionary (`'english'` configuration) for stemming and stop words

Query Parsing

User input is parsed with `plainto_tsquery('english', query)` (or `phraseto_tsquery` for quoted phrases) which:

  • Applies English stemming (e.g., "running" matches "run")
  • Removes English stop words
  • Treats all terms as AND (all must match)
  • Handles special characters safely (no injection risk)
  • Quoted phrases use `phraseto_tsquery` for adjacency matching (e.g., `"machine learning"` matches the exact phrase)

Ranking

Results are ranked by `ts_rank` combining the weighted title and content vectors. Title matches rank higher than content-only matches.

ts_rank(
  setweight(n.tsv, 'A') ||
  setweight(to_tsvector('english', coalesce(c.content, '')), 'B'),
  plainto_tsquery('english', $1)
)

Snippets

Highlighted snippets are generated by `ts_headline` with these settings:

  • `StartSel=<mark>`, `StopSel=</mark>` — HTML highlighting
  • `MaxWords=35`, `MinWords=15` — snippet length control

Render snippets with `dangerouslySetInnerHTML` or sanitize the `<mark>` tags.

Semantic Search Details

Prerequisites

1. Enable Semantic capability in Settings (downloads all-MiniLM-L6-v2, ~23MB) 2. Generate embeddings for notes (click "Generate Embedding" on each note, or embeddings auto-queue on note creation when capability is enabled)

How It Works

1. Note content is chunked via `chunkText()` (overlapping windows) 2. The embedding task is selected as `embedding.document` or `embedding.large-document` from content length and chunk count 3. Each chunk is embedded to a 384-dimensional vector via transformers.js, or by the routed provider configured for that task 4. Chunk embeddings are averaged and normalized into one vector per note 5. Stored in the `embedding` table with HNSW index for fast cosine search 6. At query time, the search query is embedded with the `embedding.query` task 7. pgvector's `<=>` operator finds nearest neighbors by cosine distance

Embedding Model

PropertyValue
Model`Xenova/all-MiniLM-L6-v2`
Dimensions384
Download size~23 MB
Index typeHNSW (via pgvector)
Distance metricCosine (`<=>` operator)
Runtimetransformers.js (WASM, no GPU needed)

Hybrid Search Details

Reciprocal Rank Fusion (RRF)

Hybrid search runs BM25 and vector search independently, then merges results using RRF with k=60:

RRF_score(d) = sum( 1 / (k + rank_i(d)) ) for each ranker i

Where:

  • `k = 60` (standard RRF constant, matching fortemi server)
  • `rank_i(d)` is the 0-based position of document d in ranker i's results
  • Documents appearing in both rankers get boosted scores
  • Top 100 candidates from each ranker before fusion

This produces results that balance keyword precision (BM25) with semantic understanding (vector), without requiring manual weight tuning.

Filter Options

Available Filters

FilterTypeApplies ToDescription
`tags``string[]`All modesNotes must have ANY of the specified tags
`collection_id``string`All modesNotes must belong to this collection
`date_from``Date`All modesNotes created on or after this date
`date_to``Date`All modesNotes created on or before this date
`is_starred``boolean`All modesFilter by starred status
`is_archived``boolean`All modesFilter by archived status
`format``string`All modesFilter by note format (`'markdown'`, `'plain'`, `'html'`)
`source``string`All modesFilter by note source (`'user'`, `'mcp'`, `'import'`, `'api'`)
`visibility``string`All modesFilter by visibility (`'private'`, `'shared'`, `'public'`)
`include_facets``boolean`All modesInclude tag/collection aggregate counts (default: false)
`limit``number`All modesResults per page (1-100, default: 20)
`offset``number`All modesPagination offset (default: 0)

All filters apply uniformly across text, semantic, and hybrid search modes via the shared `buildNoteConditions()` helper.

Shared Condition Builder

Filters are generated by a shared `buildNoteConditions()` function used by both `SearchRepository` and `NotesRepository`. This prevents drift between the two repositories and makes adding future filters trivial:

import { buildNoteConditions } from '@fortemi/core'

const { conditions, params, nextIdx } = buildNoteConditions(
  { tags: ['ai'], is_starred: true, date_from: new Date('2026-01-01') },
  1, // starting parameter index
)
// conditions: ['n.deleted_at IS NULL', 'EXISTS (...)', 'n.is_starred = $2', 'n.created_at >= $3']
// params: [['ai'], true, Date]

Empty Query Behavior

When the search query is empty or whitespace-only:

  • No embedding provided: Returns recent notes ordered by `created_at DESC`
  • Embedding provided: Runs pure semantic search (find notes similar to the embedding vector)

This matches the fortemi server's behavior where an empty search bar shows the most recent notes.

Performance Targets

MetricTargetNotes
Text search (10k notes)< 200msGIN index on tsvector
Semantic search (10k embeddings)< 500msHNSW index on pgvector
Hybrid search (10k notes + embeddings)< 1sTwo queries + RRF fusion
Embedding generation< 2s per chunktransformers.js WASM

These targets match the fortemi server's performance expectations documented in `supplementary-requirements.md` (PERF-003).

Architecture Notes

Deleted Notes

All search modes automatically exclude soft-deleted notes (`n.deleted_at IS NULL`). There is no option to search deleted notes — this matches the server behavior.

Tag Enrichment

Search results include the note's tags in every mode. Tags are fetched in a single batch query after the main search to avoid N+1 queries.

Mode Field

The `SearchResponse.mode` field correctly reflects the actual search mode used: `'text'`, `'semantic'`, or `'hybrid'`. The `semantic_available` boolean indicates whether the semantic capability is loaded.

Parity with fortemi Server

FeatureServerfortemi-reactNotes
Text search (BM25)FullFullIdentical tsvector/tsquery implementation
Semantic search (cosine)FullFullSame pgvector HNSW, same distance metric
Hybrid search (RRF k=60)FullFullSame algorithm and k constant
Search filters10+ filters12 filterstags, collection_id, date_from, date_to, is_starred, is_archived, format, source, visibility, include_facets, limit, offset
Response.mode fieldReflects actual modeReflects actual modeFull parity
Phrase search`phraseto_tsquery``phraseto_tsquery`Full parity — triggered by quoted input
Search suggestionsAutocomplete from tsvector`useSearchSuggestions` hookClient-side prefix matching from `ts_stat` vocabulary
Search historyStored in DB`useSearchHistory` hooklocalStorage (survives archive switches)
Faceted resultsTag/collection counts`include_facets` optionTop 20 tags and collections by count

For the complete server search specification, see:

  • Search types: `fortemi/src/search/types.rs`
  • Search service: `fortemi/src/search/service.rs`
  • Filter definitions: `fortemi/src/search/filters.rs`