Best Practices

Recommended patterns for tagging, search, and content organization.

Best Practices

Research-backed guidance for getting the most out of Fortemi. Each section provides a quick recommendation, the reasoning behind it, a decision framework, and common mistakes to avoid.

Search Mode Selection

Quick recommendation: Use hybrid search for most queries. Override to specialized modes only when you know the query type and its characteristics.

Why Hybrid Works

Hybrid search combines BM25 full-text search (FTS) with dense vector retrieval using Reciprocal Rank Fusion (RRF). This approach:

  • Handles both exact keyword matching and conceptual similarity
  • Automatically balances term precision and semantic understanding
  • Provides robust fallback when one method fails
  • Achieves 15-20% better recall than either method alone

The system implements adaptive RRF with dynamic k-values and weights based on query characteristics:

  • Short queries (1-2 words): k multiplied by 0.7 to favor top results
  • Long queries (8+ words): k multiplied by 1.3 to consider more candidates
  • Quoted phrases: k multiplied by 0.6 to focus on exact matches

Decision Tree

Query analysis:
├── Unknown query type or mixed requirements
│   └── Use: hybrid (default)
│
├── Exact phrase, code snippet, known terminology
│   └── Use: fts
│       └── If contains CJK characters: enable FTS_BIGRAM_CJK=true
│       └── If contains emoji/symbols: enable FTS_TRIGRAM_FALLBACK=true
│
├── Conceptual question, "how do I", natural language
│   └── Use: semantic
│
└── Need score magnitude (not just ranking)
    └── Use: hybrid with rsf fusion mode

Performance Characteristics

ModeLatency (10K docs)Best forWeakness
hybrid45-80msGeneral queries, unknown intentSlightly slower than pure methods
fts15-30msExact terms, code, identifiersMisses conceptual matches
semantic30-60msConceptual, natural languageMisses exact terminology
adaptive-rrf50-90msMixed query characteristicsHighest latency
rsf50-85msNeed actual score valuesMore computationally intensive

Adaptive Weights

The system automatically adjusts FTS/semantic weights based on query analysis:

Query TypeFTS WeightSemantic WeightDetection Criteria
Quoted phrases0.700.30Contains "quoted text"
Keyword-heavy0.600.40Short, technical terms, no question words
Balanced0.500.50Default, mixed characteristics
Conceptual0.350.65Contains "how", "why", "what is", longer

When to Use RSF

Reciprocal Score Fusion (RSF) preserves score magnitude instead of just rank order:

  • Use when: You need actual similarity scores for filtering, thresholding, or downstream ranking
  • Benefit: +6% recall improvement on FIQA benchmark compared to RRF
  • Cost: 10-15% higher latency due to score normalization

Common Mistakes

Using semantic for exact term lookup

  • Problem: Embedding models may generalize away specific terminology
  • Solution: Use FTS mode for code, identifiers, or precise terms

Using FTS for conceptual queries

  • Problem: Keyword matching fails when query and document use different words
  • Solution: Use semantic or hybrid for "how to" questions and conceptual searches

Not enabling multilingual feature flags

  • Problem: Poor results for CJK, emoji, or non-English queries
  • Solution: Enable `FTS_SCRIPT_DETECTION=true`, `FTS_BIGRAM_CJK=true`, `FTS_TRIGRAM_FALLBACK=true`

Over-specifying search mode

  • Problem: Manually choosing modes adds complexity without benefit
  • Solution: Let hybrid mode handle most cases, override only when testing specific behaviors

Embedding Strategy

Quick recommendation: Use `nomic-embed-text-v1.5` at 768 dimensions for general use. Consider MRL truncation to 256 dimensions for collections over 50K documents.

Model Selection

Research (REF-068) shows that smaller models with LLM re-ranking outperform larger models by 7.7-23.1%. The key insight: model size matters less than domain fit and retrieval strategy.

Domain factor (REF-069) has 1.00 effect size - the strongest predictor of retrieval quality. Only fine-tune when baseline Recall@10 is below 60%.

Use CaseRecommended ModelDimensionsRationale
General knowledge basenomic-embed-text-v1.5768Best balance: MRL support, good performance, moderate size
Budget/performanceall-MiniLM-L6-v238422M params, works best with re-ranking (REF-068)
Multilingual (100+ langs)multilingual-e5-large1024Specialized for cross-lingual retrieval
High accuracy, no MRLbge-large-en-v1.51024335M params, strongest baseline quality
High accuracy + MRLmxbai-embed-large-v11024335M params, MRL support, best of both

