13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] 벡터 검색

  • [AI] 벡터 데이터베이스 비교: Qdrant vs ChromaDB 선택 가이드

    [AI] 벡터 데이터베이스 비교: Qdrant vs ChromaDB 선택 가이드

    [AI] 벡터 데이터베이스 비교: Qdrant vs ChromaDB 선택 가이드

    RAG를 붙일 때 많은 분이 처음엔 임베딩 품질이나 프롬프트부터 만지십니다. 그런데 실무에선 장애가 저장소 계층에서 먼저 터지는 경우가 꽤 많더라고요. 벡터 데이터베이스 비교를 대충 하고 들어가면, 나중에 검색 필터가 느려지거나 재기동 뒤 데이터가 비어 보이거나 팀이 늘면서 컬렉션 규칙이 꼬이기 시작합니다.

    이번 글은 기능 목록을 나열하는 비교가 아닙니다. Qdrant와 ChromaDB를 어디서 갈라 써야 덜 갈아엎는지, 그리고 어떤 실수에서 실제 비용이 커지는지를 중심으로 보겠습니다. 하루 만에 PoC를 띄우는 문제와 6개월 뒤 운영팀이 덜 고생하는 구조를 고르는 문제는 꽤 다르거든요.

    벡터 데이터베이스 비교를 위한 Qdrant와 ChromaDB 아키텍처 개요 이미지

    RAG 파이프라인에서 임베딩 생성기, 벡터 저장소, 검색 API가 어떻게 연결되는지 한눈에 보여주는 개요 이미지입니다.

    벡터 데이터베이스 비교에서 왜 Qdrant와 ChromaDB가 자주 같이 언급될까

    둘 다 임베딩을 저장하고 유사한 벡터를 찾는다는 점은 같습니다. 다만 실무에서 중요한 건 저장 방식보다 운영 계약입니다. 같은 검색 저장소라도 애플리케이션이 기대하는 규칙이 다르면, 나중에 갈아타기 비용이 확 커집니다.

    • Qdrant는 서버 중심 구조라 컬렉션, 거리 함수, 필터, 인덱스, 헬스체크 같은 운영 요소가 비교적 명시적입니다.
    • ChromaDB는 개발 속도를 우선하는 경험이 강합니다. 로컬 실험이나 Python 중심 워크플로에 바로 넣기 편합니다.
    • 둘 다 메타데이터를 함께 저장해 RAG에 연결하기 좋지만, 필터를 얼마나 진지하게 다뤄야 하는지에서 체감 차이가 크게 납니다.

    제가 실제로는 이렇게 나눕니다. 한 프로세스가 실험용으로 쓰는가, 여러 서비스가 동시에 접근하는가. 이 질문에 답하면 절반은 이미 정리됩니다.

    이 비교에서 먼저 봐야 할 핵심 포인트

    벡터 DB를 고를 때 성능 숫자부터 찾기 쉬운데, 초기에 더 큰 차이를 만드는 건 아래 네 가지였습니다.

    1. 접근 모델: 같은 프로세스 안에서 쓰는지, HTTP나 gRPC로 외부에서 붙는지
    2. 필터 비중: 유사도 검색만 하는지, tenant/team/date/service 필터가 늘 붙는지
    3. 스키마 통제: 컬렉션 규칙을 코드로 강제하는지, 팀 규약에 맡기는지
    4. 운영 책임: 백업, 재시작, health check, 접근 제어를 누가 챙길지

    이걸 무시하고 간단해 보여서 고르면 나중에 삽질 포인트가 비슷합니다. 검색 품질 문제가 아니라, 저장 규칙과 조회 규칙이 분리되어 있지 않아서 생기는 문제인 경우가 많거든요.

    Qdrant vs ChromaDB, 실무에서 체감되는 차이

    비교 항목 Qdrant ChromaDB
    기본 성격 서버형 벡터 검색 엔진에 가깝습니다. 로컬 개발과 빠른 실험에 강한 벡터 데이터베이스입니다.
    처음 붙이는 속도 컬렉션 규칙을 먼저 생각해야 해서 초반 인지 부하가 있습니다. Python에서 바로 컬렉션 만들고 넣어보기가 쉽습니다.
    멀티앱 접근 여러 서비스가 API로 붙는 구성이 자연스럽습니다. 가능하지만 운영 원칙을 애플리케이션 쪽에서 더 엄격히 잡아야 편합니다.
    필터 중심 검색 payload filter와 payload index 전략을 세우기 좋습니다. where 필터는 편하지만 운영 규칙까지 자동으로 대신해주진 않습니다.
    구성 명시성 거리 함수, 인덱스, 헬스체크, 보안 경계가 비교적 분명합니다. 개발 경험은 가볍지만 구조적 제약은 덜 강합니다.
    어울리는 시점 서비스화가 보이거나 필터가 핵심인 RAG 노트북 실험, 내부 도구 MVP, 데이터 흐름 검증
    피해야 할 경우 오늘 안에 실험값만 확인하면 되는 초경량 테스트 여러 팀이 동시에 붙고 운영 경계가 분명해야 하는 환경

    현업에서 자주 보는 오판도 비슷합니다. ChromaDB를 간단하니 일단 운영까지 가보자로 시작했다가, 나중에 운영 규칙을 보강하느라 구조 비용을 뒤늦게 치르는 경우가 많습니다. 반대로 처음부터 Qdrant를 넣고도 실제로는 단일 Python 작업 하나만 돌리면서 서버 운영 부담만 떠안는 경우도 있고요.

    핵심 개념은 정의보다 실패 모드로 이해하는 편이 빠릅니다

    임베딩 차원은 설정값이 아니라 저장 계약입니다

    초보 때는 임베딩 차원을 모델 속성 정도로 넘기기 쉽습니다. 그런데 운영에서는 거의 스키마 역할을 합니다. 384차원으로 만든 컬렉션에 768차원 질의를 보내면, 그 순간 문제는 검색 품질이 아니라 저장 계약 위반입니다.

    • 컬렉션 생성 시 차원을 고정합니다.
    • 임베딩 모델 교체는 새 컬렉션 생성으로 보는 편이 안전합니다.
    • 질의 임베딩과 적재 임베딩의 생성 경로를 분리해두면 언젠가 꼭 사고가 납니다.

    저는 보통 <code>embedding_model, embedding_dimension, distance_metric를 같은 설정 파일이나 환경 변수 묶음에서 읽게 합니다. 모델만 바꾸고 컬렉션은 그대로 쓰는 실수, 이거 생각보다 자주 나옵니다.

    필터는 부가 기능이 아니라 RAG 품질 제어 장치입니다

    RAG에서 벡터 검색 결과가 이상하다는 말의 절반은 임베딩 성능 문제가 아닙니다. 검색 범위를 제어하지 않은 채 서로 다른 문서군을 한 컬렉션에 섞어 넣은 경우가 많습니다. 예를 들어 운영 런북과 제품 FAQ를 같은 컬렉션에 넣고 service나 source 필터 없이 질의하면, 유사도 상위권에 엉뚱한 문서가 뜨는 게 오히려 자연스럽습니다.

    이럴 때 중요한 건 top-k 숫자를 바꾸는 게 아니라 후보 집합을 먼저 줄이는 것입니다. 이 관점에 익숙해지면 Qdrant의 payload index가 왜 자주 언급되는지 바로 감이 옵니다.

    실전 구현: 같은 데이터를 Qdrant와 ChromaDB에 넣어보면

    여기서는 4차원 더미 벡터를 쓰겠습니다. 숫자는 단순하지만 컬렉션 정의 방식과 필터 흐름 차이는 그대로 드러납니다. 실제 프로젝트에선 여기에 임베딩 모델과 청킹 전략만 얹으면 됩니다. RAG 청킹 전략이나 LLM 임베딩 선택 글과 함께 보면 더 입체적으로 보이실 거예요.

    1. Docker로 두 저장소를 분리해서 띄우기

    이 단계부터 운영 감각이 드러납니다. 테스트라도 데이터 디렉터리와 헬스체크를 분리해서 보는 편이 좋습니다. 나중에 검색이 이상할 때 애플리케이션 오류인지 저장소 상태 문제인지 빨리 분리할 수 있거든요.

    services:
      qdrant:
        image: qdrant/qdrant
        container_name: qdrant
        ports:
          - "6333:6333"
          - "6334:6334"
        volumes:
          - ./qdrant_storage:/qdrant/storage
        healthcheck:
          test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
          interval: 30s
          timeout: 10s
          retries: 3
    
      chroma:
        image: chromadb/chroma:1.5.9
        container_name: chroma
        environment:
          - IS_PERSISTENT=TRUE
        ports:
          - "8000:8000"
        volumes:
          - ./chroma_data:/data
        healthcheck:
          test: ["CMD", "curl", "-f", "http://localhost:8000/api/v2/heartbeat"]
          interval: 30s
          timeout: 10s
          retries: 3

    올릴 때는 이렇게 확인하시면 됩니다.

    docker compose up -d
    
    docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
    curl -f http://localhost:6333/healthz
    curl -f http://localhost:8000/api/v2/heartbeat

    여기서 제가 보는 포인트는 세 가지입니다.

    • 컨테이너가 Started인지보다 health check 통과 여부를 먼저 봅니다.
    • Qdrant는 6333이 REST, 6334가 gRPC라서 클라이언트 종류에 따라 포트를 명확히 나눠야 합니다.
    • ChromaDB는 Docker에서 /data 볼륨만 마운트한다고 끝이 아닙니다. 영속 모드 설정까지 확인해야 재기동 후 데이터가 남습니다.
    벡터 데이터베이스 비교 실습을 위한 Qdrant와 ChromaDB 홈랩 구성 이미지

    개발 PC 또는 홈랩 서버에서 두 컨테이너가 각각 다른 포트와 볼륨을 사용하는 모습을 보여주는 구성 이미지입니다.

    2. Qdrant는 컬렉션 규칙을 먼저 명시하는 편이 낫습니다

    Qdrant의 장점은 처음부터 규칙을 분명히 적게 만든다는 점입니다. 초반엔 조금 번거롭지만, 나중에 누가 봐도 이 컬렉션이 어떤 가정 위에 만들어졌는지가 남습니다. 운영 단계에 들어가면 이 차이가 꽤 크게 느껴집니다.

    from qdrant_client import QdrantClient, models
    
    client = QdrantClient(url="http://localhost:6333")
    
    client.create_collection(
        collection_name="docs",
        vectors_config=models.VectorParams(
            size=4,
            distance=models.Distance.COSINE,
        ),
    )
    
    client.create_payload_index(
        collection_name="docs",
        field_name="service",
        field_schema=models.PayloadSchemaType.KEYWORD,
    )
    
    client.upsert(
        collection_name="docs",
        wait=True,
        points=[
            models.PointStruct(
                id=1,
                vector=[0.12, 0.45, 0.33, 0.91],
                payload={"source": "runbook", "service": "nginx", "env": "prod"},
            ),
            models.PointStruct(
                id=2,
                vector=[0.10, 0.40, 0.30, 0.88],
                payload={"source": "wiki", "service": "kubernetes", "env": "dev"},
            ),
        ],
    )
    
    result = client.query_points(
        collection_name="docs",
        query=[0.11, 0.44, 0.31, 0.90],
        query_filter=models.Filter(
            must=[
                models.FieldCondition(
                    key="service",
                    match=models.MatchValue(value="nginx"),
                )
            ]
        ),
        with_payload=True,
        limit=2,
    )
    
    for point in result.points:
        print(point.id, point.score, point.payload)

    여기서 중요한 건 검색 API 호출 자체보다, 필터에 자주 쓸 필드를 별도로 인덱싱하는 사고방식입니다. Qdrant는 이 지점이 분명해서 service나 env 같은 운영 규칙을 코드로 남기기 좋습니다.

    3. ChromaDB는 시작 속도가 정말 빠릅니다

    ChromaDB의 강점은 여기서 딱 드러납니다. 코드가 가볍고 Python 워크플로 안에 자연스럽게 들어옵니다. 노트북에서 문서 검색 아이디어를 검증할 때는 이거 진짜 편하더라고요.

    다만 Docker로 서버를 띄웠다면 클라이언트도 그에 맞게 HttpClient로 붙이는 게 맞습니다. 로컬 디스크를 직접 쓰는 PersistentClient 예제와 서버 모드 예제를 섞으면 저장 위치를 헷갈리기 쉽습니다.

    import chromadb
    
    client = chromadb.HttpClient(host="localhost", port=8000)
    collection = client.get_or_create_collection(name="docs")
    
    collection.upsert(
        ids=["1", "2"],
        embeddings=[
            [0.12, 0.45, 0.33, 0.91],
            [0.10, 0.40, 0.30, 0.88],
        ],
        metadatas=[
            {"source": "runbook", "service": "nginx", "env": "prod"},
            {"source": "wiki", "service": "kubernetes", "env": "dev"},
        ],
        documents=[
            "Nginx timeout troubleshooting guide",
            "Kubernetes deployment checklist",
        ],
    )
    
    results = collection.query(
        query_embeddings=[[0.11, 0.44, 0.31, 0.90]],
        n_results=2,
        where={"service": "nginx"},
        include=["documents", "metadatas", "distances"],
    )
    
    print(results)

    제가 계속 강조하는 건 하나입니다. 개발이 쉽다는 것과 운영 계약이 강하다는 것은 다르다는 점입니다. ChromaDB는 빠르게 붙일 수 있지만, 팀이 커질수록 어떤 metadata를 반드시 넣어야 하는지, 컬렉션 이름 규칙은 뭔지, 서버 모드 접속은 어디까지 허용할지 같은 규칙을 애플리케이션 계층에서 더 엄격히 정해야 편해집니다.

    4. Qdrant는 필터가 늘어날수록 차이가 커집니다

    RAG를 운영하다 보면 질문 자체보다 조회 범위 제약이 더 중요해지는 순간이 옵니다. 예를 들어 테넌트별 격리, 운영/개발 문서 분리, 날짜 기반 문서 제한이 붙기 시작하면 벡터만 가까우면 된다는 가정이 금방 무너집니다.

    curl -X PUT 'http://localhost:6333/collections/docs' \
      -H 'Content-Type: application/json' \
      --data '{
        "vectors": {
          "size": 4,
          "distance": "Cosine"
        }
      }'
    
    curl -X PUT 'http://localhost:6333/collections/docs/index?wait=true' \
      -H 'Content-Type: application/json' \
      --data '{
        "field_name": "service",
        "field_schema": "keyword"
      }'
    
    curl -X POST 'http://localhost:6333/collections/docs/points/query' \
      -H 'Content-Type: application/json' \
      --data '{
        "query": [0.11, 0.44, 0.31, 0.90],
        "filter": {
          "must": [
            {
              "key": "service",
              "match": {"value": "nginx"}
            }
          ]
        },
        "with_payload": true,
        "limit": 2
      }'

    실무적으로는 이 차이가 꽤 큽니다. 단순 유사도 검색만 할 때는 둘 다 큰 불편 없이 갈 수 있지만, 필터가 검색 품질의 일부가 되는 순간 Qdrant 쪽이 구조를 유지하기 더 쉽습니다.

    Qdrant와 ChromaDB의 벡터 데이터베이스 비교 흐름도 이미지

    필터 조건이 붙은 검색 요청이 각각 어떤 경로로 처리되는지 비교하는 다이어그램입니다.

    성능보다 먼저 봐야 하는 결정 포인트

    판단 질문 Qdrant 쪽으로 기울 때 ChromaDB 쪽으로 기울 때
    누가 붙나요? 여러 앱, 여러 컨테이너, 별도 API 서버 Python 작업 하나, 내부 분석 스크립트, 개인 실험
    필터가 중요한가요? tenant, service, env, date 같은 조건이 자주 붙음 대부분 의미 기반 검색만 하고 필터는 단순함
    데이터 계약을 강하게 가져갈 건가요? 컬렉션 규칙과 인덱스 전략을 문서화하고 유지해야 함 빠르게 만들고 버려도 되는 실험 비중이 큼
    운영 장애를 누가 받나요? 서비스 운영팀, SRE, 플랫폼 팀이 관여함 개발자 본인이 로컬 혹은 소규모 앱을 직접 돌림
    마이그레이션 비용을 감수할 수 있나요? 초기부터 안정된 구조가 필요함 일단 검증 후 나중에 옮겨도 괜찮음

    추천하는 사고방식은 단순합니다. 지금 필요한 단순함과 나중에 치를 복잡도를 따로 계산하셔야 합니다. ChromaDB는 지금 당장 단순하고, Qdrant는 나중 복잡도를 미리 줄여줍니다.

    흔한 실패 모드와 근본 원인

    1. 임베딩 차원 불일치

    이건 단순 실수라기보다 설정 분리의 결과입니다. 문서 적재 코드와 질의 코드가 서로 다른 임베딩 모델 설정을 읽고 있으면 언젠가 반드시 터집니다. 특히 배치 적재 스크립트와 API 서버가 따로 배포될 때 자주 나옵니다.

    근본 원인은 하나입니다. 임베딩 모델 계약이 코드 여러 군데에 흩어져 있는 것입니다.

    1. 컬렉션 생성 시 차원과 거리 함수를 명시합니다.
    2. 애플리케이션 시작 시점에 임베딩 차원 검증을 넣습니다.
    3. 모델 교체는 기존 컬렉션 수정이 아니라 새 컬렉션 생성과 재색인으로 처리합니다.

    2. 컨테이너는 멀쩡한데 데이터가 안 남습니다

    이건 벡터 DB 문제처럼 보이지만, 실제로는 저장 경로나 영속 모드 설정 문제인 경우가 많습니다. 특히 팀원이 HttpClient 기반 서버 모드와 PersistentClient 기반 로컬 모드를 섞어 쓰면 어제 넣은 데이터가 왜 오늘 안 보이지 하는 상황이 바로 생깁니다.

    근본 원인은 저장 위치와 실행 모드가 실행 방식마다 다르다는 사실을 팀이 공유하지 않는 것입니다.

    docker inspect qdrant --format '{{json .Mounts}}'
    docker inspect chroma --format '{{json .Mounts}}'
    docker logs qdrant --tail 100
    docker logs chroma --tail 100

    이때 제가 보는 기준은 아래 세 가지입니다.

    • Mounts에 기대한 호스트 경로가 잡혀 있는지
    • 컨테이너 내부 경로가 Qdrant는 /qdrant/storage, Chroma는 /data로 맞는지
    • Chroma 서버가 영속 모드로 떠 있는지, 재기동 후 같은 디렉터리의 데이터가 유지되는지

    3. 필터가 없어서 검색 품질이 망가집니다

    운영 가이드를 예로 들어보겠습니다. service=nginx인 문서와 service=kubernetes인 문서를 한 컬렉션에 넣고, 사용자가 배포 후 응답 지연이라고 묻는 상황을 떠올려보세요. 메타데이터 필터가 없으면 벡터 상으로 비슷한 쿠버네티스 배포 체크리스트가 먼저 튈 수 있습니다. 이건 DB가 잘못한 게 아니라 검색 범위를 제어하지 않은 겁니다.

    근본 원인은 벡터 검색을 전체 문서군에 대한 만능 검색으로 오해하는 것입니다.

    • 출처가 다르면 source를 반드시 넣습니다.
    • 서비스 경계가 있으면 service나 tenant를 필수 메타데이터로 강제합니다.
    • 질문이 특정 문서군 전용이면 유사도 검색 전에 필터로 후보군을 줄입니다.

    4. 느린 건 벡터 연산보다 필터 설계인 경우가 많습니다

    특히 Qdrant에서는 필터에 자주 쓰는 payload field를 인덱싱하지 않은 채 왜 검색이 무겁지로 가는 경우가 꽤 많습니다. 반대로 모든 필드를 무작정 인덱싱하는 것도 메모리와 디스크 비용을 늘립니다. 결국 핵심은 많이 쓰는 필드가 아니라 결과 집합을 가장 강하게 줄이는 필드를 먼저 고르는 것입니다.

    예를 들어 color처럼 값 종류가 몇 개 안 되는 필드보다, tenant_id나 document_type처럼 검색 공간을 크게 좁히는 필드가 인덱스 우선순위가 높습니다. 이건 문서만 읽을 때보다 운영에 들어가면 더 강하게 체감됩니다.

    검증: 무엇을 보면 잘 붙었다고 판단할까

    저는 벡터 저장소를 붙이고 나면 벤치마크보다 먼저 아래 네 단계를 확인합니다. 이걸 통과하지 못하면 성능 수치는 큰 의미가 없습니다.

    1. 재조회 가능성: 넣은 id나 문서가 같은 컬렉션에서 다시 조회되는지
    2. 필터 정확성: service=nginx 같은 조건이 틀리지 않고 적용되는지
    3. 재기동 내구성: 컨테이너 재시작 후 데이터가 그대로 남는지
    4. 질의 일관성: 비슷한 표현을 바꿔도 같은 문서군이 안정적으로 상위에 오는지

    여기서 작은 재현 시나리오 하나를 권합니다. 운영 런북 5개, 개발 위키 5개를 넣고 아래처럼 질의를 세 번 바꿔보세요.

    • nginx timeout 원인
    • reverse proxy 지연
    • 응답이 늦을 때 점검 순서

    세 질의 모두 nginx 런북 군집 안에서 놀아야 정상입니다. 결과가 매번 다른 문서군으로 튄다면 저장소 자체보다도 청킹, 메타데이터 설계, 필터 적용 순서를 먼저 의심하셔야 합니다.

    벡터 데이터베이스 비교 검증을 위한 검색 결과 대시보드 이미지

    질의별 상위 결과, score, source/service 메타데이터를 함께 검토하는 검증 화면을 묘사한 이미지입니다.

    언제 Qdrant를 고르고, 언제 ChromaDB를 고를까

    여기서는 애매하게 말하지 않겠습니다.

    • Qdrant를 고르세요: 여러 애플리케이션이 같은 저장소를 공유한다, 메타데이터 필터가 검색 품질의 핵심이다, 운영 환경에서 health check와 접근 경계를 분명히 해야 한다, 컬렉션 규칙을 코드와 설정으로 남기고 싶다.
    • ChromaDB를 고르세요: Python 중심으로 빠르게 검증해야 한다, 혼자 혹은 작은 팀이 로컬/내부 도구를 먼저 만들어야 한다, 구조보다 속도가 우선이다, 나중에 서버형 구조로 옮길 가능성을 감수할 수 있다.

    현실적인 흐름도 있습니다.

    1. 아이디어 검증은 ChromaDB로 짧게 가져갑니다.
    2. 필터와 멀티서비스 요구가 보이면 Qdrant 이전 비용을 계산합니다.
    3. 처음부터 운영형 RAG라면 굳이 돌아가지 말고 바로 Qdrant로 갑니다.

    반대로 말하면 이렇습니다. ChromaDB를 오래 운영하려면 애플리케이션이 질서를 대신 만들어야 하고, Qdrant를 가볍게 쓰려면 서버 운영 부담을 감수해야 합니다. 둘 중 어느 비용을 지금 낼지 정하는 문제입니다.

    자주 묻는 질문

    ChromaDB도 서버처럼 쓸 수 있나요?

    가능합니다. 공식 문서 기준으로 Docker 컨테이너와 HttpClient 조합이 지원됩니다. 다만 서버 모드로 가는 순간부터는 편한 로컬 도구가 아니라 운영 대상이 되니, 접근 제어와 저장 경로, 영속 모드까지 같이 챙기셔야 합니다.

    Qdrant는 너무 무거운 선택 아닌가요?

    단일 프로세스 실험만 할 거라면 과할 수 있습니다. 하지만 필터가 중요한 RAG, 여러 서비스가 붙는 구조, 운영팀이 관여하는 환경에서는 초반에 규칙을 명시해두는 편이 오히려 나중 비용을 줄여줍니다.

    둘 다 알아야 하나요?

    짧게라도 둘 다 만져보시는 편이 좋습니다. 같은 데이터셋으로 두 저장소를 각각 붙여보면 내 프로젝트가 진짜 원하는 게 개발 속도인지, 운영 계약인지 감이 꽤 빨리 옵니다.

    벡터 데이터베이스 비교와 선택 기준을 정리한 Qdrant 대 ChromaDB 인포그래픽

    프로토타입, 사내 도구, 운영 서비스, 복잡한 필터링 등 상황별 추천 선택지를 요약한 이미지입니다.

    마무리

    벡터 데이터베이스 비교에서 중요한 건 누가 더 유명한지가 아니라, 내 검색 시스템이 어디서 깨질 가능성이 높은가입니다. 혼자 빠르게 RAG를 검증하는 단계라면 ChromaDB가 훨씬 민첩합니다. 반대로 필터와 운영이 본격적으로 중요해지는 순간부터는 Qdrant가 구조를 잡기 수월합니다.

    제 판단을 한 줄로 압축하면 이렇습니다. 오늘 바로 실험해야 하면 ChromaDB, 내일 운영팀이 붙을 게 보이면 Qdrant. 이 기준으로 가시면 초반 속도와 이후 유지비 사이에서 덜 후회할 가능성이 큽니다.

  • [AI] LlamaIndex RAG 시스템 구축 실패 사례: 흔한 문제와 디버깅 전략

    [AI] LlamaIndex RAG 시스템 구축 실패 사례: 흔한 문제와 디버깅 전략

    [AI/LLM] LlamaIndex RAG 시스템 구축 실패 사례: 흔한 문제와 디버깅 전략

    LlamaIndex RAG 시스템을 처음 붙일 때 많은 분들이 비슷한 지점에서 막히시더라고요. 문서는 잘 넣은 것 같은데 답이 엉뚱하게 나오고, 로그를 봐도 어디서 망가졌는지 감이 안 오고, 심지어 AI 에이전트(Agent) 문제인 줄 알았는데 알고 보니 검색 단계가 이미 틀어져 있던 경우도 많습니다. 저도 홈랩에서 이것저것 붙여 보면서 삽질 좀 했습니다 ㅎㅎ 처음엔 모델 탓인가 싶었는데, 실제로는 데이터 적재(Ingestion, 문서 수집 및 변환), 청킹(Chunking, 문서 분할), 검색(Retrieval, 관련 문맥 찾기), 응답 합성(Response Synthesis, 답변 생성) 중 하나가 어긋난 경우가 대부분이었습니다.

    이번 글은 제품 홍보나 멋진 데모가 아니라, LlamaIndex RAG를 구성하다가 망했던 패턴을 정리한 실패 사례 중심 글입니다. 특히 RAG 시스템 문제, LlamaIndex 오류, RAG 디버깅, AI 에이전트 실패를 한 번에 점검할 수 있는 흐름으로 정리해 보겠습니다. 혹시 “문서는 넣었는데 왜 이렇게 못 찾지?” 같은 경험 있으신가요? 그거 생각보다 정상입니다. 중요한 건 감으로 고치는 게 아니라, 단계별로 끊어서 확인하는 겁니다.

    LlamaIndex RAG 전체 아키텍처를 보여주는 홈랩 다이어그램

    문서 수집부터 청킹, 벡터 저장, 검색, 응답 생성까지 흐름을 한눈에 보는 개요 이미지입니다.

    LlamaIndex RAG를 쉽게 말하면 어디서 망가질까요?

    쉽게 말해 RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 “모델이 원래 알고 있는 것”에만 기대지 않고, 질문이 들어오면 관련 문서를 먼저 찾아서 그 문맥을 바탕으로 답하게 만드는 구조입니다. LlamaIndex는 이 과정을 구성하기 쉽게 만들어 주는 프레임워크 쪽에 가깝고요.

    제가 직접 해보니, 실패 지점은 생각보다 단순했습니다.

    • 문서 적재 단계: 파일은 읽었는데 내용이 이상하게 잘렸습니다.
    • 인덱싱(Indexing, 검색용 구조화): 벡터 저장은 됐는데 실제 검색 품질이 낮았습니다.
    • 리트리버(Retriever, 관련 문맥 검색기): 질문과 맞는 조각을 못 가져왔습니다.
    • 응답 합성: 검색은 맞았는데 모델이 답변을 엉성하게 만들었습니다.
    • 영속화(Persistence, 디스크 저장): 재시작 후 이전 상태가 사라지거나 오래된 인덱스를 계속 읽었습니다.

    여기서 중요한 포인트! LlamaIndex RAG는 한 덩어리처럼 보이지만, 실제 디버깅은 단계별 분리 진단이 핵심입니다. 검색이 틀렸는데 프롬프트만 계속 고치면 시간만 날립니다.

    실패를 줄이는 기본 설계: 먼저 관측 가능한 구조로 만드세요

    저는 예전엔 일단 돌아가게만 만들고 나중에 로그를 붙였었는데요, 그 방식은 RAG에서 진짜 비효율적이더라고요. 처음부터 “문서가 어떻게 잘렸는지”, “질문에 대해 어떤 노드(Node, 검색 가능한 문서 조각)가 선택됐는지”, “최종 답변이 어떤 근거를 썼는지”가 보여야 합니다.

    구간 자주 보이는 증상 실제 원인 후보 우선 확인할 것
    문서 로딩 파일은 읽히는데 답변이 비어 있음 본문 추출 실패, 인코딩 문제, 예상과 다른 폴더 로드된 문서 수와 본문 샘플
    청킹 검색 결과가 너무 짧거나 문맥이 끊김 chunk_size 과소, overlap 부족 분할된 노드 샘플
    검색 관련 없는 문서가 자주 나옴 질문-문서 표현 불일치, 메타데이터 필터 오류 retriever 결과 목록
    응답 생성 찾아놓고도 엉뚱한 답변 프롬프트 제약, 컨텍스트 과다/과소 source nodes와 최종 응답 비교
    저장/재로딩 재실행 후 결과가 달라짐 인메모리 상태 의존, persist 누락 저장 경로와 로딩 경로

    LlamaIndex RAG 실전 구현: 최소 구성부터 시작하기

    처음부터 벡터 DB(Vector Database, 벡터 저장소)까지 크게 벌이지 말고, 최소한의 로컬 흐름으로 시작하는 걸 추천드립니다. 이유는 간단합니다. 실패 지점을 줄여야 하거든요.

    1. 문서를 읽는다.
    2. 인덱스를 만든다.
    3. 리트리버와 쿼리 엔진을 분리해서 테스트한다.
    4. 결과가 괜찮으면 그다음에 영속화와 외부 저장소를 붙인다.

    1. 설치와 기본 실행

    python -m venv .venv
    source .venv/bin/activate
    pip install llama-index
    

    여기서는 버전 핀을 일부러 박지 않았습니다. 글의 목적이 특정 릴리스 사용기가 아니라, 디버깅 흐름 자체에 있기 때문입니다.

    2. 가장 단순한 인덱스 생성

    from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
    
    # data/ 폴더 아래 문서를 읽습니다.
    documents = SimpleDirectoryReader("data").load_data()
    
    # 가장 단순한 형태의 인덱스를 만듭니다.
    index = VectorStoreIndex.from_documents(documents)
    
    # 검색과 응답 생성을 분리해서 확인할 수 있도록 둘 다 만듭니다.
    retriever = index.as_retriever()
    query_engine = index.as_query_engine()
    
    question = "장애 대응 절차를 요약해줘"
    retrieved_nodes = retriever.retrieve(question)
    response = query_engine.query(question)
    
    print("[RETRIEVED NODES]")
    for i, node in enumerate(retrieved_nodes, start=1):
        print(f"--- node {i} ---")
        print(node.text[:300])
    
    print("[FINAL RESPONSE]")
    print(response)
    

    이 코드에서 핵심은 retriever와 query_engine을 따로 본다는 점입니다. 처음엔 이게 뭔가 싶었는데, 실제로 써보니까 이 분리가 디버깅 시간을 확 줄여주더라고요.

    3. 청킹을 직접 통제하는 적재 파이프라인

    from llama_index.core import Document
    from llama_index.core.ingestion import IngestionPipeline
    from llama_index.core.node_parser import SentenceSplitter
    from llama_index.core.extractors import TitleExtractor
    
    sample_docs = [
        Document(text="장애 조치 문서 예시입니다. 원인 분석, 임시 조치, 영구 조치가 포함됩니다."),
    ]
    
    pipeline = IngestionPipeline(
        transformations=[
            SentenceSplitter(chunk_size=256, chunk_overlap=32),
            TitleExtractor(),
        ]
    )
    
    nodes = pipeline.run(documents=sample_docs)
    
    for i, node in enumerate(nodes, start=1):
        print(f"NODE {i}")
        print(node.text)
        print(node.metadata)
    

    청킹은 진짜 중요합니다. 저는 처음에 chunk_size를 너무 작게 잡아서 문서 문맥이 다 잘려 나갔었는데, 검색 결과는 그럴듯하게 보여도 실제 답변은 자꾸 뜬구름 잡더라고요.

    LlamaIndex RAG 문서 청킹과 리트리버 동작을 설명하는 이미지

    문서가 여러 조각으로 분할되고 질문에 따라 관련 노드가 선택되는 구조를 설명하는 이미지입니다.

    4. 캐시와 저장 경로를 명시하기

    from llama_index.core import StorageContext, load_index_from_storage
    
    # 인덱스를 저장합니다.
    index.storage_context.persist(persist_dir="./storage")
    
    # 저장된 인덱스를 다시 로드합니다.
    storage_context = StorageContext.from_defaults(persist_dir="./storage")
    loaded_index = load_index_from_storage(storage_context)
    
    loaded_query_engine = loaded_index.as_query_engine()
    print(loaded_query_engine.query("장애 대응 절차를 요약해줘"))
    

    여기서 많이 터집니다. LlamaIndex는 기본적으로 인메모리(in-memory, 메모리 상주) 상태를 많이 쓰기 때문에, 재시작 후에도 같은 동작을 기대한다면 저장과 로딩 경로를 명확히 관리해야 합니다.

    ⚠️ 흔한 LlamaIndex 오류와 RAG 디버깅 전략

    이제부터는 제가 실제로 자주 밟았던 실패 패턴입니다. 증상만 보면 비슷한데 원인은 꽤 다릅니다.

    문제 1. 문서는 읽었는데 답변이 너무 일반적입니다

    이 경우 많은 분이 모델 성능부터 의심하시는데요, 사실은 검색 단계에서 충분한 근거가 안 들어간 경우가 많습니다.

    • 문서가 너무 잘게 쪼개져 핵심 문맥이 끊겼는지 봅니다.
    • 질문과 문서 표현이 다르면 검색 품질이 확 떨어집니다.
    • 리트리버가 뽑은 노드 본문을 직접 읽어 봅니다.

    제가 직접 해보니, 최종 답변보다 retrieved_nodes 출력을 먼저 보는 습관이 정말 중요했습니다. 답이 이상하면 먼저 “뭘 찾았는가”를 봐야 합니다.

    문제 2. 재색인했는데 결과가 안 바뀝니다

    이건 캐시나 저장 디렉터리 때문에 생기는 경우가 많습니다. 특히 테스트하면서 같은 경로를 계속 재사용하면 오래된 데이터가 남아 있을 수 있습니다.

    rm -rf ./storage
    rm -rf ./pipeline_storage
    

    물론 운영 환경에서는 이렇게 단순 삭제하면 안 됩니다. 다만 로컬 검증 단계에서는 “정말 새로 인덱싱된 게 맞나?”를 확인하는 용도로 한 번쯤 초기화가 필요하더라고요.

    문제 3. 검색은 맞는데 최종 답변이 엉뚱합니다

    이건 응답 합성 단계 문제일 가능성이 높습니다. 쉽게 말해 모델이 가져온 컨텍스트를 잘 못 쓰는 거죠.

    • 너무 많은 컨텍스트를 넣어 핵심이 묻히지 않았는지 봅니다.
    • 프롬프트에서 근거 기반 답변을 강하게 요구하는지 봅니다.
    • 답변과 함께 source nodes를 출력해 비교합니다.

    근데 여기서 중요한 포인트가 하나 있습니다. 검색 품질이 80인데 생성 품질이 20이면 프롬프트 손봐야 하고, 검색 품질이 20인데 생성만 만지면 계속 헛바퀴 돕니다.

    문제 4. AI 에이전트 실패처럼 보이는데 사실 RAG 실패입니다

    도구 호출(Tool Calling, 외부 기능 호출)이나 에이전트 라우팅이 문제인 줄 알았는데, 막상 까보면 검색된 문맥이 비어 있는 경우가 있습니다. 저도 처음엔 에이전트 흐름도를 한참 봤었는데요, 결국 원인은 리트리버였습니다. 그래서 저는 에이전트를 붙이기 전에 항상 아래 순서로 검증합니다.

    1. retriever.retrieve() 결과를 먼저 확인
    2. query_engine.query() 결과와 비교
    3. 그 다음에만 agent/tool 레이어 확인

    문제 5. 운영 환경에서만 이상합니다

    개발 환경에서는 한글 문서가 잘 검색됐는데 운영에서만 품질이 달라지는 경우도 있었습니다. 이런 건 대개 데이터셋 차이, 적재 타이밍 차이, 저장소 경로 차이, 혹은 문서 전처리 차이로 이어집니다. 이럴 때는 “운영 모델이 다르다” 같은 큰 가설보다, 실제로 어떤 문서가 들어갔는지를 먼저 비교하는 게 낫습니다.

    검증 방법: 정답률보다 먼저 파이프라인을 눈으로 확인하세요

    저는 RAG를 검증할 때 처음부터 화려한 평가 지표를 붙이지 않습니다. 물론 평가(Evaluation, 성능 측정)는 중요하지만, 초기에는 육안 검증이 훨씬 빠릅니다.

    1. 질문 10개를 만든다.
    2. 각 질문마다 검색된 노드 상위 결과를 저장한다.
    3. 최종 답변과 근거 문장을 같이 본다.
    4. 틀린 답변은 검색 실패인지 생성 실패인지 분류한다.
    test_questions = [
        "장애 보고 절차는 어떻게 되나요?",
        "임시 조치와 영구 조치의 차이는 무엇인가요?",
        "점검 창구는 어디인가요?",
    ]
    
    for question in test_questions:
        nodes = retriever.retrieve(question)
        answer = query_engine.query(question)
    
        print("=" * 40)
        print("Q:", question)
        print("[TOP NODES]")
        for node in nodes[:3]:
            print(node.text[:200])
        print("[ANSWER]")
        print(answer)
    

    실제로 써보니까, 이 정도만 해도 어디가 문제인지 금방 보입니다. 드디어 됐다! 싶은 순간도 보통 여기서 오더라고요.

    LlamaIndex RAG 검증 과정에서 검색 노드와 답변을 비교하는 대시보드 이미지

    질문, 검색된 문서 조각, 최종 응답을 나란히 놓고 비교하는 검증 화면 예시입니다.

    실패를 줄이는 운영 체크리스트

    LlamaIndex RAG를 조금 오래 운영하다 보면, 결국 반복 확인 항목이 정해집니다. 저는 아래 체크리스트를 배포 전 마지막 관문처럼 씁니다.

    • 문서 수와 예상 파일 수가 맞는가
    • 청크 샘플을 3개 이상 직접 읽어 봤는가
    • 검색 결과 상위 노드가 질문 의도와 맞는가
    • 재시작 후에도 같은 인덱스를 로드하는가
    • 캐시와 저장 디렉터리 충돌이 없는가
    • 에이전트 문제로 보기 전에 검색 결과를 확인했는가
    실패 유형 대응 우선순위 빠른 처방
    답변이 너무 일반적 높음 청킹과 검색 결과부터 확인
    재색인 후 결과 동일 높음 persist 경로와 캐시 정리 확인
    운영에서만 품질 저하 중간 실제 적재 문서와 경로 비교
    AI 에이전트 실패처럼 보임 높음 agent 이전에 retriever 단독 테스트

    자주 묻는 질문: RAG 시스템 문제를 어디서부터 봐야 하나요?

    Q1. LlamaIndex 오류가 나지 않아도 시스템이 틀릴 수 있나요?

    네, 그게 더 흔합니다. 에러 없이도 잘못된 문맥을 검색하면 결과는 충분히 틀릴 수 있습니다. 그래서 RAG 디버깅은 예외 메시지보다 중간 산출물 확인이 더 중요합니다.

    Q2. 청킹만 잘하면 해결되나요?

    아닙니다. 청킹은 출발점일 뿐이고, 메타데이터, 저장 경로, 검색기 설정, 응답 합성까지 같이 봐야 합니다.

    Q3. AI 에이전트 실패와 RAG 실패는 어떻게 구분하나요?

    에이전트를 떼고 retriever와 query_engine을 각각 테스트해 보시면 됩니다. 에이전트를 제거했는데도 답이 틀리면 대개 RAG 쪽입니다.

    마무리: 화려한 튜닝보다 관측 가능한 구조가 먼저입니다

    이번 글에서 말씀드리고 싶었던 건 딱 하나입니다. LlamaIndex RAG는 잘 만들면 강력하지만, 실패했을 때는 “어디서 틀어졌는지 보이게” 설계해야 한다는 점입니다. 저도 처음엔 모델만 계속 바꿔 봤었는데, 결국 해결은 대부분 로그, 노드 출력, 저장 경로 확인에서 나왔습니다. 사실 이런 건 멋이 없거든요. 근데 운영에서는 이런 기본기가 제일 셉니다.

    다음 글에서는 검색 품질이 낮을 때 메타데이터 필터링과 질문 정규화 전략을 어떻게 붙이는지 다뤄볼 예정입니다. 이전 글 참고 형식으로 이어서 보시면 흐름 잡기 편하실 겁니다.

    LlamaIndex RAG 디버깅 체크리스트와 실패 유형 요약 이미지

    실패 유형별 점검 순서와 대응 우선순위를 한 장으로 정리한 요약 이미지입니다.

    참고한 공식 문서