데이터 무결성 검증

데이터 무결성 검증 (migration-guidance-data-integrity)

마이그레이션에서 가장 먼저 확인해야 할 것은 데이터가 제대로 도착했는지, 그리고 올바르게 도착했는지예요. 먼저 마이그레이션 전 기준점(baseline)을 잡았다면, 그다음으로 데이터 무결성(Data integrity)을 점검해야 해요. 데이터 무결성이 답하는 질문은 단순해요. "내 모든 데이터가 도착했나, 그리고 올바르게 도착했나?" 이 검사들은 실행 속도가 가장 빠르면서도 가장 흔한 마이그레이션 실패를 잡아내줘요.

출처: Qdrant 공식문서

1. 벡터 수 확인 (Vector Count Verification)

가장 간단한 검사예요. Qdrant의 벡터 수가 소스 시스템과 일치하는지 보면 돼요.

from qdrant_client import QdrantClient

client = QdrantClient("localhost", port=6333)

# Get collection info
collection_info = client.get_collection("your_collection")
qdrant_count = collection_info.points_count

# Compare against baseline
source_count = baseline["total_vector_count"]  # From pre-migration capture

if qdrant_count == source_count:
    print(f"✓ Vector count matches: {qdrant_count}")
else:
    diff = source_count - qdrant_count
    pct = (diff / source_count) * 100
    print(f"✗ Count mismatch: source={source_count}, qdrant={qdrant_count}, "
          f"missing={diff} ({pct:.2f}%)")

카운트 불일치의 흔한 원인:

증상 예상 원인
Qdrant 수가 더 낮음 마이그레이션 스크립트가 중간에 실패했거나, 소스의 중복 ID가 중복 제거되거나, 소스 카운트에 소프트 삭제된 레코드가 포함되어 있음
Qdrant 수가 더 높음 재시도된 마이그레이션으로 중복 삽입이 발생했거나, 소스 카운트가 모든 네임스페이스/파티션을 포함하지 않았음
카운트는 일치하지만 데이터가 잘못됨 ID 충돌: 서로 다른 벡터가 같은 포인트 ID에 매핑됨

정확한 일치가 기대되지 않는 경우: 일부 소스 시스템은 카운트 방식을 다르게 해요. Pinecone의 describe_index_stats는 모든 네임스페이스를 합쳐서 세는데, 여러분이 일부만 마이그레이션했다면 카운트가 맞지 않을 거예요. pgvector의 n_live_tup은 추정값이에요. 마이그레이션이 실패했다고 결론 내기 전에, 이런 예상되는 차이를 문서로 남겨두어야 해요.

2. 벡터 차원 확인 (Vector Dimension Verification)

벡터 차원이 소스 설정과 일치하는지 확인해요.

collection_info = client.get_collection("your_collection")
qdrant_dim = collection_info.config.params.vectors.size
# For named vectors:
# qdrant_dim = collection_info.config.params.vectors["dense"].size

source_dim = baseline["dimension"]

assert qdrant_dim == source_dim, (
    f"Dimension mismatch: source={source_dim}, qdrant={qdrant_dim}"
)

차원이 일치하지 않는 경우: 거의 항상 마이그레이션 스크립트 오류를 의미해요 (예: 벡터가 잘렸거나, 재임베딩에 잘못된 임베딩 모델을 사용). 이 문제를 해결하기 전에는 더 이상의 검증을 진행하지 마세요.

3. 거리 메트릭 확인 (Distance Metric Verification)

거리 메트릭이 소스 시스템 설정과 일치하는지 확인해요.

qdrant_metric = collection_info.config.params.vectors.distance
# Returns: "Cosine", "Euclid", or "Dot"

# Map source system metrics to Qdrant equivalents
METRIC_MAP = {
    # Pinecone
    "cosine": "Cosine",
    "euclidean": "Euclid",
    "dotproduct": "Dot",
    # Weaviate
    "l2-squared": "Euclid",
    # Milvus
    "COSINE": "Cosine",
    "L2": "Euclid",
    "IP": "Dot",
}

expected_metric = METRIC_MAP.get(baseline["metric"])
assert qdrant_metric == expected_metric, (
    f"Distance metric mismatch: source={baseline['metric']} "
    f"(expected {expected_metric}), qdrant={qdrant_metric}"
)

거리 메트릭 불일치는 조용한(silent) 오류예요. 마이그레이션해도 벡터는 여전히 로드되고, 쿼리도 결과를 반환하거든요. 예를 들어 코사인 유사도와 내적(dot product)은 벡터가 단위 정규화(unit-normalized)되어 있을 때만 동일한 순위를 만들어요. 벡터가 정규화되어 있지 않은데 코사인과 내적을 오가면 모든 검색 결과가 달라져요.

4. 메타데이터(Payload) 확인 (Metadata Verification)

메타데이터 확인은 세 가지를 살펴봐요. 필드 존재 여부, 필드 타입, 필드 값이에요.

4a. 필드 존재 여부 (Field Presence)

예상되는 모든 메타데이터 필드가 Qdrant에 존재하는지 확인해요.

import random

# Sample points from Qdrant using scroll
records, _next = client.scroll(
    collection_name="your_collection",
    limit=1000,
    with_payload=True,
    with_vectors=False,  # Skip vectors to speed up the check
)

# Collect all field names across sampled records
qdrant_fields = set()
for record in records:
    if record.payload:
        qdrant_fields.update(record.payload.keys())

source_fields = set(baseline["metadata_fields"])
missing = source_fields - qdrant_fields
extra = qdrant_fields - source_fields

if missing:
    print(f"✗ Fields missing in Qdrant: {missing}")