MRL Trade-offs

Matryoshka Representation Learning (REF-067) enables dimension truncation with minimal quality loss, achieving up to 12x storage and compute savings.

DimensionsStorage ReductionQuality LossUse When
768 (full)1x (baseline)0%Default for most use cases
5121.5x<1%Negligible loss, moderate savings
2563x1-2%Good balance for large collections (50K-500K docs)
1286x2-3%Acceptable for very large collections (>500K docs)
6412x3-5%Maximum savings, use only when storage/compute critical

MRL quality tradeoff analysis (REF-067):

  • 64-dim: 12x reduction, 3-5% Recall@10 loss
  • 128-dim: 6x reduction, 2% loss
  • 256-dim: 3x reduction, 1% loss

Filter Sets vs Full Sets

Filter sets share embeddings from the default embedding set:

  • Use when: Same model, just filtering results by tag/scheme
  • Benefit: No duplicate embeddings, instant "set" creation
  • Limitation: Cannot use different models or MRL dimensions

Full sets maintain independent embeddings:

  • Use when: Different domain needing specialized model
  • Use when: Want different MRL truncation per set
  • Use when: Need to experiment with models without affecting production
  • Cost: Full re-embedding required, storage multiplied

Decision Tree

Embedding strategy selection:
├── Starting new system or general-purpose?
│   └── nomic-embed-text-v1.5, 768-dim, default set
│
├── Collection size > 50K documents?
│   └── Enable MRL, use 256-dim truncation
│       └── If size > 500K: consider 128-dim
│
├── Need multilingual support?
│   └── multilingual-e5-large, full 1024-dim
│
├── Budget-constrained with LLM re-ranking?
│   └── all-MiniLM-L6-v2, 384-dim (REF-068)
│
├── Multiple domains with different needs?
│   └── Full embedding sets per domain
│
└── Just filtering existing embeddings by tag?
    └── Filter sets (shared embeddings)

Two-Stage Retrieval

For collections over 100K documents, use two-stage retrieval:

1. Coarse stage: Use 64-dim MRL truncation to scan entire corpus 2. Fine stage: Re-rank top 100 candidates with full 768-dim vectors

Benefits:

  • 128x reduction in floating-point operations (MFLOPS)
  • 12x reduction in vector comparison operations
  • Maintains 98%+ recall compared to single-stage
  • Latency reduction: 200ms -> 50ms for 500K document corpus

When to enable:

  • Collection size > 100K documents
  • Latency requirements < 100ms
  • Storage or compute constraints

Common Mistakes

Assuming bigger model equals better results

  • Problem: REF-068 shows all-MiniLM-L6-v2 (22M) outperforms bge-large (335M) by 23.1% with re-ranking
  • Solution: Choose model based on domain fit and retrieval strategy, not parameter count

Using non-MRL models with dimension truncation

  • Problem: bge-large-en-v1.5 and all-MiniLM-L6-v2 don't support MRL, truncation degrades quality
  • Solution: Use nomic-embed-text-v1.5 or mxbai-embed-large-v1 for MRL support

Fine-tuning when baseline is already good

  • Problem: REF-069 shows domain fine-tuning only helps when Recall@10 < 60%
  • Solution: Measure baseline first, only fine-tune if retrieval quality is poor

Not using two-stage retrieval for large corpora

  • Problem: Full-dimension search on 500K+ docs causes 200ms+ latency
  • Solution: Enable two-stage retrieval for 128x compute reduction with minimal quality loss

Mixing MRL dimensions in filter sets

  • Problem: Filter sets share embeddings, cannot have per-"set" truncation
  • Solution: Use full embedding sets when you need different MRL dimensions

Chunking Strategy

Quick recommendation: Use semantic chunking for markdown documentation, syntactic chunking for code, and per-section chunking for formal documents. Aim for 500-2000 tokens per chunk with 50-100 character overlap.

Why Chunking Matters

Effective chunking ensures:

  • Embeddings capture cohesive semantic units
  • Context windows don't split related information
  • Retrieval granularity matches query scope
  • Search results provide actionable excerpts

Size Guidelines

