Files
hkuds--lightrag/lightrag/tools/prepare_qdrant_legacy_data.py
2026-07-13 12:08:54 +08:00

721 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Qdrant Legacy Data Preparation Tool for LightRAG
This tool copies data from new collections to legacy collections for testing
the data migration logic in setup_collection function.
New Collections (with workspace_id):
- lightrag_vdb_chunks
- lightrag_vdb_entities
- lightrag_vdb_relationships
Legacy Collections (without workspace_id, dynamically named as {workspace}_{suffix}):
- {workspace}_chunks (e.g., space1_chunks)
- {workspace}_entities (e.g., space1_entities)
- {workspace}_relationships (e.g., space1_relationships)
The tool:
1. Filters source data by workspace_id
2. Verifies workspace data exists before creating legacy collections
3. Removes workspace_id field to simulate legacy data format
4. Copies only the specified workspace's data to legacy collections
Usage:
python -m lightrag.tools.prepare_qdrant_legacy_data
# or
python lightrag/tools/prepare_qdrant_legacy_data.py
# Specify custom workspace
python -m lightrag.tools.prepare_qdrant_legacy_data --workspace space1
# Process specific collection types only
python -m lightrag.tools.prepare_qdrant_legacy_data --types chunks,entities
# Dry run (preview only, no actual changes)
python -m lightrag.tools.prepare_qdrant_legacy_data --dry-run
"""
import argparse
import asyncio
import configparser
import os
import sys
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import pipmaster as pm
from dotenv import load_dotenv
from qdrant_client import QdrantClient, models # type: ignore
# Add project root to path for imports
sys.path.insert(
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
# Load environment variables
load_dotenv(dotenv_path=".env", override=False)
# Ensure qdrant-client is installed
if not pm.is_installed("qdrant-client"):
pm.install("qdrant-client")
# Collection namespace mapping: new collection pattern -> legacy suffix
# Legacy collection will be named as: {workspace}_{suffix}
COLLECTION_NAMESPACES = {
"chunks": {
"new": "lightrag_vdb_chunks",
"suffix": "chunks",
},
"entities": {
"new": "lightrag_vdb_entities",
"suffix": "entities",
},
"relationships": {
"new": "lightrag_vdb_relationships",
"suffix": "relationships",
},
}
# Default batch size for copy operations
DEFAULT_BATCH_SIZE = 500
# Field to remove from legacy data
WORKSPACE_ID_FIELD = "workspace_id"
# ANSI color codes for terminal output
BOLD_CYAN = "\033[1;36m"
BOLD_GREEN = "\033[1;32m"
BOLD_YELLOW = "\033[1;33m"
BOLD_RED = "\033[1;31m"
RESET = "\033[0m"
@dataclass
class CopyStats:
"""Copy operation statistics"""
collection_type: str
source_collection: str
target_collection: str
total_records: int = 0
copied_records: int = 0
failed_records: int = 0
errors: List[Dict[str, Any]] = field(default_factory=list)
elapsed_time: float = 0.0
def add_error(self, batch_idx: int, error: Exception, batch_size: int):
"""Record batch error"""
self.errors.append(
{
"batch": batch_idx,
"error_type": type(error).__name__,
"error_msg": str(error),
"records_lost": batch_size,
"timestamp": time.time(),
}
)
self.failed_records += batch_size
class QdrantLegacyDataPreparationTool:
"""Tool for preparing legacy data in Qdrant for migration testing"""
def __init__(
self,
workspace: str = "space1",
batch_size: int = DEFAULT_BATCH_SIZE,
dry_run: bool = False,
clear_target: bool = False,
):
"""
Initialize the tool.
Args:
workspace: Workspace to use for filtering new collection data
batch_size: Number of records to process per batch
dry_run: If True, only preview operations without making changes
clear_target: If True, delete target collection before copying data
"""
self.workspace = workspace
self.batch_size = batch_size
self.dry_run = dry_run
self.clear_target = clear_target
self._client: Optional[QdrantClient] = None
def _get_client(self) -> QdrantClient:
"""Get or create QdrantClient instance"""
if self._client is None:
config = configparser.ConfigParser()
config.read("config.ini", "utf-8")
self._client = QdrantClient(
url=os.environ.get(
"QDRANT_URL", config.get("qdrant", "uri", fallback=None)
),
api_key=os.environ.get(
"QDRANT_API_KEY",
config.get("qdrant", "apikey", fallback=None),
),
)
return self._client
def print_header(self):
"""Print tool header"""
print("\n" + "=" * 60)
print("Qdrant Legacy Data Preparation Tool - LightRAG")
print("=" * 60)
if self.dry_run:
print(f"{BOLD_YELLOW}⚠️ DRY RUN MODE - No changes will be made{RESET}")
if self.clear_target:
print(
f"{BOLD_RED}⚠️ CLEAR TARGET MODE - Target collections will be deleted first{RESET}"
)
print(f"Workspace: {BOLD_CYAN}{self.workspace}{RESET}")
print(f"Batch Size: {self.batch_size}")
print("=" * 60)
def check_connection(self) -> bool:
"""Check Qdrant connection"""
try:
client = self._get_client()
# Try to list collections to verify connection
client.get_collections()
print(f"{BOLD_GREEN}{RESET} Qdrant connection successful")
return True
except Exception as e:
print(f"{BOLD_RED}{RESET} Qdrant connection failed: {e}")
return False
def get_collection_info(self, collection_name: str) -> Optional[Dict[str, Any]]:
"""
Get collection information.
Args:
collection_name: Name of the collection
Returns:
Dictionary with collection info (vector_size, count) or None if not exists
"""
client = self._get_client()
if not client.collection_exists(collection_name):
return None
info = client.get_collection(collection_name)
count = client.count(collection_name=collection_name, exact=True).count
# Handle both object and dict formats for vectors config
vectors_config = info.config.params.vectors
if isinstance(vectors_config, dict):
# Named vectors format or dict format
if vectors_config:
first_key = next(iter(vectors_config.keys()), None)
if first_key and hasattr(vectors_config[first_key], "size"):
vector_size = vectors_config[first_key].size
distance = vectors_config[first_key].distance
else:
# Try to get from dict values
first_val = next(iter(vectors_config.values()), {})
vector_size = (
first_val.get("size")
if isinstance(first_val, dict)
else getattr(first_val, "size", None)
)
distance = (
first_val.get("distance")
if isinstance(first_val, dict)
else getattr(first_val, "distance", None)
)
else:
vector_size = None
distance = None
else:
# Standard single vector format
vector_size = vectors_config.size
distance = vectors_config.distance
return {
"name": collection_name,
"vector_size": vector_size,
"count": count,
"distance": distance,
}
def delete_collection(self, collection_name: str) -> bool:
"""
Delete a collection if it exists.
Args:
collection_name: Name of the collection to delete
Returns:
True if deleted or doesn't exist
"""
client = self._get_client()
if not client.collection_exists(collection_name):
return True
if self.dry_run:
target_info = self.get_collection_info(collection_name)
count = target_info["count"] if target_info else 0
print(
f" {BOLD_YELLOW}[DRY RUN]{RESET} Would delete collection '{collection_name}' ({count:,} records)"
)
return True
try:
target_info = self.get_collection_info(collection_name)
count = target_info["count"] if target_info else 0
client.delete_collection(collection_name=collection_name)
print(
f" {BOLD_RED}{RESET} Deleted collection '{collection_name}' ({count:,} records)"
)
return True
except Exception as e:
print(f" {BOLD_RED}{RESET} Failed to delete collection: {e}")
return False
def create_legacy_collection(
self, collection_name: str, vector_size: int, distance: models.Distance
) -> bool:
"""
Create legacy collection if it doesn't exist.
Args:
collection_name: Name of the collection to create
vector_size: Dimension of vectors
distance: Distance metric
Returns:
True if created or already exists
"""
client = self._get_client()
if client.collection_exists(collection_name):
print(f" Collection '{collection_name}' already exists")
return True
if self.dry_run:
print(
f" {BOLD_YELLOW}[DRY RUN]{RESET} Would create collection '{collection_name}' with {vector_size}d vectors"
)
return True
try:
client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(
size=vector_size,
distance=distance,
),
hnsw_config=models.HnswConfigDiff(
payload_m=16,
m=0,
),
)
print(
f" {BOLD_GREEN}{RESET} Created collection '{collection_name}' with {vector_size}d vectors"
)
return True
except Exception as e:
print(f" {BOLD_RED}{RESET} Failed to create collection: {e}")
return False
def _get_workspace_filter(self) -> models.Filter:
"""Create workspace filter for Qdrant queries"""
return models.Filter(
must=[
models.FieldCondition(
key=WORKSPACE_ID_FIELD,
match=models.MatchValue(value=self.workspace),
)
]
)
def get_workspace_count(self, collection_name: str) -> int:
"""
Get count of records for the current workspace in a collection.
Args:
collection_name: Name of the collection
Returns:
Count of records for the workspace
"""
client = self._get_client()
return client.count(
collection_name=collection_name,
count_filter=self._get_workspace_filter(),
exact=True,
).count
def copy_collection_data(
self,
source_collection: str,
target_collection: str,
collection_type: str,
workspace_count: int,
) -> CopyStats:
"""
Copy data from source to target collection.
This filters by workspace_id and removes it from payload to simulate legacy data format.
Args:
source_collection: Source collection name
target_collection: Target collection name
collection_type: Type of collection (chunks, entities, relationships)
workspace_count: Pre-computed count of workspace records
Returns:
CopyStats with operation results
"""
client = self._get_client()
stats = CopyStats(
collection_type=collection_type,
source_collection=source_collection,
target_collection=target_collection,
)
start_time = time.time()
stats.total_records = workspace_count
if workspace_count == 0:
print(f" No records for workspace '{self.workspace}', skipping")
stats.elapsed_time = time.time() - start_time
return stats
print(f" Workspace records: {workspace_count:,}")
if self.dry_run:
print(
f" {BOLD_YELLOW}[DRY RUN]{RESET} Would copy {workspace_count:,} records to '{target_collection}'"
)
stats.copied_records = workspace_count
stats.elapsed_time = time.time() - start_time
return stats
# Batch copy using scroll with workspace filter
workspace_filter = self._get_workspace_filter()
offset = None
batch_idx = 0
while True:
# Scroll source collection with workspace filter
result = client.scroll(
collection_name=source_collection,
scroll_filter=workspace_filter,
limit=self.batch_size,
offset=offset,
with_vectors=True,
with_payload=True,
)
points, next_offset = result
if not points:
break
batch_idx += 1
# Transform points: remove workspace_id from payload
new_points = []
for point in points:
new_payload = dict(point.payload or {})
# Remove workspace_id to simulate legacy format
new_payload.pop(WORKSPACE_ID_FIELD, None)
# Use original id from payload if available, otherwise use point.id
original_id = new_payload.get("id")
if original_id:
# Generate a simple deterministic id for legacy format
# Use original id directly (legacy format didn't have workspace prefix)
import hashlib
import uuid
hashed = hashlib.sha256(original_id.encode("utf-8")).digest()
point_id = uuid.UUID(bytes=hashed[:16], version=4).hex
else:
point_id = str(point.id)
new_points.append(
models.PointStruct(
id=point_id,
vector=point.vector,
payload=new_payload,
)
)
try:
# Upsert to target collection
client.upsert(
collection_name=target_collection, points=new_points, wait=True
)
stats.copied_records += len(new_points)
# Progress bar
progress = (stats.copied_records / workspace_count) * 100
bar_length = 30
filled = int(bar_length * stats.copied_records // workspace_count)
bar = "█" * filled + "░" * (bar_length - filled)
print(
f"\r Copying: {bar} {stats.copied_records:,}/{workspace_count:,} ({progress:.1f}%) ",
end="",
flush=True,
)
except Exception as e:
stats.add_error(batch_idx, e, len(new_points))
print(
f"\n {BOLD_RED}{RESET} Batch {batch_idx} failed: {type(e).__name__}: {e}"
)
if next_offset is None:
break
offset = next_offset
print() # New line after progress bar
stats.elapsed_time = time.time() - start_time
return stats
def process_collection_type(self, collection_type: str) -> Optional[CopyStats]:
"""
Process a single collection type.
Args:
collection_type: Type of collection (chunks, entities, relationships)
Returns:
CopyStats or None if error
"""
namespace_config = COLLECTION_NAMESPACES.get(collection_type)
if not namespace_config:
print(f"{BOLD_RED}{RESET} Unknown collection type: {collection_type}")
return None
source = namespace_config["new"]
# Generate legacy collection name dynamically: {workspace}_{suffix}
target = f"{self.workspace}_{namespace_config['suffix']}"
print(f"\n{'=' * 50}")
print(f"Processing: {BOLD_CYAN}{collection_type}{RESET}")
print(f"{'=' * 50}")
print(f" Source: {source}")
print(f" Target: {target}")
# Check source collection
source_info = self.get_collection_info(source)
if source_info is None:
print(
f" {BOLD_YELLOW}{RESET} Source collection '{source}' does not exist, skipping"
)
return None
print(f" Source vector dimension: {source_info['vector_size']}d")
print(f" Source distance metric: {source_info['distance']}")
print(f" Source total records: {source_info['count']:,}")
# Check workspace data exists BEFORE creating legacy collection
workspace_count = self.get_workspace_count(source)
print(f" Workspace '{self.workspace}' records: {workspace_count:,}")
if workspace_count == 0:
print(
f" {BOLD_YELLOW}{RESET} No data found for workspace '{self.workspace}' in '{source}', skipping"
)
return None
# Clear target collection if requested
if self.clear_target:
if not self.delete_collection(target):
return None
# Create target collection only after confirming workspace data exists
if not self.create_legacy_collection(
target, source_info["vector_size"], source_info["distance"]
):
return None
# Copy data with workspace filter
stats = self.copy_collection_data(
source, target, collection_type, workspace_count
)
# Print result
if stats.failed_records == 0:
print(
f" {BOLD_GREEN}{RESET} Copied {stats.copied_records:,} records in {stats.elapsed_time:.2f}s"
)
else:
print(
f" {BOLD_YELLOW}{RESET} Copied {stats.copied_records:,} records, "
f"{BOLD_RED}{stats.failed_records:,} failed{RESET} in {stats.elapsed_time:.2f}s"
)
return stats
def print_summary(self, all_stats: List[CopyStats]):
"""Print summary of all operations"""
print("\n" + "=" * 60)
print("Summary")
print("=" * 60)
total_copied = sum(s.copied_records for s in all_stats)
total_failed = sum(s.failed_records for s in all_stats)
total_time = sum(s.elapsed_time for s in all_stats)
for stats in all_stats:
status = (
f"{BOLD_GREEN}{RESET}"
if stats.failed_records == 0
else f"{BOLD_YELLOW}{RESET}"
)
print(
f" {status} {stats.collection_type}: {stats.copied_records:,}/{stats.total_records:,} "
f"({stats.source_collection}{stats.target_collection})"
)
print("-" * 60)
print(f" Total records copied: {BOLD_CYAN}{total_copied:,}{RESET}")
if total_failed > 0:
print(f" Total records failed: {BOLD_RED}{total_failed:,}{RESET}")
print(f" Total time: {total_time:.2f}s")
if self.dry_run:
print(f"\n{BOLD_YELLOW}⚠️ DRY RUN - No actual changes were made{RESET}")
# Print error details if any
all_errors = []
for stats in all_stats:
all_errors.extend(stats.errors)
if all_errors:
print(f"\n{BOLD_RED}Errors ({len(all_errors)}){RESET}")
for i, error in enumerate(all_errors[:5], 1):
print(
f" {i}. Batch {error['batch']}: {error['error_type']}: {error['error_msg']}"
)
if len(all_errors) > 5:
print(f" ... and {len(all_errors) - 5} more errors")
print("=" * 60)
async def run(self, collection_types: Optional[List[str]] = None):
"""
Run the data preparation tool.
Args:
collection_types: List of collection types to process (default: all)
"""
self.print_header()
# Check connection
if not self.check_connection():
return
# Determine which collection types to process
if collection_types:
types_to_process = [t.strip() for t in collection_types]
invalid_types = [
t for t in types_to_process if t not in COLLECTION_NAMESPACES
]
if invalid_types:
print(
f"{BOLD_RED}{RESET} Invalid collection types: {', '.join(invalid_types)}"
)
print(f" Valid types: {', '.join(COLLECTION_NAMESPACES.keys())}")
return
else:
types_to_process = list(COLLECTION_NAMESPACES.keys())
print(f"\nCollection types to process: {', '.join(types_to_process)}")
# Process each collection type
all_stats = []
for ctype in types_to_process:
stats = self.process_collection_type(ctype)
if stats:
all_stats.append(stats)
# Print summary
if all_stats:
self.print_summary(all_stats)
else:
print(f"\n{BOLD_YELLOW}{RESET} No collections were processed")
def parse_args():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description="Prepare legacy data in Qdrant for migration testing",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python -m lightrag.tools.prepare_qdrant_legacy_data
python -m lightrag.tools.prepare_qdrant_legacy_data --workspace space1
python -m lightrag.tools.prepare_qdrant_legacy_data --types chunks,entities
python -m lightrag.tools.prepare_qdrant_legacy_data --dry-run
""",
)
parser.add_argument(
"--workspace",
type=str,
default="space1",
help="Workspace name (default: space1)",
)
parser.add_argument(
"--types",
type=str,
default=None,
help="Comma-separated list of collection types (chunks, entities, relationships)",
)
parser.add_argument(
"--batch-size",
type=int,
default=DEFAULT_BATCH_SIZE,
help=f"Batch size for copy operations (default: {DEFAULT_BATCH_SIZE})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview operations without making changes",
)
parser.add_argument(
"--clear-target",
action="store_true",
help="Delete target collections before copying (for clean test environment)",
)
return parser.parse_args()
async def main():
"""Main entry point"""
args = parse_args()
collection_types = None
if args.types:
collection_types = [t.strip() for t in args.types.split(",")]
tool = QdrantLegacyDataPreparationTool(
workspace=args.workspace,
batch_size=args.batch_size,
dry_run=args.dry_run,
clear_target=args.clear_target,
)
await tool.run(collection_types=collection_types)
if __name__ == "__main__":
asyncio.run(main())