Files
ragflow/tools/es-to-oceanbase-migration/src/es_ob_migration/es_client.py
Se7en 332b11cf96 feat(tools): add Elasticsearch to OceanBase migration tool (#12927)
### 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):
2026-01-31 16:11:27 +08:00

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")