Embedding ModelMin TokensOptimal RangeMax Tokens
all-MiniLM-L6-v2200500-15002000
nomic-embed-text-v1.5300700-18002500
bge-large / mxbai-embed-large400800-20003000
multilingual-e5-large400800-20003000

Overlap: Use 50-100 characters between chunks to avoid splitting sentences mid-thought.

Decision Table by Document Structure

Document StructureStrategyReasonOverlap
Markdown with headings`semantic`Respects heading hierarchy, code blocks, lists100 chars
Source code`syntactic`Uses language-specific parsers, preserves function boundaries50 chars
Plain prose/narrative`sentence`Natural semantic boundaries, clean splits75 chars
Blog posts, articles`paragraph`Paragraph = complete thought unit100 chars
Dense unstructured text`sliding_window`Overlapping windows ensure coverage200 chars
Mixed/unknown structure`recursive`Tries multiple strategies, falls back gracefully100 chars
Formal documents (RFC, spec)`per_section`Section = discrete requirement/specificationMinimal

Chunking Strategies Explained

SemanticChunker

  • Best for: Markdown, documentation, structured notes
  • How: Parses markdown AST, splits on headings while preserving code blocks and lists
  • Preserves: Code fences, blockquotes, list continuity

SyntacticChunker

  • Best for: Source code (Python, JavaScript, Rust, Go, Java, etc.)
  • How: Uses language-specific parsers (tree-sitter) to split on function/class boundaries
  • Preserves: Complete functions, imports, docstrings

SentenceChunker

  • Best for: Prose, essays, articles, natural language text
  • How: Splits on sentence boundaries using linguistic rules
  • Preserves: Complete sentences, paragraph context

ParagraphChunker

  • Best for: Blog posts, formatted articles
  • How: Splits on paragraph breaks (double newlines)
  • Preserves: Complete paragraphs as semantic units

SlidingWindowChunker

  • Best for: Dense text without clear structure, transcripts
  • How: Fixed-size windows with overlap, ensures no content skipped
  • Preserves: Coverage through overlap

RecursiveChunker

  • Best for: Unknown or mixed content types, fallback strategy
  • How: Tries delimiter-based splitting (newlines, sentences, words) recursively
  • Preserves: Adaptability to unknown formats

PerSectionChunker

  • Best for: Formal documents (RFCs, specifications, legal)
  • How: Each numbered section becomes a chunk
  • Preserves: Requirement/specification atomicity

Common Mistakes

Using fixed-size chunks for structured documents

  • Problem: Splits headings, code blocks, and lists mid-unit, destroying semantic coherence
  • Solution: Use semantic or syntactic chunking to respect document structure

Not overlapping chunks

  • Problem: Information spanning chunk boundaries is lost, reducing recall
  • Solution: Use 50-100 character overlap to maintain context continuity

Chunks too small

  • Problem: Insufficient context for embeddings, noisy retrieval results
  • Solution: Keep chunks above 500 tokens for most embedding models

Chunks too large

  • Problem: Diluted semantic signal, poor match granularity, exceeds model context
  • Solution: Keep chunks below 2500 tokens, prefer smaller when possible

Wrong chunking strategy for document type

  • Problem: Using sentence chunker on code, or syntactic chunker on prose
  • Solution: Match strategy to document structure (see decision table above)

Ignoring chunk overlap in retrieval

  • Problem: Duplicate content in search results confuses users
  • Solution: De-duplicate overlapping chunks in post-processing or rank aggregation

Tag Organization

Quick recommendation: Let the NLP pipeline handle content-based tagging automatically. Only add organizational tags manually — project names, status markers, and scope identifiers that can't be inferred from content.

Automatic vs Manual Tags

Fortémi's NLP pipeline generates 8-15 SKOS concept tags per note covering domain, topic, methodology, application, technique, and content-type. These content-based tags are fully automatic and don't require manual intervention.

What the AI tags automatically:

  • Domain classification (`domain/programming`, `domain/science/physics`)
  • Topic identification (`topic/rust/ownership`, `topic/machine-learning`)
  • Methodology (`methodology/experimental`, `methodology/literature-review`)
  • Content type (`content-type/tutorial`, `content-type/research-paper`)

What you should tag manually:

  • Project assignment (`project/alpha`, `client/acme`)
  • Status tracking (`status/draft`, `status/reviewed`, `status/archived`)
  • Scope/visibility (`scope/personal`, `scope/work`, `scope/public`)
  • Source tracking (`source/meeting`, `source/conversation`)

