DynamoDB가 네이티브 벡터 검색을 지원한다. 아이템 속성에 임베딩을 저장하고 같은 테이블에서 유사도 검색을 실행할 수 있으므로, 하나의 테이블이 운영 데이터 저장소와 벡터 저장소 역할을 함께 맡는다. 어떤 애플리케이션에는 구조를 크게 단순화하는 변화지만, 모든 검색 문제의 해답은 아니다. 제품 카탈로그를 예로 이 기능이 없애는 파이프라인과 적용 범위를 살펴보자.

이전에 쓴 AWS 벡터 데이터베이스 선택 가이드의 비용과 지연 시간 관점에서 보면, DynamoDB 벡터 검색은 새로운 선택지다. 다만 다른 벡터 저장소와 경쟁하는 기능은 아니다. 별도의 벡터 저장소를 아예 두지 않아도 되는 상황을 만드는 기능이다.

제품 카탈로그로 살펴보기

제품 카탈로그를 운영한다고 하자. 제품은 이미 product_id를 키로 하는 DynamoDB 테이블에 저장되어 있으며, 각 아이템에는 title, description, price, category가 있다. 읽기와 쓰기는 빠르고 비용도 낮다. 이제 제품팀이 “비슷한 제품 찾기"와 “비 오는 날 하이킹에 입을 따뜻한 재킷” 같은 자연어 검색을 요청한다. 키워드가 아니라 문장의 의미를 기준으로 제품을 찾는 기능이다.

이런 검색에는 텍스트의 의미를 숫자 벡터로 표현한 임베딩과, 가까운 벡터를 찾는 최근접 이웃 검색이 필요하다. 중요한 질문은 이 벡터를 어디에 둘 것인가다.

기존 구조: 별도 벡터 저장소와 동기화 파이프라인

기존의 일반적인 해법은 DynamoDB 옆에 전용 벡터 저장소를 두는 방식이다. 제품마다 임베딩을 생성해 product_id와 함께 벡터 저장소에 기록한다. 검색할 때는 벡터 저장소에서 제품 ID를 찾고, 그 ID로 DynamoDB를 다시 조회해 실제 제품 데이터를 가져온다.

비용과 복잡도를 키우는 요소는 벡터 저장소 하나가 아니다. 두 저장소를 계속 같은 상태로 유지해야 한다는 점이다.

DynamoDB (원본 데이터 저장소)
    | DynamoDB Streams
    v
Lambda (변경 시 임베딩 생성) --> Bedrock (Titan 임베딩)
    |
    v
벡터 저장소 (OpenSearch / pgvector / 기타)   <-- 지속적인 동기화 필요

검색 시:
  쿼리 --임베딩 생성--> 벡터 저장소 --> [product_id, ...] --> DynamoDB BatchGetItem --> 제품 데이터

이 구조에는 세 가지 운영 문제가 따라온다.

  • 동기화 불일치. 제품이 바뀔 때마다 변경 내용을 벡터 저장소에도 전달해야 한다. Lambda 오류, 처리량 제한, 중간에 멈춘 백필 작업 때문에 하나라도 누락되면 검색 결과가 오래되거나 일부 제품이 빠질 수 있다. 결국 두 저장소의 정합성을 확인하고 복구하는 작업까지 직접 운영해야 한다.
  • 추가 조회. 벡터 저장소는 제품 ID만 반환한다. 사용자가 볼 제품 데이터를 가져오려면 DynamoDB를 한 번 더 호출해야 하므로, 검색마다 지연 시간과 장애 지점이 늘어난다.
  • 별도의 비용과 운영 대상. 이미 가진 데이터를 복제하는 용도의 저장소를 따로 프로비저닝하고, 패치하고, 확장해야 한다.

새 구조: 임베딩을 아이템 속성으로 저장하기

네이티브 벡터 검색에서는 기존 DynamoDB 테이블에 벡터 인덱스를 추가한다. 임베딩은 제품 아이템의 속성이 된다. 벡터가 설명하는 제품 데이터와 같은 아이템에 있으므로, 별도 저장소와 동기화 파이프라인이 필요 없다.

