Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`.

**Test Verification Baseline:** **1052 Automated Tests** (699 Pytest Backend + 303 Vitest Frontend + 50 Playwright E2E).
**Test Verification Baseline:** **1058 Automated Tests** (705 Pytest Backend + 303 Vitest Frontend + 50 Playwright E2E).

---

Expand Down Expand Up @@ -431,13 +431,15 @@ classDiagram
- `test_api_embedding_settings`
- `test_api_embedding_settings_errors`

#### `tests/backend/test_api_vector_store.py` (14 tests)
#### `tests/backend/test_api_vector_store.py` (16 tests)
- `test_api_get_vector_store_success` - _Test retrieving active vector store configuration and stats._
- `test_api_get_vector_store_error` - _Test GET /admin/api/vector-store error handling._
- `test_api_test_vector_store_valid_embedded` - _Test dry-run validation with valid embedded Qdrant configuration._
- `test_api_test_vector_store_valid_chroma` - _Test dry-run validation with valid embedded Chroma configuration._
- `test_api_test_vector_store_invalid_provider` - _Test dry-run validation with unsupported provider._
- `test_api_test_vector_store_remote_missing_url` - _Test dry-run validation with remote mode but missing URL._
- `test_api_test_vector_store_unreachable_remote_qdrant` - _Test POST /admin/api/vector-store/test fails for unreachable remote Qdrant._
- `test_api_test_vector_store_unreachable_remote_chroma` - _Test POST /admin/api/vector-store/test fails for unreachable remote Chroma._
- `test_api_test_vector_store_exception` - _Test dry-run validation when unexpected exception occurs._
- `test_api_switch_vector_store_success` - _Test switching vector store backend and triggering re-indexing._
- `test_api_switch_vector_store_invalid_provider` - _Test switching to an unsupported provider returns 400._
Expand Down Expand Up @@ -738,10 +740,11 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._
- `test_vector_document_validation` - _Verify required field validation in VectorDocument._
- `test_vector_search_result_creation_and_payload` - _Verify VectorSearchResult creation and payload access._

#### `tests/backend/test_vector_store_chroma.py` (34 tests)
#### `tests/backend/test_vector_store_chroma.py` (36 tests)
- `TestChromaVectorStoreInit::test_init_in_memory`
- `TestChromaVectorStoreInit::test_init_persistent_disk`
- `TestChromaVectorStoreInit::test_init_remote_success`
- `TestChromaVectorStoreInit::test_init_remote_unreachable_raises_error`
- `TestChromaVectorStoreInit::test_init_remote_fallback_to_persistent_on_error`
- `TestChromaVectorStoreInit::test_custom_injected_client`
- `TestChromaVectorStoreOperations::test_ensure_collection`
Expand All @@ -759,6 +762,7 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._
- `test_init_in_memory`
- `test_init_persistent_disk`
- `test_init_remote_success`
- `test_init_remote_unreachable_raises_error`
- `test_init_remote_fallback_to_persistent_on_error`
- `test_custom_injected_client`
- `test_ensure_collection`
Expand Down Expand Up @@ -814,9 +818,10 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._
- `test_test_connection_active_embedded` - _Verify test_connection succeeds on active embedded Qdrant store without file lock conflict._
- `test_switch_same_embedded_directory` - _Verify switch_vector_store succeeds when switching collection on the same embedded Qdrant directory._

#### `tests/backend/test_vector_store_qdrant.py` (40 tests)
#### `tests/backend/test_vector_store_qdrant.py` (42 tests)
- `TestQdrantVectorStoreInit::test_init_in_memory_or_embedded`
- `TestQdrantVectorStoreInit::test_init_remote_success`
- `TestQdrantVectorStoreInit::test_init_remote_unreachable_raises_error`
- `TestQdrantVectorStoreInit::test_init_remote_fallback_to_embedded_on_connection_error`
- `TestQdrantVectorStoreInit::test_custom_injected_client`
- `TestQdrantVectorStoreOperations::test_ensure_collection_recreates_on_schema_mismatch`
Expand All @@ -837,6 +842,7 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._
- `TestQdrantVectorStoreOperations::test_get_stats_and_health_check`
- `test_init_in_memory_or_embedded`
- `test_init_remote_success`
- `test_init_remote_unreachable_raises_error`
- `test_init_remote_fallback_to_embedded_on_connection_error`
- `test_custom_injected_client`
- `test_ensure_collection_recreates_on_schema_mismatch`
Expand Down
61 changes: 46 additions & 15 deletions app/services/vector_store/chroma_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def __init__(
url: Optional[str] = None,
client: Optional[Any] = None,
auto_init: bool = True,
allow_fallback: Optional[bool] = None,
):
self.collection_name = collection_name or os.getenv("COLLECTION_NAME", "knowledge_rag_v1")
self.collection = None
Expand All @@ -94,17 +95,25 @@ def __init__(
target_ssl = ssl if ssl is not None else os.getenv("CHROMA_SSL", "false").lower() in ("true", "1", "yes")
target_storage = storage_path or get_default_chroma_storage_path()


if prefer_remote is None:
prefer_remote = os.getenv("CHROMA_PREFER_REMOTE", "false").lower() in ("true", "1", "yes")
if host is not None:
prefer_remote = True

if allow_fallback is None:
allow_fallback = os.getenv("CHROMA_ALLOW_FALLBACK", "false").lower() in ("true", "1", "yes")

self.fallback_active = False
self.fallback_reason = None

if client is not None:
self.client = client
self.configured_mode = "custom"
self.effective_mode = "custom"
self.mode = "custom"
self.location = "injected_client"
elif prefer_remote:
self.configured_mode = "remote"
try:
remote_client = chromadb.HttpClient(
host=target_host,
Expand All @@ -115,32 +124,45 @@ def __init__(
remote_client.heartbeat()
self.client = remote_client
self.mode = "remote"
self.effective_mode = "remote"
self.location = f"{'https' if target_ssl else 'http'}://{target_host}:{target_port}"
logger.info(f"Connected to remote Chroma server at {self.location}")
except Exception as e:
if not allow_fallback:
logger.error(f"Failed to connect to remote Chroma at {target_host}:{target_port}: {e}")
raise ConnectionError(f"Failed to connect to remote Chroma at {target_host}:{target_port}: {e}") from e

self.fallback_active = True
self.fallback_reason = str(e)
logger.warning(
f"Failed to connect to remote Chroma at {target_host}:{target_port}: {e}. "
f"Falling back to persistent disk storage at {target_storage}"
f"Falling back to persistent disk storage at {target_storage} (opt-in fallback active)"
)
if target_storage == ":memory:":
self.client = chromadb.EphemeralClient()
self.mode = "memory"
self.effective_mode = "memory"
self.location = target_storage
else:
clean_storage = validate_chroma_storage_path(target_storage)
os.makedirs(clean_storage, exist_ok=True)
self.client = chromadb.PersistentClient(path=clean_storage)
self.mode = "persistent"
self.effective_mode = "persistent"
self.location = clean_storage
else:
if target_storage == ":memory:":
self.client = chromadb.EphemeralClient()
self.configured_mode = "memory"
self.effective_mode = "memory"
self.mode = "memory"
self.location = target_storage
else:
clean_storage = validate_chroma_storage_path(target_storage)
os.makedirs(clean_storage, exist_ok=True)
self.client = chromadb.PersistentClient(path=clean_storage)
self.configured_mode = "persistent"
self.effective_mode = "persistent"
self.mode = "persistent"
self.location = clean_storage
logger.info(f"Initialized local Chroma client at {self.location}")
Expand Down Expand Up @@ -331,31 +353,39 @@ def search(
def get_stats(self) -> Dict[str, Any]:
"""Returns collection info including point count, backend, and mode."""
try:
base_stats = {
"backend": "chroma",
"mode": self.mode,
"configured_mode": getattr(self, "configured_mode", self.mode),
"effective_mode": getattr(self, "effective_mode", self.mode),
"fallback_active": getattr(self, "fallback_active", False),
"fallback_reason": getattr(self, "fallback_reason", None),
"collection_name": self.collection_name,
"location": self.location,
}
if self.collection is None:
return {
"backend": "chroma",
"mode": self.mode,
"collection_name": self.collection_name,
base_stats.update({
"exists": False,
"points_count": 0,
"status": "not_initialized",
"location": self.location,
}
})
return base_stats
count = self.collection.count()
return {
"backend": "chroma",
"mode": self.mode,
"collection_name": self.collection_name,
base_stats.update({
"exists": True,
"points_count": count,
"status": "ok",
"location": self.location,
}
})
return base_stats
except Exception as e:
logger.error(f"Error getting stats for Chroma collection '{self.collection_name}': {e}")
return {
"backend": "chroma",
"mode": self.mode,
"configured_mode": getattr(self, "configured_mode", self.mode),
"effective_mode": getattr(self, "effective_mode", self.mode),
"fallback_active": getattr(self, "fallback_active", False),
"fallback_reason": getattr(self, "fallback_reason", None),
"collection_name": self.collection_name,
"exists": False,
"error": str(e),
Expand All @@ -367,7 +397,8 @@ def health_check(self) -> Tuple[bool, str]:
try:
hb = self.client.heartbeat()
count = self.collection.count() if self.collection is not None else 0
return True, f"Chroma ({self.mode} @ {self.location}) is healthy; heartbeat: {hb}, collection '{self.collection_name}' count: {count}"
fallback_note = f" [DEGRADED: Fallback to {self.effective_mode} from {self.configured_mode} active; reason: {self.fallback_reason}]" if getattr(self, "fallback_active", False) else ""
return True, f"Chroma ({self.effective_mode} @ {self.location}) is healthy; heartbeat: {hb}, collection '{self.collection_name}' count: {count}{fallback_note}"
except Exception as e:
logger.warning(f"Chroma ({self.mode}) health check failed: {e}")
return False, f"Chroma ({self.mode}) health check failed."
Expand Down
12 changes: 10 additions & 2 deletions app/services/vector_store/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,16 @@ def get_vector_store_config(cls) -> Dict[str, Any]:
health_msg = "Error connecting to vector store."
stats = {"error": "Error retrieving vector store statistics."}

configured_mode = cfg["mode"]
effective_mode = stats.get("effective_mode", stats.get("mode", configured_mode))
fallback_active = stats.get("fallback_active", False)

return {
"provider": cfg["provider"],
"mode": cfg["mode"],
"mode": configured_mode,
"configured_mode": configured_mode,
"effective_mode": effective_mode,
"fallback_active": fallback_active,
"storage_path": cfg["storage_path"],
"url": cfg["url"],
"collection": cfg["collection"],
Expand Down Expand Up @@ -306,6 +313,7 @@ def test_connection(
"storage_path": target_storage,
"url": target_url.strip(),
"collection": target_collection,
"allow_fallback": False,
}

# Check if this matches active store to prevent locked embedded directory collisions
Expand All @@ -327,7 +335,7 @@ def test_connection(
return is_healthy, health_msg
except Exception as e:
logger.error(f"Connection test failed for {prov}: {e}")
return False, f"Connection test failed for {prov}."
return False, f"Connection test failed for {prov}: {e}"
finally:
if test_store is not None:
try:
Expand Down
Loading
Loading