Why Hierarchical Tags

Hierarchical tags with SKOS semantics provide:

  • Inheritance: Search for `domain/programming` finds notes also tagged `domain/programming/rust`
  • Filtering: Strict tag filtering guarantees data isolation for multi-tenancy
  • Navigation: Tree structure enables browsing and exploration
  • Clarity: Path format shows context: `project/fortemi/architecture`

PMEST Faceted Classification

For the manual organizational tags, PMEST provides a useful framework:

Personality (Type) - What kind of resource? (auto-detected as `content-type`)

  • Auto-generated by AI — typically don't need manual `type/` tags

Matter (Source) - Where did it come from?

  • `source/book`, `source/article`, `source/video`, `source/conversation`
  • Useful for filtering by provenance

Energy (Domain) - What subject area?

  • Auto-generated by AI — the pipeline handles `domain/` classification

Space (Scope) - What context?

  • `scope/personal` - Personal use only
  • `scope/work` - Work-related content
  • `scope/public` - Shareable externally

Time (Status) - What stage?

  • `status/active` - Currently working on
  • `status/archived` - Completed or deprecated
  • `status/someday` - Future consideration

Example Tag Set

A note's tags are a combination of AI-generated and manual tags:

# Auto-generated by NLP pipeline (don't add these manually):
domain/technology/databases/postgresql
topic/indexing/btree
content-type/technical-note
methodology/analysis

# Manually added (organizational):
project/fortemi
scope/work
status/active

Anti-Pattern Detection

The system automatically detects tag organization issues:

Anti-PatternExampleProblemFix
over_nesting`a/b/c/d/e/f/g`Too deep (>4 levels), hard to navigateSimplify to 3-4 levels max
meta_tag`important`, `todo`, `urgent`Generic, non-descriptive, status better in fieldsUse specific domain tags, track status separately
orphanTag with 0 notesUnused tag clutters namespaceDelete or archive unused tags
synonym_sprawl`tech`, `technology`, `technical`Inconsistent terminologyPick one canonical form, merge others
mixed_hierarchy`technology/rust` + `rust/programming`Inconsistent structureStandardize on one hierarchy pattern

Hierarchy Depth Guidelines

DepthExampleUse When
1 level`technology`Very broad categorization, rare
2 levels`technology/rust`Common, good balance
3 levels`technology/rust/async`Specific topic, still manageable
4 levels`technology/rust/async/tokio`Maximum recommended depth
5+ levels`technology/rust/async/tokio/runtime/config`Avoid - too complex

Common Mistakes

Manually tagging what the AI handles

  • Problem: Adding `domain/technology/rust` manually when the AI already generates these tags
  • Solution: Let the NLP pipeline handle content-based tagging. Only add organizational tags (project, status, scope) manually

Not reviewing auto-generated concepts

  • Problem: AI-generated concepts accumulate in "candidate" status, synonym sprawl develops
  • Solution: Periodically review `GET /api/v1/concepts?status=candidate`, promote good concepts, merge duplicates

Over-nesting beyond 4 levels

  • Problem: Deep hierarchies become unmanageable, hard to browse or remember
  • Solution: Limit to 3-4 levels, use multiple facets instead of single deep tree

Generic meta-tags

  • Problem: Tags like `important`, `todo`, `review` don't describe content, just status
  • Solution: Use note fields for status, use tags for domain/topic classification

Synonym sprawl

  • Problem: `tech`, `technology`, `technical` fragment search results
  • Solution: Choose one canonical term, create SKOS `broader`/`narrower` relations to merge. The AI may generate synonyms — curate periodically

Mixing unrelated domains in same hierarchy

  • Problem: `work/project/rust/meeting` mixes organizational structure with domain topics
  • Solution: Use separate facets: `domain/technology/rust` + `scope/work` + `type/project`

Not using SKOS schemes for isolation

  • Problem: All users' tags in global namespace, risk of collision or leakage
  • Solution: Create SKOS scheme per tenant, use `required_schemes` filter for strict isolation

Multi-Tenancy and Isolation

Quick recommendation: Use SKOS scheme-based isolation with `required_schemes` filtering for guaranteed data segregation between tenants, teams, or projects.

Strict Tag Filtering Architecture