인덱스는 새 테이블을 만들 때 정의할 수 있고, 기존 테이블에도 UpdateTable로 추가할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
dynamodb.create_table(
    TableName="Products",
    AttributeDefinitions=[{"AttributeName": "product_id", "AttributeType": "S"}],
    KeySchema=[{"AttributeName": "product_id", "KeyType": "HASH"}],
    VectorIndexes=[
        {
            "IndexName": "VectorIndex",
            "VectorAttribute": {"AttributeName": "embedding"},
            "Dimensions": 1024,                 # 임베딩 모델의 출력 차원과 일치해야 함
            "DistanceFunction": "DOT_PRODUCT",  # COSINE | DOT_PRODUCT | EUCLIDEAN
            "Projection": {"ProjectionType": "ALL"},
        }
    ],
    BillingMode="PAY_PER_REQUEST",
)

제품을 저장할 때 임베딩도 같은 아이템에 함께 넣는다.

1
2
3
4
5
emb = bedrock_embed(f"{title}. {description}")   # Titan Text Embeddings V2, 정규화된 벡터
table.put_item(Item={
    "product_id": pid, "title": title, "description": description,
    "price": price, "category": category, "embedding": emb,
})

검색 결과에는 ID뿐 아니라 제품 데이터도 함께 담긴다.

1
2
3
4
5
6
q = bedrock_embed("비 오는 날 하이킹에 입을 따뜻한 재킷")
resp = table.query(
    IndexName="VectorIndex",
    VectorSearchConfiguration={"Vector": q, "TopK": 5},
)
# resp["Items"]에 title, price, category가 포함되어 있어 추가 조회가 필요하지 않음

이제 Streams -> Lambda -> 벡터 저장소 파이프라인과 정합성 확인 작업, BatchGetItem을 통한 추가 조회가 사라진다. ProjectionALL로 설정했기 때문에 검색 한 번으로 전체 제품 데이터를 받을 수 있다.

다만 다음 설정은 신중히 골라야 한다.

  • Dimensions는 임베딩 모델의 출력 차원과 같아야 한다. Titan V2는 1024차원을 사용하며, DynamoDB는 최대 4,096차원을 지원한다.
  • Distance function은 임베딩이 이미 단위 벡터로 정규화되어 있다면 DOT_PRODUCT를 사용한다. 코사인 유사도와 같은 순서를 얻으면서 별도의 정규화 단계를 피할 수 있다. 정규화하지 않은 벡터에는 COSINE을, 벡터 크기 자체가 의미를 가질 때는 EUCLIDEAN을 사용한다. COSINEEUCLIDEAN은 값이 낮을수록 유사하고, DOT_PRODUCT는 값이 높을수록 유사하다.
  • Projection에서 ALL은 전체 아이템을 반환한다. INCLUDE는 필요한 속성만 반환하므로 인덱스 크기와 비용을 줄일 수 있다. KEYS_ONLY는 원본 아이템을 다시 읽어야 하므로 추가 조회를 되살린다. 아이템이 크다면 결과 화면에 필요한 속성만 INCLUDE로 지정하는 편이 낫다.
  • 검색은 ANN(approximate nearest neighbor, 근사 최근접 이웃) 방식이다. 예측 가능한 속도와 비용을 얻는 대신 정확도를 일부 포기한다. 대규모 검색에는 합리적인 선택이지만, 재현율(recall)이 100%가 아니라는 점은 아래의 선택 기준에서 중요하다.

멀티 테넌트 격리에서의 파티션 키

고객마다 자신의 제품만 볼 수 있는 멀티 테넌트 카탈로그라면, 벡터 인덱스의 선택적 파티션 키가 특히 유용하다. 인덱스의 파티션 키를 tenant_id로 정의하면 모든 검색 요청에 테넌트 값을 포함해야 한다. DynamoDB는 해당 테넌트의 인덱스 영역만 조회한다.

그 결과 검색 대상 데이터가 줄어 비용이 낮아지고, 테넌트별 처리량 한도를 둘 수 있으며, 저장소 계층에서 격리를 강제할 수 있다. 검색 시 필터를 적용했는지에만 의존하지 않아도 된다는 뜻이다.

대부분의 관리형 RAG 스택은 메타데이터 필드를 붙이고 검색할 때 필터링하는 방식을 사용한다. 이 방식도 쓸 수 있지만, 필터 하나가 빠져 다른 테넌트의 데이터가 검색 결과에 노출되는 사고가 발생할 수 있다. 테넌트 경계가 분명하다면 저장소 수준의 파티셔닝은 더 강한 선택지다.

벡터 DB를 대체할 수 있는 경우

