feat: Implement BM25 hybrid search with native Qdrant RRF fusion
Replace custom keyword/fuzzy search algorithms with industry-standard BM25 sparse vectors, combined with dense semantic vectors using Qdrant's native Reciprocal Rank Fusion (RRF). This consolidates search architecture and improves relevance for both semantic and keyword queries. Key changes: - Add fastembed dependency for BM25 sparse vector generation - Update Qdrant collection schema to support named vectors (dense + sparse) - Create BM25SparseEmbeddingProvider using FastEmbed's Qdrant/bm25 model - Implement BM25HybridSearchAlgorithm with native Qdrant RRF prefetch - Update document processor to generate both dense and sparse embeddings - Simplify nc_semantic_search() tool to use BM25 hybrid only - Remove legacy keyword.py, fuzzy.py, and custom hybrid.py (736 lines) - Update ADR-014 with implementation notes and test results Benefits: - Consolidated architecture (single Qdrant database) - Native database-level RRF fusion (more efficient) - Industry-standard BM25 (replaces brittle custom keyword search) - Better relevance across semantic and keyword queries - Simplified codebase (-285 net lines) Tests: All 125 tests passing (118 unit, 7 integration) Implements ADR-014: Replace Custom Keyword Search with BM25 Hybrid Search 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"""Semantic search MCP tools using vector database."""
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from httpx import RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
@@ -25,12 +24,7 @@ from nextcloud_mcp_server.models.semantic import (
|
||||
from nextcloud_mcp_server.observability.metrics import (
|
||||
instrument_tool,
|
||||
)
|
||||
from nextcloud_mcp_server.search import (
|
||||
FuzzySearchAlgorithm,
|
||||
HybridSearchAlgorithm,
|
||||
KeywordSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,36 +40,30 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
ctx: Context,
|
||||
limit: int = 10,
|
||||
doc_types: list[str] | None = None,
|
||||
score_threshold: float = 0.7,
|
||||
algorithm: Literal["semantic", "keyword", "fuzzy", "hybrid"] = "hybrid",
|
||||
semantic_weight: float = 0.5,
|
||||
keyword_weight: float = 0.3,
|
||||
fuzzy_weight: float = 0.2,
|
||||
score_threshold: float = 0.0,
|
||||
) -> SemanticSearchResponse:
|
||||
"""
|
||||
Search Nextcloud content using configurable algorithms with cross-app support.
|
||||
Search Nextcloud content using BM25 hybrid search with cross-app support.
|
||||
|
||||
Supports multiple search algorithms with client-configurable weighting:
|
||||
- semantic: Vector similarity search (requires VECTOR_SYNC_ENABLED=true)
|
||||
- keyword: Token-based matching (title matches weighted 3x)
|
||||
- fuzzy: Character overlap matching (typo-tolerant)
|
||||
- hybrid: Combines all algorithms using Reciprocal Rank Fusion (default)
|
||||
Uses Qdrant's native hybrid search combining:
|
||||
- Dense semantic vectors: For conceptual similarity and natural language queries
|
||||
- BM25 sparse vectors: For precise keyword matching, acronyms, and specific terms
|
||||
|
||||
Document types are queried from the vector database to determine what's
|
||||
actually indexed. Currently only "note" documents are fully supported.
|
||||
Results are automatically fused using Reciprocal Rank Fusion (RRF) in the
|
||||
database for optimal relevance. This provides the best of both semantic
|
||||
understanding and keyword precision.
|
||||
|
||||
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
|
||||
fully supported for indexing.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
query: Natural language or keyword search query
|
||||
limit: Maximum number of results to return (default: 10)
|
||||
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
|
||||
score_threshold: Minimum similarity score for semantic/hybrid (0-1, default: 0.7)
|
||||
algorithm: Search algorithm to use (default: "hybrid")
|
||||
semantic_weight: Weight for semantic results in hybrid mode (default: 0.5)
|
||||
keyword_weight: Weight for keyword results in hybrid mode (default: 0.3)
|
||||
fuzzy_weight: Weight for fuzzy results in hybrid mode (default: 0.2)
|
||||
score_threshold: Minimum RRF fusion score (0-1, default: 0.0 for RRF scoring)
|
||||
|
||||
Returns:
|
||||
SemanticSearchResponse with matching documents and relevance scores
|
||||
SemanticSearchResponse with matching documents ranked by RRF fusion scores
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
@@ -84,42 +72,22 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
username = client.username
|
||||
|
||||
logger.info(
|
||||
f"Search: query='{query}', user={username}, algorithm={algorithm}, "
|
||||
f"BM25 hybrid search: query='{query}', user={username}, "
|
||||
f"limit={limit}, score_threshold={score_threshold}"
|
||||
)
|
||||
|
||||
# Check that vector sync is enabled
|
||||
if not settings.vector_sync_enabled:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="BM25 hybrid search requires VECTOR_SYNC_ENABLED=true",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Create appropriate algorithm instance
|
||||
if algorithm == "semantic":
|
||||
if not settings.vector_sync_enabled:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Semantic search requires VECTOR_SYNC_ENABLED=true",
|
||||
)
|
||||
)
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
elif algorithm == "keyword":
|
||||
search_algo = KeywordSearchAlgorithm()
|
||||
elif algorithm == "fuzzy":
|
||||
search_algo = FuzzySearchAlgorithm()
|
||||
elif algorithm == "hybrid":
|
||||
if semantic_weight > 0 and not settings.vector_sync_enabled:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Hybrid search with semantic component requires VECTOR_SYNC_ENABLED=true",
|
||||
)
|
||||
)
|
||||
search_algo = HybridSearchAlgorithm(
|
||||
semantic_weight=semantic_weight,
|
||||
keyword_weight=keyword_weight,
|
||||
fuzzy_weight=fuzzy_weight,
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Unknown algorithm: {algorithm}")
|
||||
)
|
||||
# Create BM25 hybrid search algorithm
|
||||
search_algo = BM25HybridSearchAlgorithm(score_threshold=score_threshold)
|
||||
|
||||
# Execute search across requested document types
|
||||
# If doc_types is None, search all indexed types (cross-app search)
|
||||
@@ -179,13 +147,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"Returning {len(results)} results from {algorithm} search")
|
||||
logger.info(f"Returning {len(results)} results from BM25 hybrid search")
|
||||
|
||||
return SemanticSearchResponse(
|
||||
results=results,
|
||||
query=query,
|
||||
total_found=len(results),
|
||||
search_method=algorithm,
|
||||
search_method="bm25_hybrid",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
Reference in New Issue
Block a user