Skip to content

Commit ba2bbb0

Browse files
committed
feat: Add collection migration endpoint and fix cache service TTL parameter
- Add /api/admin/collections/migrate-to-cosine endpoint to migrate ChromaDB collections from L2 to cosine distance - Fixes high distance issues (25-28) preventing foundational knowledge retrieval - Endpoint backs up all documents, deletes old collection, creates new with cosine distance, and re-embeds all documents - Fix cache service TTL parameter mismatch (ttl_seconds vs ttl) in RAG retrieval - Handles both CacheService (ttl_seconds) and RedisCacheService (ttl) interfaces This addresses the root cause of foundational knowledge not being retrieved: - Collection was created with L2 distance before code update - Distance values 25-28 indicate L2, not cosine (cosine range: 0-2) - Migration will fix this by recreating collection with cosine distance metric
1 parent 60a4abb commit ba2bbb0

2 files changed

Lines changed: 219 additions & 2 deletions

File tree

backend/api/routers/system_router.py

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,10 +1218,219 @@ async def re_embed_foundational_knowledge_endpoint(
12181218

12191219
logger.info("✅ Foundational knowledge re-embedding complete via admin endpoint")
12201220
return response
1221-
1221+
12221222
except HTTPException:
12231223
raise
12241224
except Exception as e:
12251225
logger.error(f"Re-embed foundational knowledge error: {e}", exc_info=True)
12261226
raise HTTPException(status_code=500, detail=f"Failed to re-embed foundational knowledge: {str(e)}")
12271227

1228+
@router.post("/api/admin/collections/migrate-to-cosine")
1229+
async def migrate_collection_to_cosine_endpoint(
1230+
collection_name: str = "stillme_knowledge",
1231+
api_key: Optional[str] = Depends(require_api_key) if require_api_key else Depends(lambda: None)
1232+
):
1233+
"""
1234+
Migrate a ChromaDB collection from L2 distance to cosine distance.
1235+
1236+
This fixes high distance issues by:
1237+
1. Backing up all documents from existing collection
1238+
2. Deleting old collection (with L2 distance)
1239+
3. Creating new collection with cosine distance
1240+
4. Re-adding all documents with normalized embeddings
1241+
1242+
**CRITICAL**: This operation preserves all data but requires re-embedding.
1243+
For large collections (6000+ documents), this may take 10-30 minutes.
1244+
1245+
**Authentication Required**: This is an admin endpoint protected by API key.
1246+
Provide API key in `X-API-Key` header.
1247+
1248+
**Example:**
1249+
```bash
1250+
curl -X POST "https://stillme-backend-production.up.railway.app/api/admin/collections/migrate-to-cosine?collection_name=stillme_knowledge" \
1251+
-H "X-API-Key: your-api-key-here"
1252+
```
1253+
1254+
**Returns:**
1255+
- `status`: "success", "error", or "partial"
1256+
- `message`: Human-readable message
1257+
- `collection_name`: Name of migrated collection
1258+
- `documents_backed_up`: Number of documents backed up
1259+
- `documents_migrated`: Number of documents successfully migrated
1260+
- `time_elapsed_seconds`: Time taken for migration
1261+
- `timestamp`: ISO timestamp
1262+
"""
1263+
if require_api_key:
1264+
logger.debug(f"API key verified for collection migration")
1265+
try:
1266+
logger.info(f"🔧 Admin endpoint: Migrating collection '{collection_name}' to cosine distance...")
1267+
1268+
# Import RAG components
1269+
chroma_client = get_chroma_client()
1270+
if not chroma_client:
1271+
raise HTTPException(status_code=503, detail="ChromaDB client not available")
1272+
1273+
from stillme_core.rag.embeddings import EmbeddingService
1274+
embedding_service = EmbeddingService()
1275+
1276+
# Check if collection exists
1277+
try:
1278+
collection = chroma_client.client.get_collection(name=collection_name)
1279+
logger.info(f"✅ Found collection: {collection_name}")
1280+
except Exception as e:
1281+
logger.error(f"❌ Collection '{collection_name}' not found: {e}")
1282+
raise HTTPException(status_code=404, detail=f"Collection '{collection_name}' not found: {e}")
1283+
1284+
# Check collection metadata to see current distance metric
1285+
metadata = collection.metadata or {}
1286+
current_metric = metadata.get("hnsw:space", "unknown")
1287+
logger.info(f"📊 Current distance metric: {current_metric}")
1288+
1289+
if current_metric == "cosine":
1290+
logger.info("✅ Collection already uses cosine distance - no migration needed")
1291+
return {
1292+
"status": "success",
1293+
"message": f"Collection '{collection_name}' already uses cosine distance - no migration needed",
1294+
"collection_name": collection_name,
1295+
"documents_backed_up": collection.count(),
1296+
"documents_migrated": collection.count(),
1297+
"time_elapsed_seconds": 0,
1298+
"timestamp": datetime.now().isoformat()
1299+
}
1300+
1301+
# Step 1: Backup all documents
1302+
logger.info(f"📦 Step 1: Backing up all documents from '{collection_name}'...")
1303+
import time
1304+
start_time = time.time()
1305+
1306+
try:
1307+
all_data = collection.get(include=["documents", "metadatas", "embeddings"])
1308+
1309+
if not all_data or not all_data.get("ids"):
1310+
logger.warning(f"⚠️ Collection '{collection_name}' is empty")
1311+
return {
1312+
"status": "success",
1313+
"message": "Collection is empty - no migration needed",
1314+
"collection_name": collection_name,
1315+
"documents_backed_up": 0,
1316+
"documents_migrated": 0,
1317+
"time_elapsed_seconds": 0,
1318+
"timestamp": datetime.now().isoformat()
1319+
}
1320+
1321+
ids = all_data["ids"]
1322+
documents = all_data.get("documents", [])
1323+
metadatas = all_data.get("metadatas", [])
1324+
1325+
num_docs = len(ids)
1326+
logger.info(f" ✅ Backed up {num_docs} documents")
1327+
1328+
except Exception as e:
1329+
logger.error(f"❌ Failed to backup documents: {e}", exc_info=True)
1330+
raise HTTPException(status_code=500, detail=f"Failed to backup documents: {str(e)}")
1331+
1332+
# Step 2: Delete old collection
1333+
logger.info(f"🗑️ Step 2: Deleting old collection '{collection_name}' (with {current_metric} distance)...")
1334+
try:
1335+
chroma_client.client.delete_collection(name=collection_name)
1336+
logger.info(f" ✅ Deleted old collection")
1337+
except Exception as e:
1338+
logger.error(f"❌ Failed to delete collection: {e}", exc_info=True)
1339+
raise HTTPException(status_code=500, detail=f"Failed to delete collection: {str(e)}")
1340+
1341+
# Step 3: Create new collection with cosine distance
1342+
logger.info(f"🆕 Step 3: Creating new collection '{collection_name}' with cosine distance...")
1343+
try:
1344+
description = "Knowledge base for StillMe learning" if "knowledge" in collection_name.lower() else "Conversation history for context"
1345+
1346+
new_collection = chroma_client.client.create_collection(
1347+
name=collection_name,
1348+
metadata={
1349+
"description": description,
1350+
"hnsw:space": "cosine" # CRITICAL: Use cosine distance for normalized embeddings
1351+
}
1352+
)
1353+
logger.info(f" ✅ Created new collection with cosine distance metric")
1354+
except Exception as e:
1355+
logger.error(f"❌ Failed to create new collection: {e}", exc_info=True)
1356+
raise HTTPException(status_code=500, detail=f"Failed to create new collection: {str(e)}")
1357+
1358+
# Step 4: Re-embed and re-add documents in batches
1359+
logger.info(f"🔄 Step 4: Re-embedding and re-adding {num_docs} documents...")
1360+
logger.info(f" Model: {embedding_service.model_name}")
1361+
logger.info(f" This may take 10-30 minutes for large collections...")
1362+
1363+
batch_size = 50
1364+
re_embedded_count = 0
1365+
errors = []
1366+
1367+
for i in range(0, num_docs, batch_size):
1368+
batch_end = min(i + batch_size, num_docs)
1369+
batch_ids = ids[i:batch_end]
1370+
batch_documents = documents[i:batch_end]
1371+
batch_metadatas = metadatas[i:batch_end] if metadatas else [{}] * (batch_end - i)
1372+
1373+
logger.info(f" Processing batch {i//batch_size + 1}/{(num_docs + batch_size - 1)//batch_size} ({i+1}-{batch_end}/{num_docs})...")
1374+
1375+
try:
1376+
# Re-embed documents with normalized embeddings
1377+
batch_embeddings = []
1378+
for doc in batch_documents:
1379+
embedding = embedding_service.encode_text(doc)
1380+
batch_embeddings.append(embedding)
1381+
1382+
# Add to new collection
1383+
new_collection.add(
1384+
ids=batch_ids,
1385+
documents=batch_documents,
1386+
metadatas=batch_metadatas,
1387+
embeddings=batch_embeddings
1388+
)
1389+
1390+
re_embedded_count += len(batch_ids)
1391+
logger.info(f" ✅ Added {len(batch_ids)} documents to new collection")
1392+
1393+
except Exception as e:
1394+
error_msg = f"Failed to process batch {i//batch_size + 1}: {str(e)}"
1395+
logger.error(f" ❌ {error_msg}", exc_info=True)
1396+
errors.append(error_msg)
1397+
1398+
elapsed = time.time() - start_time
1399+
logger.info(f"✅ Migration complete: {re_embedded_count}/{num_docs} documents")
1400+
logger.info(f" Time elapsed: {elapsed:.2f} seconds ({elapsed/60:.2f} minutes)")
1401+
1402+
# Step 5: Verify migration
1403+
logger.info(f"✅ Step 5: Verifying migration...")
1404+
try:
1405+
verify_count = new_collection.count()
1406+
if verify_count == num_docs:
1407+
logger.info(f" ✅ Verification passed: {verify_count} documents in new collection")
1408+
else:
1409+
logger.warning(f" ⚠️ Verification warning: Expected {num_docs}, found {verify_count}")
1410+
except Exception as e:
1411+
logger.warning(f" ⚠️ Verification failed: {e}")
1412+
1413+
# Prepare response
1414+
response = {
1415+
"status": "success" if re_embedded_count == num_docs else "partial",
1416+
"message": f"Migrated {re_embedded_count}/{num_docs} documents successfully" + (f" ({len(errors)} errors)" if errors else ""),
1417+
"collection_name": collection_name,
1418+
"documents_backed_up": num_docs,
1419+
"documents_migrated": re_embedded_count,
1420+
"time_elapsed_seconds": round(elapsed, 2),
1421+
"time_elapsed_minutes": round(elapsed / 60, 2),
1422+
"timestamp": datetime.now().isoformat()
1423+
}
1424+
1425+
if errors:
1426+
response["errors"] = errors
1427+
1428+
logger.info("✅ Collection migration complete via admin endpoint")
1429+
return response
1430+
1431+
except HTTPException:
1432+
raise
1433+
except Exception as e:
1434+
logger.error(f"Migrate collection error: {e}", exc_info=True)
1435+
raise HTTPException(status_code=500, detail=f"Failed to migrate collection: {str(e)}")
1436+

stillme_core/rag/rag_retrieval.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,15 @@ def _apply_mmr(documents: List[Dict[str, Any]], query_embedding: List[float],
621621
"latency": 0.0, # Could track actual latency if needed
622622
"timestamp": time.time()
623623
}
624-
cache_service.set(cache_key, cache_value, ttl_seconds=TTL_RAG_RETRIEVAL)
624+
# Handle different cache service interfaces
625+
# CacheService uses ttl_seconds, RedisCacheService uses ttl
626+
if hasattr(cache_service, 'set'):
627+
# Try ttl_seconds first (CacheService)
628+
try:
629+
cache_service.set(cache_key, cache_value, ttl_seconds=TTL_RAG_RETRIEVAL)
630+
except TypeError:
631+
# Fallback to ttl (RedisCacheService)
632+
cache_service.set(cache_key, cache_value, ttl=TTL_RAG_RETRIEVAL)
625633
logger.debug(f"💾 RAG retrieval cached (key: {cache_key[:50]}...)")
626634
except Exception as cache_error:
627635
logger.warning(f"Failed to cache RAG retrieval: {cache_error}")

0 commit comments

Comments
 (0)