Fortemi implements pre-search filtering using PostgreSQL WHERE clauses before vector search. This provides:

  • 100% isolation guarantee: Filters applied at SQL level, impossible to leak across boundaries
  • Performance: Filters reduce search space, making vector operations faster
  • Security: Trust boundary at database layer, not application logic

Pattern for Multi-Tenancy

1. Create SKOS scheme per tenant

   POST /tags/schemes
   {
     "uri": "scheme://tenant-acme-corp",
     "name": "Acme Corp Knowledge Base"
   }

2. Tag all tenant content within their scheme

   All Acme Corp notes tagged with:
   - scheme://tenant-acme-corp/projects/roadrunner
   - scheme://tenant-acme-corp/teams/engineering

3. Apply `required_schemes` filter on all searches

   {
     "query": "widget specifications",
     "required_schemes": ["scheme://tenant-acme-corp"]
   }

4. Result: Only notes within `scheme://tenant-acme-corp` are considered, regardless of query

Filter Types

FilterLogicUse CaseExample
required_tagsANDMust have all tags`["project/matric", "status/active"]`
any_tagsORMust have at least one tag`["language/rust", "language/python"]`
excluded_tagsNOTMust not have any tag`["status/archived", "scope/private"]`
required_schemesIsolationMust be in scheme(s)`["scheme://tenant-acme"]`
excluded_schemesExclusionMust not be in scheme(s)`["scheme://deprecated"]`

Combining Filters

Filters are applied as SQL WHERE clauses before vector search:

WHERE
  -- required_tags: note must have ALL of these
  note_id IN (SELECT note_id FROM note_tags WHERE tag_id = 'project/matric')
  AND note_id IN (SELECT note_id FROM note_tags WHERE tag_id = 'status/active')

  -- any_tags: note must have AT LEAST ONE of these
  AND note_id IN (
    SELECT note_id FROM note_tags
    WHERE tag_id IN ('language/rust', 'language/python')
  )

  -- excluded_tags: note must have NONE of these
  AND note_id NOT IN (
    SELECT note_id FROM note_tags
    WHERE tag_id IN ('status/archived', 'scope/private')
  )

  -- required_schemes: note must be in this scheme
  AND note_id IN (
    SELECT note_id FROM note_tags nt
    JOIN tags t ON nt.tag_id = t.id
    WHERE t.scheme_id IN ('scheme://tenant-acme')
  )

Common Mistakes

Relying on soft tag filtering for security

  • Problem: Application-layer filtering can be bypassed by bugs or injection
  • Solution: Use `required_schemes` with SQL-level enforcement for security boundaries

Forgetting to filter in all query paths

  • Problem: Search endpoint filtered, but graph traversal or related notes APIs not filtered
  • Solution: Apply tenant scheme filter in middleware/context, enforce at database layer

Not using scheme isolation for strong boundaries

  • Problem: Using regular tags for tenant separation, risk of tag collision or manual error
  • Solution: SKOS schemes provide URI-based namespacing with strict semantics

Over-filtering

  • Problem: Applying too many filter combinations makes search too restrictive, zero results
  • Solution: Start broad (scheme isolation only), add specific filters progressively

Mixing isolation and categorization

  • Problem: Using `required_schemes` for topic filtering instead of security isolation
  • Solution: Schemes for tenant boundaries, regular tags for topic categorization within tenant

Performance Optimization

Quick recommendation: Start with default settings. Tune HNSW `ef_search` and enable batch operations as you scale past 10K documents. Use strict tag filtering to reduce search space.

HNSW Parameter Tuning

HNSW (Hierarchical Navigable Small World) indexes provide approximate nearest neighbor search. The `ef_search` parameter controls the accuracy-speed tradeoff.

Adaptive ef_search formula:

ef_search = base_ef * max(1.0, log2(corpus_size / 10000) * scale_factor)

Recall target table:

Recall Targetbase_efscale_factor10K docs100K docs1M docs
Fast (85%)400.5405373
Balanced (92%)801.080160293
High (96%)1501.5150375825
Exhaustive (99%)3002.03009002400

When to tune:

  • Collection size > 50K documents: Increase ef_search for maintained recall
  • Latency requirements < 50ms: Decrease ef_search, accept lower recall
  • Precision-critical queries: Use "High" or "Exhaustive" preset

Batch Operations

Embedding jobs: Batch 50-100 documents per job for optimal throughput

