mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-03 14:27:32 +08:00
### What problem does this PR solve? fixes https://github.com/infiniflow/ragflow/issues/12774 Add a CLI tool for migrating RAGFlow data from Elasticsearch to OceanBase, enabling users to switch their document storage backend. - Automatic discovery and migration of all `ragflow_*` indices - Schema conversion with vector dimension auto-detection - Batch processing with progress tracking and resume capability - Data consistency validation and migration report generation **Note**: Due to network issues, I was unable to pull the required Docker images (Elasticsearch, OceanBase) to run the full end-to-end verification. Unit tests have been verified to pass. I will complete the e2e verification when network conditions allow, and submit a follow-up PR if any fixes are needed. ```bash ============================= test session starts ============================== platform darwin -- Python 3.13.6, pytest-9.0.2, pluggy-1.6.0 rootdir: /Users/sevenc/code/ai/oceanbase/ragflow/tools/es-to-oceanbase-migration configfile: pyproject.toml testpaths: tests plugins: anyio-4.12.1, asyncio-1.3.0, cov-7.0.0 collected 86 items tests/test_progress.py::TestMigrationProgress::test_create_basic_progress PASSED [ 1%] tests/test_progress.py::TestMigrationProgress::test_create_progress_with_counts PASSED [ 2%] tests/test_progress.py::TestMigrationProgress::test_progress_default_values PASSED [ 3%] tests/test_progress.py::TestMigrationProgress::test_progress_status_values PASSED [ 4%] tests/test_progress.py::TestProgressManager::test_create_progress_manager PASSED [ 5%] tests/test_progress.py::TestProgressManager::test_create_progress_manager_creates_dir PASSED [ 6%] tests/test_progress.py::TestProgressManager::test_create_progress PASSED [ 8%] tests/test_progress.py::TestProgressManager::test_save_and_load_progress PASSED [ 9%] tests/test_progress.py::TestProgressManager::test_load_nonexistent_progress PASSED [ 10%] tests/test_progress.py::TestProgressManager::test_delete_progress PASSED [ 11%] tests/test_progress.py::TestProgressManager::test_update_progress PASSED [ 12%] tests/test_progress.py::TestProgressManager::test_update_progress_multiple_batches PASSED [ 13%] tests/test_progress.py::TestProgressManager::test_mark_completed PASSED [ 15%] tests/test_progress.py::TestProgressManager::test_mark_failed PASSED [ 16%] tests/test_progress.py::TestProgressManager::test_mark_paused PASSED [ 17%] tests/test_progress.py::TestProgressManager::test_can_resume_running PASSED [ 18%] tests/test_progress.py::TestProgressManager::test_can_resume_paused PASSED [ 19%] tests/test_progress.py::TestProgressManager::test_can_resume_completed PASSED [ 20%] tests/test_progress.py::TestProgressManager::test_can_resume_nonexistent PASSED [ 22%] tests/test_progress.py::TestProgressManager::test_get_resume_info PASSED [ 23%] tests/test_progress.py::TestProgressManager::test_get_resume_info_nonexistent PASSED [ 24%] tests/test_progress.py::TestProgressManager::test_progress_file_path PASSED [ 25%] tests/test_progress.py::TestProgressManager::test_progress_file_content PASSED [ 26%] tests/test_schema.py::TestRAGFlowSchemaConverter::test_analyze_ragflow_mapping PASSED [ 27%] tests/test_schema.py::TestRAGFlowSchemaConverter::test_detect_vector_size PASSED [ 29%] tests/test_schema.py::TestRAGFlowSchemaConverter::test_unknown_fields PASSED [ 30%] tests/test_schema.py::TestRAGFlowSchemaConverter::test_get_column_definitions PASSED [ 31%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_basic_document PASSED [ 32%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_with_vector PASSED [ 33%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_array_fields PASSED [ 34%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_json_fields PASSED [ 36%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_unknown_fields_to_extra PASSED [ 37%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_kb_id_list PASSED [ 38%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_content_with_weight_dict PASSED [ 39%] tests/test_schema.py::TestRAGFlowDataConverter::test_convert_batch PASSED [ 40%] tests/test_schema.py::TestVectorFieldPattern::test_valid_patterns PASSED [ 41%] tests/test_schema.py::TestVectorFieldPattern::test_invalid_patterns PASSED [ 43%] tests/test_schema.py::TestVectorFieldPattern::test_extract_dimension PASSED [ 44%] tests/test_schema.py::TestConstants::test_array_columns PASSED [ 45%] tests/test_schema.py::TestConstants::test_json_columns PASSED [ 46%] tests/test_schema.py::TestConstants::test_ragflow_columns_completeness PASSED [ 47%] tests/test_schema.py::TestConstants::test_fts_columns PASSED [ 48%] tests/test_schema.py::TestConstants::test_ragflow_columns_types PASSED [ 50%] tests/test_schema.py::TestRAGFlowSchemaConverterEdgeCases::test_empty_mapping PASSED [ 51%] tests/test_schema.py::TestRAGFlowSchemaConverterEdgeCases::test_mapping_without_properties PASSED [ 52%] tests/test_schema.py::TestRAGFlowSchemaConverterEdgeCases::test_multiple_vector_fields PASSED [ 53%] tests/test_schema.py::TestRAGFlowSchemaConverterEdgeCases::test_get_column_definitions_without_analysis PASSED [ 54%] tests/test_schema.py::TestRAGFlowSchemaConverterEdgeCases::test_get_vector_fields PASSED [ 55%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_empty_document PASSED [ 56%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_document_without_source PASSED [ 58%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_boolean_to_integer PASSED [ 59%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_invalid_integer PASSED [ 60%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_float_field PASSED [ 61%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_array_with_special_characters PASSED [ 62%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_already_json_array PASSED [ 63%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_single_value_to_array PASSED [ 65%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_detect_vector_fields_from_document PASSED [ 66%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_with_default_values PASSED [ 67%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_list_content PASSED [ 68%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_convert_batch_empty PASSED [ 69%] tests/test_schema.py::TestRAGFlowDataConverterEdgeCases::test_existing_extra_field_merged PASSED [ 70%] tests/test_verify.py::TestVerificationResult::test_create_basic_result PASSED [ 72%] tests/test_verify.py::TestVerificationResult::test_result_default_values PASSED [ 73%] tests/test_verify.py::TestVerificationResult::test_result_with_counts PASSED [ 74%] tests/test_verify.py::TestMigrationVerifier::test_verify_counts_match PASSED [ 75%] tests/test_verify.py::TestMigrationVerifier::test_verify_counts_mismatch PASSED [ 76%] tests/test_verify.py::TestMigrationVerifier::test_verify_samples_all_match PASSED [ 77%] tests/test_verify.py::TestMigrationVerifier::test_verify_samples_some_missing PASSED [ 79%] tests/test_verify.py::TestMigrationVerifier::test_verify_samples_data_mismatch PASSED [ 80%] tests/test_verify.py::TestMigrationVerifier::test_values_equal_none_values PASSED [ 81%] tests/test_verify.py::TestMigrationVerifier::test_values_equal_array_columns PASSED [ 82%] tests/test_verify.py::TestMigrationVerifier::test_values_equal_json_columns PASSED [ 83%] tests/test_verify.py::TestMigrationVerifier::test_values_equal_kb_id_list PASSED [ 84%] tests/test_verify.py::TestMigrationVerifier::test_values_equal_content_with_weight_dict PASSED [ 86%] tests/test_verify.py::TestMigrationVerifier::test_determine_result_passed PASSED [ 87%] tests/test_verify.py::TestMigrationVerifier::test_determine_result_failed_count PASSED [ 88%] tests/test_verify.py::TestMigrationVerifier::test_determine_result_failed_samples PASSED [ 89%] tests/test_verify.py::TestMigrationVerifier::test_generate_report PASSED [ 90%] tests/test_verify.py::TestMigrationVerifier::test_generate_report_with_missing PASSED [ 91%] tests/test_verify.py::TestMigrationVerifier::test_generate_report_with_mismatches PASSED [ 93%] tests/test_verify.py::TestValueComparison::test_string_comparison PASSED [ 94%] tests/test_verify.py::TestValueComparison::test_integer_comparison PASSED [ 95%] tests/test_verify.py::TestValueComparison::test_float_comparison PASSED [ 96%] tests/test_verify.py::TestValueComparison::test_boolean_comparison PASSED [ 97%] tests/test_verify.py::TestValueComparison::test_empty_array_comparison PASSED [ 98%] tests/test_verify.py::TestValueComparison::test_nested_json_comparison PASSED [100%] ======================= 86 passed, 88 warnings in 0.66s ======================== ``` ### Type of change - [ ] Bug Fix (non-breaking change which fixes an issue) - [x] New Feature (non-breaking change which adds functionality) - [ ] Documentation Update - [ ] Refactoring - [ ] Performance Improvement - [ ] Other (please describe):
293 lines
8.5 KiB
Python
293 lines
8.5 KiB
Python
"""
|
|
Elasticsearch 8+ Client for RAGFlow data migration.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Iterator
|
|
|
|
from elasticsearch import Elasticsearch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ESClient:
|
|
"""Elasticsearch client wrapper for RAGFlow migration operations."""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str = "localhost",
|
|
port: int = 9200,
|
|
username: str | None = None,
|
|
password: str | None = None,
|
|
api_key: str | None = None,
|
|
use_ssl: bool = False,
|
|
verify_certs: bool = True,
|
|
):
|
|
"""
|
|
Initialize ES client.
|
|
|
|
Args:
|
|
host: ES host address
|
|
port: ES port
|
|
username: Basic auth username
|
|
password: Basic auth password
|
|
api_key: API key for authentication
|
|
use_ssl: Whether to use SSL
|
|
verify_certs: Whether to verify SSL certificates
|
|
"""
|
|
self.host = host
|
|
self.port = port
|
|
|
|
# Build connection URL
|
|
scheme = "https" if use_ssl else "http"
|
|
url = f"{scheme}://{host}:{port}"
|
|
|
|
# Build connection arguments
|
|
conn_args: dict[str, Any] = {
|
|
"hosts": [url],
|
|
"verify_certs": verify_certs,
|
|
}
|
|
|
|
if api_key:
|
|
conn_args["api_key"] = api_key
|
|
elif username and password:
|
|
conn_args["basic_auth"] = (username, password)
|
|
|
|
self.client = Elasticsearch(**conn_args)
|
|
logger.info(f"Connected to Elasticsearch at {url}")
|
|
|
|
def health_check(self) -> dict[str, Any]:
|
|
"""Check cluster health."""
|
|
return self.client.cluster.health().body
|
|
|
|
def get_cluster_info(self) -> dict[str, Any]:
|
|
"""Get cluster information."""
|
|
return self.client.info().body
|
|
|
|
def list_indices(self, pattern: str = "*") -> list[str]:
|
|
"""List all indices matching pattern."""
|
|
response = self.client.indices.get(index=pattern)
|
|
return list(response.keys())
|
|
|
|
def list_ragflow_indices(self) -> list[str]:
|
|
"""
|
|
List all RAGFlow-related indices.
|
|
|
|
Returns indices matching patterns:
|
|
- ragflow_* (document chunks)
|
|
- ragflow_doc_meta_* (document metadata)
|
|
|
|
Returns:
|
|
List of RAGFlow index names
|
|
"""
|
|
try:
|
|
# Get all ragflow_* indices
|
|
ragflow_indices = self.list_indices("ragflow_*")
|
|
return sorted(ragflow_indices)
|
|
except Exception:
|
|
# If no indices match, return empty list
|
|
return []
|
|
|
|
def get_index_mapping(self, index_name: str) -> dict[str, Any]:
|
|
"""
|
|
Get index mapping.
|
|
|
|
Args:
|
|
index_name: Name of the index
|
|
|
|
Returns:
|
|
Index mapping dictionary
|
|
"""
|
|
response = self.client.indices.get_mapping(index=index_name)
|
|
return response[index_name]["mappings"]
|
|
|
|
def get_index_settings(self, index_name: str) -> dict[str, Any]:
|
|
"""Get index settings."""
|
|
response = self.client.indices.get_settings(index=index_name)
|
|
return response[index_name]["settings"]
|
|
|
|
def count_documents(self, index_name: str) -> int:
|
|
"""Count documents in an index."""
|
|
response = self.client.count(index=index_name)
|
|
return response["count"]
|
|
|
|
def count_documents_with_filter(
|
|
self,
|
|
index_name: str,
|
|
filters: dict[str, Any]
|
|
) -> int:
|
|
"""
|
|
Count documents with filter conditions.
|
|
|
|
Args:
|
|
index_name: Index name
|
|
filters: Filter conditions (e.g., {"kb_id": "xxx"})
|
|
|
|
Returns:
|
|
Document count
|
|
"""
|
|
# Build bool query with filters
|
|
must_clauses = []
|
|
for field, value in filters.items():
|
|
if isinstance(value, list):
|
|
must_clauses.append({"terms": {field: value}})
|
|
else:
|
|
must_clauses.append({"term": {field: value}})
|
|
|
|
query = {
|
|
"bool": {
|
|
"must": must_clauses
|
|
}
|
|
} if must_clauses else {"match_all": {}}
|
|
|
|
response = self.client.count(index=index_name, query=query)
|
|
return response["count"]
|
|
|
|
def aggregate_field(
|
|
self,
|
|
index_name: str,
|
|
field: str,
|
|
size: int = 10000,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Aggregate field values (like getting all unique kb_ids).
|
|
|
|
Args:
|
|
index_name: Index name
|
|
field: Field to aggregate
|
|
size: Max number of buckets
|
|
|
|
Returns:
|
|
Aggregation result with buckets
|
|
"""
|
|
response = self.client.search(
|
|
index=index_name,
|
|
size=0,
|
|
aggs={
|
|
"field_values": {
|
|
"terms": {
|
|
"field": field,
|
|
"size": size,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
return response["aggregations"]["field_values"]
|
|
|
|
def scroll_documents(
|
|
self,
|
|
index_name: str,
|
|
batch_size: int = 1000,
|
|
query: dict[str, Any] | None = None,
|
|
sort_field: str = "_doc",
|
|
) -> Iterator[list[dict[str, Any]]]:
|
|
"""
|
|
Scroll through all documents in an index using search_after (ES 8+).
|
|
|
|
This is the recommended approach for ES 8+ instead of scroll API.
|
|
Uses search_after for efficient deep pagination.
|
|
|
|
Args:
|
|
index_name: Name of the index
|
|
batch_size: Number of documents per batch
|
|
query: Optional query filter
|
|
sort_field: Field to sort by (default: _doc for efficiency)
|
|
|
|
Yields:
|
|
Batches of documents
|
|
"""
|
|
search_body: dict[str, Any] = {
|
|
"size": batch_size,
|
|
"sort": [{sort_field: "asc"}, {"_id": "asc"}],
|
|
}
|
|
|
|
if query:
|
|
search_body["query"] = query
|
|
else:
|
|
search_body["query"] = {"match_all": {}}
|
|
|
|
# Initial search
|
|
response = self.client.search(index=index_name, body=search_body)
|
|
hits = response["hits"]["hits"]
|
|
|
|
while hits:
|
|
# Extract documents with _id
|
|
documents = []
|
|
for hit in hits:
|
|
doc = hit["_source"].copy()
|
|
doc["_id"] = hit["_id"]
|
|
if "_score" in hit:
|
|
doc["_score"] = hit["_score"]
|
|
documents.append(doc)
|
|
|
|
yield documents
|
|
|
|
# Check if there are more results
|
|
if len(hits) < batch_size:
|
|
break
|
|
|
|
# Get search_after value from last hit
|
|
search_after = hits[-1]["sort"]
|
|
search_body["search_after"] = search_after
|
|
|
|
response = self.client.search(index=index_name, body=search_body)
|
|
hits = response["hits"]["hits"]
|
|
|
|
def get_document(self, index_name: str, doc_id: str) -> dict[str, Any] | None:
|
|
"""Get a single document by ID."""
|
|
try:
|
|
response = self.client.get(index=index_name, id=doc_id)
|
|
doc = response["_source"].copy()
|
|
doc["_id"] = response["_id"]
|
|
return doc
|
|
except Exception:
|
|
return None
|
|
|
|
def get_sample_documents(
|
|
self,
|
|
index_name: str,
|
|
size: int = 10,
|
|
query: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Get sample documents from an index.
|
|
|
|
Args:
|
|
index_name: Index name
|
|
size: Number of samples
|
|
query: Optional query filter
|
|
"""
|
|
search_body = {
|
|
"query": query if query else {"match_all": {}},
|
|
"size": size
|
|
}
|
|
|
|
response = self.client.search(index=index_name, body=search_body)
|
|
documents = []
|
|
for hit in response["hits"]["hits"]:
|
|
doc = hit["_source"].copy()
|
|
doc["_id"] = hit["_id"]
|
|
documents.append(doc)
|
|
return documents
|
|
|
|
def get_document_ids(
|
|
self,
|
|
index_name: str,
|
|
size: int = 1000,
|
|
query: dict[str, Any] | None = None,
|
|
) -> list[str]:
|
|
"""Get list of document IDs."""
|
|
search_body = {
|
|
"query": query if query else {"match_all": {}},
|
|
"size": size,
|
|
"_source": False,
|
|
}
|
|
|
|
response = self.client.search(index=index_name, body=search_body)
|
|
return [hit["_id"] for hit in response["hits"]["hits"]]
|
|
|
|
def close(self):
|
|
"""Close the ES client connection."""
|
|
self.client.close()
|
|
logger.info("Elasticsearch connection closed")
|