Hybrid Search combines the strengths of Full-Text Search (FTS) and Semantic Search using Reciprocal Rank Fusion (RRF), a proven algorithm used by Elasticsearch, Weaviate, and other search platforms. This approach leverages:
- Full-Text Search: Exact keyword matching, stemming, phrase search, and boolean queries
- Semantic Search: Meaning-based similarity using vector embeddings
- RRF Fusion: Rank-based algorithm that combines results without score normalization
This combination is particularly powerful for:
- Finding content that matches both keywords AND meaning
- Boosting results that appear in both search methods (high confidence matches)
- Graceful fallback when one search method is unavailable
- Cross-domain queries where exact terms and related concepts matter
Hybrid search is auto-enabled by default: it registers automatically whenever at least one underlying search method (full-text or semantic) is available.
Hybrid Search requires at least one underlying search method to be available. Both are auto-enabled by default:
- Full-Text Search (FTS):
ENABLE_FTS=auto(default; no additional dependencies, so it is normally available) - Semantic Search:
ENABLE_SEMANTIC_SEARCH=auto(default; available whenever an embedding provider is present, which it is when embedding generation is on by default)
With the defaults, hybrid search works out of the box. The explicit true form remains available (for the prerequisite-gated toggles it registers the tool only when the prerequisites are present, logging a warning otherwise):
ENABLE_FTS=true
ENABLE_SEMANTIC_SEARCH=true
ENABLE_HYBRID_SEARCH=trueDependencies by Search Mode:
| Search Mode | Dependencies | Setup Guide |
|---|---|---|
| FTS only | None (built-in) | Full-Text Search Guide |
| Semantic only | Ollama, embedding model, sqlite-vec/pgvector | Semantic Search Guide |
| Both (recommended) | All above | Both guides |
Hybrid Search uses existing FTS and Semantic Search infrastructure. No additional installation is required beyond the dependencies for your chosen search modes.
For full hybrid search capability:
# Install embedding provider and reranking dependencies (e.g., Ollama)
uv sync --extra embeddings-ollama --extra reranking
# Or use another provider: embeddings-openai, embeddings-azure, embeddings-huggingface, embeddings-voyage
# Pull embedding model (for Ollama)
ollama pull qwen3-embedding:0.6bNote: The --extra reranking is necessary to enable reranking.
Hybrid search is controlled by the following environment variables in your MCP configuration:
- Type: Tri-state
- Default:
auto - Description: Controls registration of the
hybrid_search_contexttool.auto(default) andtrueboth register it when at least one underlying search method is available, otherwise skip it with a warning (hybrid has no underlying mode to fuse, sotruecannot force the tool on with neither);falseforces it off, for the minimal tool surface. The boolean spellingstrue/false/1/0/yes/no/on/offare also accepted (they map totrue/false). - Example:
"ENABLE_HYBRID_SEARCH": "false"(only needed to disable; default already registers the tool)
Note: Hybrid search still requires at least one underlying search method to be available. With the defaults (ENABLE_FTS=auto, ENABLE_SEMANTIC_SEARCH=auto), this condition is normally satisfied; if you force both underlying methods off, the hybrid tool is skipped even when ENABLE_HYBRID_SEARCH=true.
- Type: Integer
- Default:
60 - Range: 1-1000
- Description: RRF smoothing constant controlling how much emphasis is placed on top-ranked vs. lower-ranked documents
- Example:
"HYBRID_RRF_K": "60"
Understanding RRF k Parameter:
| k Value | Behavior |
|---|---|
| Lower (10-30) | More emphasis on top-ranked documents; larger score differences between ranks |
| Default (60) | Balanced approach; industry standard used by Elasticsearch |
| Higher (100+) | More uniform treatment across all ranks; smaller score differences |
With the defaults, FTS, semantic, and hybrid search all auto-register, so no ENABLE_* entries are required. Installing the embedding provider extra (here, Ollama) is what makes semantic search available, and hybrid follows automatically. Add to your .mcp.json file (the HYBRID_RRF_K entry below is optional tuning):
{
"mcpServers": {
"context-server": {
"type": "stdio",
"command": "uvx",
"args": [
"--python",
"3.12",
"--with",
"mcp-context-server[embeddings-ollama,reranking]",
"mcp-context-server"
],
"env": {
"HYBRID_RRF_K": "60"
}
}
}
}Note: The --extra reranking is necessary to enable reranking.
By default, hybrid search uses match mode (AND logic) for FTS queries, requiring all terms to appear in a document. For long queries typical of LLM agents (4+ terms), this often returns zero FTS results, degrading hybrid search to semantic-only.
The adaptive FTS mode automatically switches long queries to OR logic:
| Query Length | FTS Mode | Logic | Example |
|---|---|---|---|
| 1-3 significant terms | match |
AND (all terms required) | "python async" |
| 4+ significant terms | boolean |
OR (any term matches) | "DRY extraction embedding helper" |
Configuration:
HYBRID_FTS_OR_THRESHOLD=4 # Default: switch to OR at 4+ termsHow it works:
- The query is split into words; single-character words are excluded from the count
- If significant word count >= threshold, terms are joined with OR keywords
- Hyphens are replaced with spaces to prevent NOT operator interpretation
- The transformed query uses
booleanmode (websearch_to_tsqueryon PostgreSQL, FTS5 boolean on SQLite)
Tuning: Use explain_query=True to see the adaptive_fts_mode field in stats. If too many irrelevant results appear, increase the threshold. If FTS still returns zero results frequently, decrease it.
Reciprocal Rank Fusion combines results from multiple search methods using a simple yet effective formula:
RRF_score(d) = sum(1 / (k + rank_i(d))) for each search method i
Where:
dis a documentkis the smoothing constant (default: 60)rank_i(d)is the rank of documentdin result listi(1-based)
1. Rank-based, not score-based: RRF uses positions rather than raw scores, avoiding the need to normalize different scoring systems (BM25 vs L2 distance).
2. Documents in both lists score higher: A document ranked #1 in FTS and #1 in semantic search gets:
RRF = 1/(60+1) + 1/(60+1) = 0.0328
While a document ranked #1 in only one list gets:
RRF = 1/(60+1) = 0.0164
3. Graceful handling of unique results: Documents appearing in only one search method still receive a score and can rank highly if their single-source rank is good.
FTS Results: Semantic Results: After RRF Fusion:
1. Doc A (score 2.5) 1. Doc B (dist 0.15) 1. Doc B (rrf 0.0328) [in both]
2. Doc B (score 2.1) 2. Doc C (dist 0.22) 2. Doc A (rrf 0.0164) [FTS only]
3. Doc D (score 1.8) 3. Doc A (dist 0.35) 3. Doc C (rrf 0.0164) [semantic only]
4. Doc D (rrf 0.0159) [FTS only]
Doc B appears in both lists (FTS rank 2, semantic rank 1), so it scores highest after fusion.
When both hybrid search and reranking are enabled (both are enabled by default), reranking is applied AFTER RRF fusion:
- FTS and semantic search run in parallel
- RRF fusion combines results
- Cross-encoder reranking refines final ordering
This ensures documents found by both methods rank highest, then reranking optimizes relevance.
The candidate pool is FIXED and page-independent: it does not grow with the requested limit or offset. Both RRF and the cross-encoder are reordering stages, so a pool sized from the requested page would produce a different ordering for every page, and adjacent pages would repeat rows while other rows were never returned at all.
User requests: limit=5, offset=0
|
v
Each leg fetches: 100 (ranked depth) * 2 (HYBRID_RRF_OVERFETCH) = 200 candidates
|
v
RRF fuses both legs into one ordering, kept to the ranked depth: 100 documents
|
v
Cross-encoder scores that window and reorders it
|
v
The requested page is cut from the result: 5 rows
A page whose window reaches past the ranked depth is served short, and the response says so through the rank_depth_limit key.
Reranking is controlled by these environment variables (see Semantic Search Guide for details):
| Variable | Default | Description |
|---|---|---|
ENABLE_RERANKING |
true |
Enable cross-encoder reranking |
HYBRID_RRF_OVERFETCH |
2 |
Multiplier applied to the ranked depth for each leg |
When hybrid search is enabled and at least one underlying search method is available, a new MCP tool becomes available.
Parameters:
query(str, required): Natural language search querylimit(int, optional): Maximum results to return (1-100, default: 5)offset(int, optional): Pagination offset (default: 0)fusion_method(str, optional): Fusion algorithm - currently only'rrf'supportedrrf_k(int, optional): RRF smoothing constant (1-1000, default from settings)thread_id(str, optional): Optional filter by threadsource(str, optional): Filter by source type ('user' or 'agent')tags(list, optional): Filter by any of these tags (OR logic; at most 100 tags per request)content_type(str, optional): Filter by content type ('text' or 'multimodal')start_date(str, optional): Filter entries created on or after this date (ISO 8601 format)end_date(str, optional): Filter entries created on or before this date (ISO 8601 format)metadata(dict, optional): Simple metadata filters (key=value equality)metadata_filters(list, optional): Advanced metadata filters with operators (in/not_invalue lists accept at most 100 members)include_images(bool, optional): Include image data in results (default: false)explain_query(bool, optional): Include query execution statistics (default: false)
Metadata Filtering: The metadata and metadata_filters parameters work identically to search_context. For comprehensive documentation on operators, nested paths, and best practices, see the Metadata Guide.
Returns:
{
"query": "authentication implementation",
"results": [
{
"id": "0190abcdef1234567890abcdef123456",
"thread_id": "project-alpha",
"source": "agent",
"content_type": "text",
"text_content": "Implemented JWT authentication...",
"summary": "JWT authentication implementation with token validation, role-based access control, and session management for the project-alpha backend API.",
"is_text_content_truncated": true,
"metadata": {"status": "completed", "priority": 8},
"created_at": "2025-12-01T10:00:00Z",
"updated_at": "2025-12-01T10:00:00Z",
"tags": ["auth", "backend"],
"scores": {
"rrf": 0.0328,
"fts_rank": 2,
"semantic_rank": 1,
"fts_score": 2.45,
"semantic_distance": 0.234,
"rerank_score": 0.95
}
}
],
"count": 15,
"fusion_method": "rrf",
"search_modes_used": ["fts", "semantic"],
"fts_count": 12,
"semantic_count": 10,
"stats": {
"execution_time_ms": 125.5,
"fts_stats": {
"execution_time_ms": 15.2,
"filters_applied": 2,
"rows_returned": 12,
"backend": "sqlite",
"query_plan": "..."
},
"semantic_stats": {
"execution_time_ms": 85.3,
"embedding_generation_ms": 45.1,
"filters_applied": 2,
"rows_returned": 10,
"backend": "sqlite",
"query_plan": "..."
},
"fusion_stats": {
"rrf_k": 60,
"total_unique_documents": 15,
"documents_in_both": 7,
"documents_fts_only": 5,
"documents_semantic_only": 3
},
"adaptive_fts_mode": "match"
}
}Note: The stats field is only included when explain_query=True.
When explain_query=True, the response includes a stats object with detailed execution statistics:
| Field | Type | Description |
|---|---|---|
execution_time_ms |
float | Total hybrid search execution time |
fts_stats |
object or null | FTS search statistics (null if FTS not used) |
semantic_stats |
object or null | Semantic search statistics (null if semantic not used) |
fusion_stats |
object | RRF fusion statistics |
adaptive_fts_mode |
string | FTS mode selected by the adaptive AND/OR switch (match or boolean) |
FTS Stats:
fts_stats itself is null (not an object) when the FTS leg was rejected by parameter validation and never ran; the fields below only exist when the leg actually ran:
execution_time_ms: FTS search execution timefilters_applied: Total number of filter conditions applied (thread, source, content type, date bounds and the tag subquery, plus every metadata condition)rows_returned: Number of FTS results before fusionbackend: Active storage backend ('sqlite' or 'postgresql'), always presentquery_plan: Backend query execution plan
Semantic Stats:
semantic_stats itself is null (not an object) when the semantic leg was rejected by parameter validation and never ran; the fields below only exist when the leg actually ran:
execution_time_ms: Semantic search execution timeembedding_generation_ms: Time spent generating the query embedding via the configured embedding providerfilters_applied: Total number of filter conditions applied (thread, source, content type, date bounds and the tag subquery, plus every metadata condition)rows_returned: Number of semantic results before fusionbackend: Active storage backend ('sqlite' or 'postgresql'), always presentquery_plan: Backend query execution plan
Fusion Stats:
rrf_k: RRF smoothing constant usedtotal_unique_documents: Total unique documents after fusiondocuments_in_both: Documents found by both FTS and semantic search (high confidence)documents_fts_only: Documents found only by FTSdocuments_semantic_only: Documents found only by semantic search
Each result includes a scores object with detailed breakdown:
| Field | Type | Description |
|---|---|---|
rrf |
float | Combined RRF score (higher = better) |
fts_rank |
int or null | Position in FTS results (1-based), null if not in FTS results |
semantic_rank |
int or null | Position in semantic results (1-based), null if not in semantic results |
fts_score |
float or null | Original FTS relevance score (BM25/ts_rank) |
semantic_distance |
float or null | Original semantic distance, lower = more similar (Euclidean L2 for uncompressed/mse storage; negated inner product ~ -1..0 for the default ip compression variant) |
rerank_score |
float or null | Cross-encoder relevance score (higher = better, 0.0-1.0), null if reranking disabled |
Interpreting null values:
fts_rank: null, semantic_rank: 3- Document found only via semantic searchfts_rank: 1, semantic_rank: null- Document found only via FTS- Both non-null - Document found by both methods (high confidence match)
Hybrid search automatically adapts when search methods are unavailable:
| FTS Available | Semantic Available | Behavior |
|---|---|---|
| Yes | Yes | Full hybrid search with RRF fusion |
| Yes | No | FTS results only (semantic_rank always null) |
| No | Yes | Semantic results only (fts_rank always null) |
| No | No | Error: "No search modes available" |
Common scenarios for partial availability:
- Semantic unavailable: Ollama not running, embedding model not pulled
- FTS unavailable: FTS migration in progress, database corruption
The search_modes_used field in the response indicates which modes were actually executed.
1. High-confidence document discovery: Find documents matching both keywords AND meaning:
hybrid_search_context(query="authentication token validation")
# Results with both fts_rank and semantic_rank are high-confidence matches2. Fallback to available search: Use hybrid even when uncertain which search methods are running:
hybrid_search_context(query="error handling patterns")
# Works with whatever is available3. Filtered hybrid search: Combine hybrid search with metadata filtering:
hybrid_search_context(
query="performance optimization",
thread_id="project-alpha",
metadata={"status": "completed"},
metadata_filters=[{"key": "priority", "operator": "gte", "value": 7}]
)4. Time-bounded search: Find matching content within a specific date range:
hybrid_search_context(
query="deployment issues",
start_date="2025-11-01",
end_date="2025-11-30"
)5. Tuning RRF for your use case: Adjust k parameter for different ranking behaviors:
# Emphasize top results more (lower k)
hybrid_search_context(query="critical bug", rrf_k=30)
# More uniform treatment of all ranks (higher k)
hybrid_search_context(query="general information", rrf_k=100)Hybrid search executes FTS and semantic search in parallel for optimal performance:
| Operation | SQLite | PostgreSQL |
|---|---|---|
| FTS search | 10-50ms | 5-30ms |
| Semantic search | 50-200ms | 30-100ms |
| Hybrid (parallel) | 55-220ms | 35-115ms |
| RRF fusion | <1ms | <1ms |
Performance notes:
- Searches run in parallel via
asyncio.gather - Over-fetch strategy (limit * 2) improves fusion quality with minimal overhead
- RRF fusion is extremely fast (simple arithmetic operations)
- Total time dominated by slower search method (usually semantic)
-
Confirm FTS is not force-disabled: FTS registers by default (
ENABLE_FTS=auto).echo $ENABLE_FTS # Empty/"auto"/"true" is fine; "false" disables it
-
Confirm semantic search prerequisites (if using semantic):
echo $ENABLE_SEMANTIC_SEARCH # Empty/"auto"/"true" is fine; "false" disables it curl http://localhost:11434 # Should return: Ollama is running ollama list # Should show your embedding model
-
Confirm hybrid search is not force-disabled: Hybrid registers by default (
ENABLE_HYBRID_SEARCH=auto) when at least one underlying method is available.echo $ENABLE_HYBRID_SEARCH # Empty/"auto"/"true" is fine; "false" disables it
-
Start server and check logs:
uv run mcp-context-server
Look for:
hybrid_search_context modes available: ['fts', 'semantic'] -
Verify MCP client - List available tools and confirm
hybrid_search_contextis present -
Test functionality:
hybrid_search_context(query="test query", limit=5)
Call get_statistics to check hybrid search availability:
{
"fts": {
"available": true,
"indexed_entries": 1000
},
"semantic_search": {
"available": true,
"indexed_entries": 1000
}
}Both FTS and semantic search should show as available for full hybrid functionality.
Error: Tool not listed or "Hybrid search is not available"
Diagnostic Steps:
-
Check environment variables (any of these set to
falseforce-disables the corresponding feature):echo $ENABLE_HYBRID_SEARCH # "false" disables hybrid echo $ENABLE_FTS # "false" disables FTS echo $ENABLE_SEMANTIC_SEARCH # "false" disables semantic
-
Check server logs for initialization messages
-
Call
get_statisticstool to verify underlying search methods
Solution: Hybrid registers by default when at least one underlying method is available. If the tool is missing, ensure none of ENABLE_HYBRID_SEARCH, ENABLE_FTS, or ENABLE_SEMANTIC_SEARCH is set to false, and that at least one underlying method (FTS or semantic) is actually available.
Symptom: Results have semantic_rank: null for all entries or fts_rank: null for all entries
Cause: One search method is unavailable
Diagnostic Steps:
- Check
search_modes_usedin response - shows which modes actually executed - Check
fts_countandsemantic_countin response
For missing semantic search:
- Verify Ollama is running:
curl http://localhost:11434 - Verify model is available:
ollama list - Ensure
ENABLE_SEMANTIC_SEARCHis not set tofalse(it defaults toauto)
For missing FTS:
- Ensure
ENABLE_FTSis not set tofalse(it defaults toauto) - Verify FTS migration completed (check server logs)
Symptom: Results don't seem to combine well, or single-source results dominate
Possible Causes:
- Very different result sets: FTS and semantic may return completely different documents
- One search returning few results: Limited overlap for fusion
- Inappropriate k value: May need tuning
Solutions:
- Check overlap: Look at results - are any entries in both search methods?
- Increase limit: More results = more potential overlap
- Tune k parameter:
- Lower k (30) for more top-heavy ranking
- Higher k (100) for more uniform treatment
- Verify data has embeddings: Check
get_statisticsfor embedding coverage
Symptom: Searches taking longer than expected
Cause: Usually semantic search is the bottleneck
Solutions:
-
Check Ollama performance: Ensure model is loaded in memory
ollama ps # Shows loaded models -
Increase Ollama keep-alive:
export OLLAMA_KEEP_ALIVE=3600 # Keep model loaded
-
Use dedicated search tools if only keywords needed:
fts_search_context(query="exact match")
| Error Message | Cause | Solution |
|---|---|---|
Hybrid search is not available |
Feature force-disabled | Unset ENABLE_HYBRID_SEARCH (default auto) or set it to true |
No search modes available |
Both FTS and semantic force-disabled or unavailable | Keep at least one search method available |
FTS requires ENABLE_FTS=true |
FTS force-disabled | Unset ENABLE_FTS (default auto) or set ENABLE_FTS=true |
Semantic search requires... |
Semantic dependencies missing | Set up Ollama and semantic search |
All search modes failed |
Both FTS and semantic errored | Check individual search method status |
| Feature | FTS | Semantic | Hybrid |
|---|---|---|---|
| Query Type | Keywords/phrases | Natural language meaning | Both |
| Result Ranking | BM25/ts_rank score | L2 / negated-IP distance | RRF combined score |
| Best For | Exact matches, known terms | Concept discovery | High-confidence matches |
| Performance | Fastest | Slower | Similar to semantic (parallel) |
| Dependencies | None | Ollama + model | At least one method |
| Graceful Degradation | N/A | N/A | Falls back to available method |
When to use each:
- FTS: Known exact terms, phrase matching, boolean queries
- Semantic: Exploring related concepts, meaning-based retrieval
- Hybrid: Best of both, high-confidence discovery, uncertain query type
- API Reference: API Reference - complete tool documentation
- Summary Generation: Summary Generation Guide - LLM-based automatic summarization for search results
- Database Backends: Database Backends Guide - database configuration
- Full-Text Search: Full-Text Search Guide - FTS configuration and usage
- Semantic Search: Semantic Search Guide - semantic search setup with Ollama
- Metadata Filtering: Metadata Guide - metadata filtering with operators
- Docker Deployment: Docker Deployment Guide - containerized deployment
- Authentication: Authentication Guide - HTTP transport authentication
- Main Documentation: README.md - overview and quick start
- Reciprocal Rank Fusion: Cormack et al., 2009
- Elasticsearch RRF: Elastic documentation
- Weaviate Hybrid Search: Weaviate documentation
app/fusion.py- RRF fusion algorithm implementationapp/server.py- hybrid_search_context tool definitionapp/settings.py- Configuration settingsapp/types.py- TypedDict definitions for hybrid search