POST /jobs/embed
{
  "note_ids": [100 IDs],
  "embedding_set_id": "default"
}

Bulk imports: Use bulk note creation endpoint with pre-computed embeddings

POST /notes/bulk
{
  "notes": [50-100 note objects]
}

Benefits:

  • 10x reduction in API round-trips
  • Shared connection pooling
  • Transaction batching
  • Embedding model batch inference

Query Optimization

Pre-filter with strict tags:

{
  "query": "rust async patterns",
  "required_schemes": ["scheme://my-tenant"],
  "required_tags": ["domain/technology/rust"]
}
  • Reduces search space by 90%+ for tenanted systems
  • Allows smaller `ef_search` with same recall
  • Faster vector operations on smaller candidate set

Use MRL for faster vector comparisons:

  • 256-dim vs 768-dim: 3x fewer floating-point operations
  • 128-dim vs 768-dim: 6x reduction
  • Enable for collections > 50K documents

Enable two-stage retrieval:

{
  "query": "machine learning fundamentals",
  "two_stage": true,
  "coarse_dim": 64,
  "coarse_top_k": 100,
  "fine_dim": 768
}
  • 128x reduction in MFLOPS
  • Stage 1: Fast 64-dim scan finds top 100
  • Stage 2: Precise 768-dim re-ranking
  • Use for collections > 100K documents

Hardware Reference Tiers

ScaleDocumentsHardwareKey Optimization
Small<10K2 CPU, 4GB RAM, 20GB SSDDefault settings sufficient
Medium10K-100K4 CPU, 8GB RAM, 100GB SSDMRL 256-dim, batch jobs
Large100K-500K8 CPU, 16GB RAM, 500GB SSDTwo-stage retrieval, ef_search tuning
Very Large500K-2M16 CPU, 32GB RAM, 1TB NVMeMRL 128-dim, aggressive batching, read replicas
Enterprise>2M32+ CPU, 64GB+ RAM, NVMe RAIDSharding, distributed search, dedicated vector DB

PostgreSQL configuration for scale:

  • `shared_buffers`: 25% of RAM
  • `effective_cache_size`: 50% of RAM
  • `maintenance_work_mem`: 1GB for index builds
  • `max_connections`: 100-200 (use connection pooling)

Common Mistakes

Over-tuning HNSW for small collections

  • Problem: Adjusting ef_search for 5K document corpus provides minimal benefit
  • Solution: Use defaults until collection exceeds 50K documents

Not using strict filters to reduce search space

  • Problem: Searching entire corpus when query scope is narrow (single project/tenant)
  • Solution: Apply `required_schemes` and `required_tags` filters to reduce candidate set by 90%+

Running embedding jobs sequentially

  • Problem: Processing 10K notes one-by-one takes hours
  • Solution: Batch 50-100 notes per job, run jobs in parallel

Not enabling MRL for large collections

  • Problem: Full 768-dim vectors on 500K corpus causes 200ms+ latency
  • Solution: Use 256-dim MRL truncation for 3x speedup with <2% quality loss

Using cloud embedding models for high-volume workloads

  • Problem: API rate limits and latency accumulate, expensive at scale
  • Solution: Use local Ollama for >1000 embeds/day, cloud for bursty workloads

Not using connection pooling

  • Problem: Opening new PostgreSQL connection per request adds 50-100ms overhead
  • Solution: Use connection pool (sqlx default), configure `max_connections` appropriately

Security

Quick recommendation: Use API keys for scripts and CLI tools, OAuth2 with PKCE for web applications, PKE encryption for sensitive documents, and local Ollama for privacy-sensitive embeddings.

Authentication and Authorization

Fortemi supports multiple authentication mechanisms based on use case:

NeedSolutionSetupSecurity Level
Script/CLI accessAPI key with minimal scopeGenerate via `/oauth/register`Medium - Revocable, scoped
Web applicationOAuth2 with PKCEStandard OAuth2 flowHigh - Time-limited tokens
Sensitive documentsPKE encryptionX25519 key exchange + AES-256-GCMVery High - E2E encryption
Privacy-sensitive embeddingsLocal OllamaRun inference on-premiseHigh - No data leaves network
Multi-user access controlOAuth2 scopes + SKOS schemesCombine with `required_schemes`High - SQL-enforced isolation
Backup encryptionKnowledge shard encryptionExport with PKE encryptionHigh - Encrypted at rest

