@@ -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+
0 commit comments