다음 조건이라면 DynamoDB 네이티브 벡터 검색이 적합하다.

  • DynamoDB가 이미 운영 데이터 저장소일 때. 벡터가 그 테이블에 있는 아이템을 설명한다면, 별도 저장소와 동기화 파이프라인을 없앨 수 있다. 이 기능의 핵심 이점은 더 좋은 인덱스가 아니라 더 단순한 구조다.
  • 주요 쿼리가 유사한 아이템과 그 데이터를 함께 요구할 때. 검색 결과와 제품 데이터를 한 번에 반환하는 구조가 가장 큰 효과를 낸다.
  • 테넌트별 격리와 처리량 제어가 필요할 때. 파티션 키가 테넌트 경계와 자연스럽게 대응해야 한다.
  • 운영 대상과 비용 청구를 줄이고 싶을 때. 두 번째 저장소를 운영하지 않아도 된다.

반대로 다음 경우에는 전용 벡터 저장소나 관리형 RAG 서비스를 유지하는 편이 낫다.

  • 관리형 RAG 파이프라인 전체가 필요할 때. DynamoDB는 벡터 검색만 제공한다. 문서 청킹, 수집 파이프라인, 검색 결과를 생성 단계로 연결하는 오케스트레이션은 제공하지 않는다. 이 기능을 바로 사용할 수 있어야 한다면 Bedrock Knowledge Bases가 더 빠른 선택지다. DynamoDB만 사용하면 그 파이프라인을 직접 구축해야 한다.
  • 작은 문서 집합에서도 재현율이 거의 완벽해야 할 때. ANN과 잘 튜닝된 전용 인덱스(OpenSearch)는 검색 품질에서 범용 저장소보다 나을 수 있다. 도입을 결정하기 전에 자체 정답 집합(golden set)으로 측정해야 한다. 이 수치가 마이그레이션 여부를 결정한다.
  • 원본 데이터가 DynamoDB에 없을 때. 데이터가 S3나 Postgres에 있다면, 벡터를 저장하려고 DynamoDB를 추가하는 순간 이 기능이 없애려는 동기화 문제를 반대 방향으로 다시 만들게 된다.

도입 전에 확인할 점

  • 벡터 검색은 RAG 전체가 아니다. 이 기능은 가까운 아이템을 반환한다. 문서 청킹, 임베딩 생성 흐름, 답변 생성은 여전히 직접 구성해야 한다. 이 범위를 빼고 “RAG 스택 대신 DynamoDB를 쓰자"고 결정하면 필요한 작업을 과소평가하게 된다.
  • Dimensions는 인덱스를 생성할 때 고정된다. 나중에 차원이 다른 임베딩 모델로 바꾸려면 새 인덱스를 만들고 데이터를 백필해야 한다. 모델 선택을 초기에 신중히 확정해야 한다.
  • KEYS_ONLY는 추가 조회를 되살린다. 인덱스를 작게 유지하려고 이 옵션을 선택하면 검색 결과마다 두 번째 GetItem 호출이 필요하다. 필요한 결과 속성만 INCLUDE로 지정하는 편이 낫다.
  • 재현율을 직접 측정해야 한다. ANN은 정확도와 속도를 맞바꾼다. 열 개의 쿼리에서 좋아 보이는 데모도 롱테일 쿼리에서는 기대한 결과를 놓칠 수 있다. 쿼리와 기대 결과의 쌍으로 정답 집합을 만들고, 마이그레이션 전에 현재 저장소와 재현율을 비교해야 한다.

실제로 해볼 일

이미 DynamoDB를 운영 데이터베이스로 사용하면서 별도 벡터 저장소를 검토하고 있다면, 먼저 이 방식을 프로토타입으로 확인해 보자. 테이블 복사본에 벡터 인덱스를 추가하고 데이터 일부를 임베딩한 뒤, 정답 집합으로 실제 쿼리를 실행해 재현율을 확인한다.

재현율이 요구 수준을 충족한다면 데이터베이스 하나, 동기화 파이프라인 하나, 추가 조회 하나를 함께 없앨 수 있다. 충족하지 못하더라도 적은 비용으로 결론을 얻은 셈이다. 그때는 선택 가이드의 전용 벡터 저장소 선택지를 검토하면 된다.

결국 질문은 “어떤 벡터 데이터베이스를 사용할까?“가 아니다. “별도의 벡터 데이터베이스가 정말 필요한가?“다.