API Key Best Practices

Generate minimal-scope keys:

curl -X POST https://memory.example.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Backup Script",
    "grant_types": ["client_credentials"],
    "scope": "read"
  }'

Scopes:

  • `read` - Search, retrieve notes (safe for most scripts)
  • `write` - Create, update notes
  • `delete` - Delete notes, tags
  • `admin` - User management, system configuration
  • `mcp` - MCP server token introspection

Rotation schedule:

  • Human API keys: Every 90 days
  • Service accounts: Every 180 days
  • Compromised keys: Immediate revocation

OAuth2 Flow for Web Apps

PKCE (Proof Key for Code Exchange) prevents authorization code interception:

1. Generate code_verifier (random string) 2. Compute code_challenge = base64url(sha256(code_verifier)) 3. Redirect to `/oauth/authorize?code_challenge=...` 4. Exchange authorization code + code_verifier for token 5. Use token for API requests until expiration

Token lifetimes:

  • Access token: 1 hour
  • Refresh token: 30 days
  • Rotate access token before expiration using refresh token

PKE Encryption for Sensitive Documents

Use case: Share sensitive notes without server having decryption key

Key exchange:

1. Recipient generates X25519 key pair (private key never leaves device)
2. Sender retrieves recipient's public key
3. Sender encrypts note with AES-256-GCM, uses ECDH shared secret for key derivation
4. Encrypted note stored on server, only recipient can decrypt

Encryption command:

POST /notes/{id}/encrypt
{
  "recipient_public_key": "base64-encoded-x25519-pubkey"
}

Decryption (client-side):

GET /notes/{id}/encrypted
# Returns encrypted payload
# Client decrypts using private key + ECDH

Privacy-Sensitive Embeddings

Problem: Sending documents to OpenAI/cloud providers for embedding reveals content

Solution: Use local Ollama instance

Setup:

# Install Ollama
curl -fsSL https://ollama.ai/install.sh | sh

# Pull embedding model
ollama pull nomic-embed-text

# Configure Fortémi
export OLLAMA_HOST=http://localhost:11434
export EMBEDDING_MODEL=nomic-embed-text

Benefits:

  • No data leaves local network
  • No API rate limits or costs
  • Full control over model versions
  • Compliance with data residency requirements

Multi-Tenant Security

Pattern: 1. Create OAuth2 client per tenant 2. Create SKOS scheme per tenant 3. Inject `required_schemes` filter from OAuth token claims 4. Enforce at middleware layer before any database query

Implementation:

// Extract tenant from OAuth token
let tenant_id = claims.get("tenant_id")?;

// Enforce scheme filter
query.required_schemes = vec![format!("scheme://{}", tenant_id)];

// All subsequent queries isolated to this tenant

Common Mistakes

Over-privileged API keys

  • Problem: CLI script has `admin` scope, compromised key grants full access
  • Solution: Use minimal scope (`read` or `write` only), never `admin` for automation

Not rotating tokens

  • Problem: Long-lived tokens increase risk window if compromised
  • Solution: Rotate API keys every 90 days, use short-lived OAuth tokens (1 hour)

Storing secrets in code

  • Problem: Hardcoded API keys committed to git, exposed in logs
  • Solution: Use environment variables, secret management (HashiCorp Vault, AWS Secrets Manager)

Using cloud inference for sensitive data without considering privacy

  • Problem: Medical records, legal documents, PII sent to OpenAI for embedding
  • Solution: Use local Ollama for regulated/sensitive content, cloud for non-sensitive

Not enforcing tenant isolation at database layer

  • Problem: Application logic filters by tenant, but SQL injection or bug bypasses filter
  • Solution: Use `required_schemes` with SQL WHERE clause, enforce in middleware

Unencrypted backups

  • Problem: Exporting notes to disk without encryption, readable by anyone with file access
  • Solution: Use knowledge shard encryption, export with PKE encryption enabled

Missing audit logging

  • Problem: Cannot track who accessed sensitive documents or detect breaches
  • Solution: Enable audit logging for read/write operations on sensitive schemes

For detailed configuration, see Configuration Reference. For deployment patterns, see Use Cases. For consumer API documentation, see the API Reference. The full generated schema is available only to administrators at `/api/v1/operator/openapi.yaml`.