feat: add Qdrant local mode support with in-memory and persistent storage

Adds flexible Qdrant deployment modes to reduce infrastructure requirements
for local development and smaller deployments:

**Configuration Changes:**
- Add QDRANT_LOCATION environment variable (mutually exclusive with QDRANT_URL)
- Three modes: network (URL), in-memory (:memory:, default), persistent (file path)
- Settings dataclass validation via __post_init__ ensures mutual exclusivity
- API key warning when set in local mode (ignored, only for network mode)

**Client Initialization:**
- Auto-detect mode: network (url + api_key) vs local (:memory: or path=)
- In-memory: AsyncQdrantClient(":memory:") - zero config default
- Persistent: AsyncQdrantClient(path="/app/data/qdrant") - file storage
- Network: AsyncQdrantClient(url, api_key) - production mode

**Docker Compose Updates:**
- Qdrant service moved to optional profile (--profile qdrant)
- MCP service uses QDRANT_LOCATION=:memory: by default
- Added mcp-data volume for persistent storage (/app/data)
- No hard dependency on qdrant service

**Documentation:**
- Comprehensive configuration guide in docs/configuration.md
- All three modes documented with pros/cons
- Docker Compose examples for each mode
- Environment variable reference table

**Tests:**
- 13 new config validation tests (mutual exclusivity, defaults, warnings)
- Persistent mode integration test (create, close, reopen, verify persistence)
- All 82 unit tests + 5 smoke tests pass

**Breaking Change:**
- Default changed from QDRANT_URL=http://qdrant:6333 to QDRANT_LOCATION=:memory:
- Simplifies local development (no external service needed)
- Production deployments: explicitly set QDRANT_URL or QDRANT_LOCATION

Related: ADR-007 background vector sync implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-09 07:07:07 +01:00
co-authored by Claude
parent 72232f937a
commit 857d8f2152
6 changed files with 465 additions and 17 deletions
+29 -3
View File
@@ -1,3 +1,4 @@
import logging
import logging.config
import os
from dataclasses import dataclass
@@ -162,8 +163,9 @@ class Settings:
vector_sync_processor_workers: int = 3
vector_sync_queue_max_size: int = 10000
# Qdrant settings
qdrant_url: str = "http://qdrant:6333"
# Qdrant settings (mutually exclusive modes)
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
qdrant_api_key: Optional[str] = None
qdrant_collection: str = "nextcloud_content"
@@ -172,6 +174,29 @@ class Settings:
ollama_embedding_model: str = "nomic-embed-text"
ollama_verify_ssl: bool = True
def __post_init__(self):
"""Validate Qdrant configuration and set defaults."""
logger = logging.getLogger(__name__)
# Ensure mutual exclusivity
if self.qdrant_url and self.qdrant_location:
raise ValueError(
"Cannot set both QDRANT_URL and QDRANT_LOCATION. "
"Use QDRANT_URL for network mode or QDRANT_LOCATION for local mode."
)
# Default to :memory: if neither set
if not self.qdrant_url and not self.qdrant_location:
self.qdrant_location = ":memory:"
logger.info("Using default Qdrant mode: in-memory (:memory:)")
# Warn if API key set in local mode
if self.qdrant_location and self.qdrant_api_key:
logger.warning(
"QDRANT_API_KEY is set but QDRANT_LOCATION is used (local mode). "
"API key is only relevant for network mode and will be ignored."
)
def get_settings() -> Settings:
"""Get application settings from environment variables.
@@ -220,7 +245,8 @@ def get_settings() -> Settings:
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
),
# Qdrant settings
qdrant_url=os.getenv("QDRANT_URL", "http://qdrant:6333"),
qdrant_url=os.getenv("QDRANT_URL"),
qdrant_location=os.getenv("QDRANT_LOCATION"),
qdrant_api_key=os.getenv("QDRANT_API_KEY"),
qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"),
# Ollama settings
+31 -9
View File
@@ -1,11 +1,12 @@
"""Qdrant client wrapper."""
import logging
import os
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import Distance, VectorParams
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -19,6 +20,11 @@ async def get_qdrant_client() -> AsyncQdrantClient:
Automatically creates collection on first use if it doesn't exist.
Supports three Qdrant modes:
- Network mode: QDRANT_URL set (e.g., http://qdrant:6333)
- In-memory mode: QDRANT_LOCATION=:memory: (default if nothing configured)
- Persistent local mode: QDRANT_LOCATION=/path/to/data
Returns:
Configured AsyncQdrantClient instance
@@ -28,17 +34,33 @@ async def get_qdrant_client() -> AsyncQdrantClient:
global _qdrant_client
if _qdrant_client is None:
url = os.getenv("QDRANT_URL", "http://qdrant:6333")
api_key = os.getenv("QDRANT_API_KEY")
settings = get_settings()
_qdrant_client = AsyncQdrantClient(
url=url,
api_key=api_key,
timeout=30,
)
# Detect mode and initialize client accordingly
if settings.qdrant_url:
# Network mode
logger.info(f"Using Qdrant network mode: {settings.qdrant_url}")
_qdrant_client = AsyncQdrantClient(
url=settings.qdrant_url,
api_key=settings.qdrant_api_key,
timeout=30,
)
elif settings.qdrant_location:
# Local mode (either :memory: or persistent path)
if settings.qdrant_location == ":memory:":
logger.info("Using Qdrant in-memory mode: :memory:")
_qdrant_client = AsyncQdrantClient(":memory:")
else:
# Persistent local mode - use path parameter
logger.info(f"Using Qdrant persistent mode: {settings.qdrant_location}")
_qdrant_client = AsyncQdrantClient(path=settings.qdrant_location)
else:
# Should not happen due to __post_init__ validation, but handle gracefully
logger.warning("No Qdrant mode configured, defaulting to :memory:")
_qdrant_client = AsyncQdrantClient(":memory:")
# Ensure collection exists
collection_name = os.getenv("QDRANT_COLLECTION", "nextcloud_content")
collection_name = settings.qdrant_collection
# Import here to avoid circular dependency
from nextcloud_mcp_server.embedding import get_embedding_service