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.
| Mode | How It Works | Requires | Status |
|---|---|---|---|
| text | PostgreSQL `tsvector`/`tsquery` with BM25 ranking | Nothing (default) | Fully implemented |
| semantic | pgvector HNSW cosine distance on note embeddings | Semantic capability enabled, embeddings generated | Implemented |
| hybrid | BM25 + vector combined via Reciprocal Rank Fusion (k=60) | Semantic capability + embeddings | Implemented |
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.
Phrase Search
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 })
}
}
MCP Tool Search
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
| Property | Value |
|---|---|
| Model | `Xenova/all-MiniLM-L6-v2` |
| Dimensions | 384 |
| Download size | ~23 MB |
| Index type | HNSW (via pgvector) |
| Distance metric | Cosine (`<=>` operator) |
| Runtime | transformers.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
| Filter | Type | Applies To | Description |
|---|---|---|---|
| `tags` | `string[]` | All modes | Notes must have ANY of the specified tags |
| `collection_id` | `string` | All modes | Notes must belong to this collection |
| `date_from` | `Date` | All modes | Notes created on or after this date |
| `date_to` | `Date` | All modes | Notes created on or before this date |
| `is_starred` | `boolean` | All modes | Filter by starred status |
| `is_archived` | `boolean` | All modes | Filter by archived status |
| `format` | `string` | All modes | Filter by note format (`'markdown'`, `'plain'`, `'html'`) |
| `source` | `string` | All modes | Filter by note source (`'user'`, `'mcp'`, `'import'`, `'api'`) |
| `visibility` | `string` | All modes | Filter by visibility (`'private'`, `'shared'`, `'public'`) |
| `include_facets` | `boolean` | All modes | Include tag/collection aggregate counts (default: false) |
| `limit` | `number` | All modes | Results per page (1-100, default: 20) |
| `offset` | `number` | All modes | Pagination 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
| Metric | Target | Notes |
|---|---|---|
| Text search (10k notes) | < 200ms | GIN index on tsvector |
| Semantic search (10k embeddings) | < 500ms | HNSW index on pgvector |
| Hybrid search (10k notes + embeddings) | < 1s | Two queries + RRF fusion |
| Embedding generation | < 2s per chunk | transformers.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
| Feature | Server | fortemi-react | Notes |
|---|---|---|---|
| Text search (BM25) | Full | Full | Identical tsvector/tsquery implementation |
| Semantic search (cosine) | Full | Full | Same pgvector HNSW, same distance metric |
| Hybrid search (RRF k=60) | Full | Full | Same algorithm and k constant |
| Search filters | 10+ filters | 12 filters | tags, collection_id, date_from, date_to, is_starred, is_archived, format, source, visibility, include_facets, limit, offset |
| Response.mode field | Reflects actual mode | Reflects actual mode | Full parity |
| Phrase search | `phraseto_tsquery` | `phraseto_tsquery` | Full parity — triggered by quoted input |
| Search suggestions | Autocomplete from tsvector | `useSearchSuggestions` hook | Client-side prefix matching from `ts_stat` vocabulary |
| Search history | Stored in DB | `useSearchHistory` hook | localStorage (survives archive switches) |
| Faceted results | Tag/collection counts | `include_facets` option | Top 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`