if extra:
    print(f"⚠ Extra fields in Qdrant (may be expected): {extra}")
if not missing and not extra:
    print(f"✓ All {len(source_fields)} metadata fields present")

4b. 필드 타입 일관성 (Field Type Consistency)

필드 타입이 마이그레이션을 거치며 유지됐는지 확인해요.

def check_field_types(source_record, qdrant_record):
    """Compare field types between source and Qdrant records."""
    issues = []
    for field, source_value in source_record.items():
        if field not in qdrant_record:
            issues.append(f"  {field}: missing in Qdrant")
            continue
        qdrant_value = qdrant_record[field]
        if type(source_value) != type(qdrant_value):
            issues.append(
                f"  {field}: type changed from "
                f"{type(source_value).__name__} to {type(qdrant_value).__name__} "
                f"(source={source_value!r}, qdrant={qdrant_value!r})"
            )
    return issues

흔한 타입 강제 변환 문제:

소스 타입 Qdrant 도착 형태 영향
Integer → Float 4242.0 = 42 필터가 실패할 수 있음. range 필터 사용.
Boolean → String true"true" = true 필터가 결과를 반환하지 않음
중첩 객체 → 평탄화 {"a": {"b": 1}}{"a.b": 1} 중첩 필터 문법이 매칭되지 않음
배열 → 단일 값 ["tag1", "tag2"]"tag1" 배열 포함 필터가 깨짐
Null → 필드 없음 null → (필드 부재) is_null 필터가 찾지 못함

4c. 필드 값 스팟체크 (Field Value Spot-Check)

샘플링한 레코드에 대해 실제 값을 비교해요.

def spot_check_values(source_sample, qdrant_collection, client):
    """Compare metadata values for sampled records."""
    mismatches = []

    for source_record in source_sample:
        point_id = source_record["id"]
        qdrant_points = client.retrieve(
            collection_name=qdrant_collection,
            ids=[point_id],
            with_payload=True,
        )
        if not qdrant_points:
            mismatches.append({"id": point_id, "issue": "Point not found in Qdrant"})
            continue

        qdrant_payload = qdrant_points[0].payload
        for field, source_value in source_record["metadata"].items():
            qdrant_value = qdrant_payload.get(field)
            if source_value != qdrant_value:
                mismatches.append({
                    "id": point_id,
                    "field": field,
                    "source": source_value,
                    "qdrant": qdrant_value,
                })

    return mismatches

5. 포인트 ID 확인 (Point ID Verification)

중복되거나 고아가 된 포인트 ID가 있는지 확인해요.

# Scroll through all points and collect IDs
all_ids = []
next_offset = None
while True:
    records, next_offset = client.scroll(
        collection_name="your_collection",
        limit=1000,
        offset=next_offset,
        with_payload=False,
        with_vectors=False,
    )
    all_ids.extend([r.id for r in records])
    if next_offset is None:
        break

# Check for duplicates
if len(all_ids) != len(set(all_ids)):
    duplicates = [id for id in all_ids if all_ids.count(id) > 1]
    print(f"✗ Found {len(duplicates)} duplicate point IDs")
else:
    print(f"✓ No duplicate point IDs ({len(all_ids)} unique)")

ID 매핑에 관한 참고: 소스 시스템이 문자열 ID를 쓰고 마이그레이션 중에 정수 ID로 매핑했다면(또는 그 반대라면), 매핑 파일을 유지하고 그것이 일관적인지 확인해야 해요.

6. 벡터 값 스팟체크 (Vector Value Spot-Check)

작은 샘플에 대해 실제 벡터 값이 일치하는지 확인해요.

import numpy as np

def verify_vectors(source_vectors, qdrant_collection, client, tolerance=1e-6):
    """Spot-check that vector values match between source and Qdrant."""
    mismatches = []

    for source in source_vectors:
        qdrant_points = client.retrieve(
            collection_name=qdrant_collection,
            ids=[source["id"]],
            with_vectors=True,
        )
        if not qdrant_points:
            mismatches.append({"id": source["id"], "issue": "not found"})
            continue

        source_vec = np.array(source["vector"])
        qdrant_vec = np.array(qdrant_points[0].vector)

        if not np.allclose(source_vec, qdrant_vec, atol=tolerance):
            max_diff = np.max(np.abs(source_vec - qdrant_vec))
            mismatches.append({
                "id": source["id"],
                "max_difference": float(max_diff),
            })

    return mismatches

예상 허용 오차: 어느 한쪽이라도 양자화(quantization)가 적용되면 정확한 부동소수점 동일성(tolerance=0)은 너무 엄격해요. Qdrant에서 스칼라 양자화를 사용한다면 작은 차이가 있을 거라고 예상해요. 양쪽 모두 양자화를 쓰지 않는다면 값은 정확히 일치해야 해요.

통과 기준 (Passing Criteria)

검사 통과 조사 필요
벡터 수 정확히 일치 (또는 문서화된 허용 오차 내) 설명할 수 없는 차이
차원 정확히 일치 불일치 (여기서 멈춤)
거리 메트릭 Qdrant 등가물로 올바르게 매핑 불일치 (여기서 멈춤)
메타데이터 필드 모든 소스 필드가 존재 누락된 필드
메타데이터 타입 타입 보존 또는 의도적으로 변환됨 예상치 못한 타입 변경
메타데이터 값 스팟체크 샘플 일치 1% 이상 불일치율
포인트 ID 중복 없음, 모든 소스 ID 존재 누락되거나 중복된 ID
벡터 값 허용 오차 내 (양자화 없으면 1e-6) 허용 오차 초과 차이

다음 단계: 검색 품질 검증 (Search Quality Verification)

더 알아보기 (Learn more)