Featured Post

AI 바이브 코딩으로 Qdrant 벡터DB 붙여본 이야기: 20년 개발자가 겪은 시행착오와 실전 팁

Qdrant - 벡터DB 관련 이미지

요즘 개발자들끼리 만나면 이상하게 꼭 한 번은 나오는 얘기가 있어요. 바로 AI 바이브 코딩입니다. 예전에는 개발자가 혼자 문서 뒤지고, Stack Overflow 뒤지고, 밤새 로그 보면서 버티는 게 거의 당연한 분위기였잖아요. 그런데 이제는 Cursor나 GitHub Copilot 같은 도구를 옆에 켜두고, AI랑 대화하듯이 코드를 만들어가는 흐름이 꽤 자연스러워졌습니다.

저도 처음엔 좀 삐딱하게 봤어요. “아니, 개발을 바이브로 한다고? 그게 말이 되나?” 싶었거든요. 20년 넘게 개발하면서 몸에 밴 습관이라는 게 있잖아요. 직접 설계하고, 직접 부딪히고, 직접 깨져봐야 내 코드가 된다는 생각이요. 그런데 막상 써보니까, 이게 또 묘하게 손에 붙더라고요. 특히 최근에 Qdrant라는 벡터 데이터베이스를 프로젝트에 붙이면서 AI 코딩 도우미를 꽤 적극적으로 활용해봤는데, 솔직히 생산성은 확실히 올라갔습니다. 다만 편하다고 해서 믿고 맡기면 안 되는 부분도 아주 선명하게 보였고요.

그래서 오늘은 Qdrant가 뭔지 교과서처럼 설명하기보다는, 제가 실제로 Qdrant 벡터DB를 도입하면서 겪었던 삽질과 수정 과정, 그리고 AI 바이브 코딩을 할 때 개발자가 꼭 붙잡고 있어야 할 기준 같은 걸 편하게 풀어보려고 합니다. 기본적인 Python, FastAPI 경험이 있는 분이라면 더 잘 와닿을 거예요.

Qdrant를 고른 이유, 막상 써보니 꽤 현실적이더라고요

벡터DB 쪽은 요즘 선택지가 참 많습니다. Pinecone, Weaviate, Milvus, Chroma 같은 이름들 한 번쯤 들어보셨을 거예요. 저도 처음에는 이것저것 비교해봤습니다. 괜히 새 기술 고를 때는 여행지 숙소 고르는 것처럼 리뷰도 보고, 가격도 보고, 괜히 깃허브 스타 수도 보고 그러잖아요.

그중에서 제가 Qdrant를 고른 이유는 꽤 단순했어요. 성능이 괜찮다는 평이 많았고, Rust 기반이라 안정감도 있었고, 무엇보다 로컬에서 쉽게 띄워서 테스트할 수 있다는 점이 마음에 들었습니다. 저는 새로운 인프라 컴포넌트를 붙일 때, 일단 내 노트북에서 편하게 망가뜨려볼 수 있어야 한다고 생각하거든요. 그래야 진짜 감이 옵니다.

또 하나 좋았던 건, Qdrant가 벡터 검색과 payload 필터링을 같이 쓰는 방식이 비교적 직관적이었다는 점이에요. RAG를 만들다 보면 단순히 “의미가 비슷한 문서”만 찾는 게 아니라, “특정 프로젝트에 속한 문서 중에서 의미가 비슷한 문서”를 찾아야 할 때가 많습니다. 이때 필터 조건을 자연스럽게 걸 수 있는지가 꽤 중요하더라고요.

검토했던 기준 Qdrant를 선택한 이유
로컬 테스트 Docker로 금방 띄울 수 있고, 개발 환경에서 부담 없이 실험하기 좋았습니다.
검색 성능 HNSW 기반 벡터 검색 성능이 준수했고, 튜닝할 여지도 충분했습니다.
필터링 payload 기반 필터 검색 구조가 이해하기 쉬웠습니다. 다만 인덱스 설정은 직접 잘 챙겨야 합니다.
운영 감각 브라우저에서 확인할 수 있는 dashboard가 있어서 개발 중 상태 확인이 편했습니다.

여기서 AI 바이브 코딩이 꽤 도움이 됐습니다. 예를 들어 Cursor에 이렇게 던져봤어요. “Qdrant에 문장 100개를 임베딩해서 저장하고, FastAPI로 유사도 검색하는 예제를 만들어줘.” 그러면 정말 몇 초 만에 그럴듯한 코드가 나옵니다. 오, 편하죠. 그런데 문제는 그 코드가 바로 실무 코드가 되지는 않는다는 겁니다. 뼈대는 좋은데, 디테일이 비어 있어요. 그리고 그 비어 있는 디테일이 실제 운영에서는 꽤 크게 터집니다.

RAG 파이프라인을 만들면서 AI 코드가 어디서 삐끗했는지 봤습니다

제가 테스트한 구조는 아주 전형적인 RAG였습니다. 회사 내부 문서 50페이지 정도를 적당한 크기로 나누고, 각 청크를 임베딩한 뒤 Qdrant에 저장합니다. 사용자가 질문을 던지면 질문도 임베딩하고, Qdrant에서 관련도 높은 문서 조각 3개 정도를 가져와 GPT에 컨텍스트로 넘기는 방식이었어요.

말로 하면 간단합니다. 그런데 막상 구현하면 자잘한 결정들이 계속 나와요. 청크 사이즈를 얼마로 할지, metadata를 어떻게 넣을지, 문서 출처를 어떤 필드명으로 저장할지, 필터를 어떻게 걸지, 중복 저장은 어떻게 막을지. 이런 건 AI가 알아서 예쁘게 판단해주지 않습니다. 물어보면 코드는 주는데, 그 코드가 내 데이터 구조와 운영 조건을 알고 짜준 건 아니거든요.

컬렉션 만들 때부터 차원이 어긋났습니다

처음 AI가 만들어준 Qdrant 컬렉션 생성 코드는 이런 식이었습니다.

from qdrant_client import QdrantClient
from qdrant_client.http.models import VectorParams, Distance

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

client.create_collection(
    collection_name="my_docs",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)

처음 보면 멀쩡해 보입니다. 저도 잠깐은 그냥 넘어갈 뻔했어요. 그런데 여기서 바로 문제가 생깁니다. 제가 쓰던 임베딩 모델은 OpenAI의 text-embedding-3-small이었고, 이 모델의 출력 차원은 1536입니다. 그런데 AI가 아무렇지도 않게 768로 넣어버린 거죠.

이런 일이 은근히 자주 생깁니다. AI는 예전에 많이 쓰이던 모델 기준이나 흔한 샘플 코드 기준으로 값을 넣어버릴 때가 있어요. 그러니까 “그럴듯한 코드”와 “내 프로젝트에서 돌아가는 코드” 사이에는 항상 간격이 있습니다. 이 간격을 메우는 게 개발자의 몫이고요.

제가 고친 코드는 이런 형태였습니다.

from qdrant_client import QdrantClient
from qdrant_client.http.models import VectorParams, Distance, PayloadFieldType

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

client.recreate_collection(
    collection_name="my_docs",
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

client.create_payload_index(
    collection_name="my_docs",
    field_name="source_file",
    field_type=PayloadFieldType.KEYWORD,
)

client.create_payload_index(
    collection_name="my_docs",
    field_name="project_name",
    field_type=PayloadFieldType.KEYWORD,
)

여기서 제가 꼭 넣은 게 payload 인덱스입니다. 이거 정말 중요해요. Qdrant에서 payload 필드로 필터링을 자주 할 거라면, 컬렉션 만든 직후에 인덱스를 잡아두는 게 좋습니다. 데이터가 몇백 건일 때는 별 차이 없어 보이는데, 몇만 건 넘어가면 체감이 달라집니다. 검색이 갑자기 굼떠지는 순간이 오거든요.

솔직히 이 부분은 AI가 잘 안 챙겨줬습니다. “Qdrant 필터 검색 최적화까지 고려해서 작성해줘”라고 구체적으로 말하면 넣어주기도 하는데, 그냥 “예제 코드 만들어줘” 정도로는 대부분 빠졌어요. 그래서 저는 이제 Qdrant 컬렉션 만들 때마다 체크리스트처럼 봅니다. 벡터 차원 맞나? distance 맞나? payload 인덱스 만들었나? 이 세 가지요.

    • 임베딩 모델의 출력 차원을 컬렉션 생성 전에 확인하기
    • 검색 기준에 따라 Distance.COSINE, Distance.DOT, Distance.EUCLID 중 하나를 명확히 고르기
    • 자주 필터링할 payload 필드는 미리 create_payload_index로 인덱싱하기
    • 로컬 테스트와 운영 환경의 컬렉션 설정이 달라지지 않도록 스크립트로 관리하기

검색 쿼리는 더 조심해야 했습니다

RAG에서 제일 중요한 건 결국 검색입니다. 사용자가 질문을 했을 때, 엉뚱한 문서를 가져오면 뒤에서 아무리 좋은 LLM을 붙여도 답변이 흔들립니다. 저는 이걸 몇 번 겪고 나서야 “아, RAG는 생성보다 검색이 먼저구나” 하고 다시 느꼈어요.

AI가 처음 만들어준 검색 코드는 아주 간단했습니다.

search_result = client.search(
    collection_name="my_docs",
    query_vector=query_embedding,
    limit=3
)

예제 코드로는 나쁘지 않습니다. 전체 문서에서 의미적으로 가까운 걸 찾아주니까요. 그런데 실무에서는 이걸 그대로 쓰기 어렵습니다. 예를 들어 사용자가 “2024년 프로젝트 예산은 얼마인가요?”라고 물었다고 해볼게요. 회사 내부 문서에는 여러 프로젝트 예산 자료가 섞여 있습니다. 이때 특정 프로젝트, 예를 들면 “슈퍼앱 프로젝트” 문서 안에서만 검색해야 답이 정확해집니다.

그래서 필터를 넣어야 했습니다.

from qdrant_client.http.models import Filter, FieldCondition, MatchValue

search_result = client.search(
    collection_name="my_docs",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(
                key="project_name",
                match=MatchValue(value="슈퍼앱 프로젝트")
            )
        ]
    ),
    limit=3
)

별거 아닌 것처럼 보이죠. 그런데 여기서도 삽질 포인트가 있습니다. payload에 저장된 값의 타입MatchValue의 타입이 맞아야 합니다. payload에는 숫자로 들어가 있는데 문자열로 검색한다든지, 반대로 문자열로 저장해놓고 숫자로 필터링하면 결과가 안 나옵니다. 에러가 친절하게 나는 것도 아니고, 그냥 검색 결과가 비어서 나오니 더 헷갈려요.

저는 그래서 payload schema를 따로 정리해뒀습니다. 거창한 문서까진 아니고, 프로젝트 안에 간단한 표 하나 만들어둔 정도예요.

payload 필드 타입 용도
source_file 문자열 원본 문서 파일명 추적
project_name 문자열 프로젝트별 검색 범위 제한
year 숫자 연도 기준 필터링
chunk_index 숫자 문서 내 청크 순서 복원

이런 작은 정리가 나중에 시간을 많이 아껴줍니다. 특히 AI한테 코드를 다시 고쳐달라고 할 때도 “payload schema는 이렇다”라고 알려주면 훨씬 덜 엉뚱한 코드를 내놓습니다. AI도 맥락을 줘야 일을 잘해요. 사람도 마찬가지고요.

AI가 잘 만들어주는 코드와 개발자가 꼭 봐야 하는 코드가 따로 있었습니다

이번에 Qdrant를 붙이면서 느낀 게 있습니다. AI 코딩 도우미는 시작점을 만드는 데 정말 좋습니다. 빈 파일 앞에서 막막할 때, 기본 구조를 잡아주는 능력은 이제 무시하기 어렵습니다. FastAPI 라우터 만들고, 요청 모델 만들고, Qdrant client 연결하고, 간단한 검색 함수 만드는 정도는 꽤 잘합니다.

그런데 운영에 가까워질수록 AI 코드는 반드시 의심하면서 봐야 합니다. 특히 데이터베이스, 검색 인덱스, 재시도 로직, 장애 처리, 동시성 같은 영역은요. 이건 그냥 제 편견이 아니라, 실제로 여러 번 당해보니 생긴 감각입니다.

AI가 자주 놓친 부분 제가 실제로 적용한 방식
임베딩 차원 불일치 컬렉션 생성 전에 사용하는 임베딩 모델의 출력 차원을 직접 확인했습니다. AI가 768을 넣어도 실제 모델이 1536이면 바로 터집니다.
payload 인덱스 누락 자주 필터링하는 필드는 컬렉션 생성 직후 create_payload_index로 따로 잡았습니다. 이건 습관처럼 넣는 게 좋습니다.
재시도 로직 없음 일시적인 503이나 네트워크 문제를 고려해서 tenacity 같은 라이브러리로 재시도 처리를 감쌌습니다.
동시성 처리 부족 대량 upsert 시 wait=True를 명시하고, 배치 크기를 조절했습니다. 무작정 병렬로 밀어 넣으면 오히려 불안정해졌습니다.
운영 확인 도구 미활용 Qdrant dashboard를 켜두고 데이터가 실제로 들어갔는지, payload가 의도대로 저장됐는지 눈으로 확인했습니다.

Qdrant dashboard는 생각보다 자주 보게 됩니다. 로컬에서 띄웠다면 보통 http://localhost:6333/dashboard로 접근할 수 있어요. 저는 개발할 때 이 화면을 거의 모니터 한쪽에 켜놨습니다. AI가 만들어준 코드로 데이터 넣고, dashboard에서 바로 확인하고, payload 이름 틀렸으면 다시 고치고. 이런 식으로요.

텍스트 로그만 보는 것보다 실제 컬렉션 상태를 눈으로 확인하면 훨씬 빨리 감이 옵니다. 뭐랄까, 지도 앱만 보고 여행하는 것보다 골목에 직접 서보는 느낌이랄까요. 아, 여기서 데이터가 이렇게 들어가는구나. 이 필드는 비었구나. 이런 게 바로 보입니다.

제일 속 터졌던 에러, 알고 보니 중복 ID와 동기화 문제였습니다

개발하다 보면 이상하게 오래 기억나는 에러가 있잖아요. 이번 Qdrant 작업에서는 이 에러가 그랬습니다.

qdrant_client.http.exceptions.UnexpectedResponse: unexpected response: 400,
{"status":{"error":"Wrong input: Point with id <some-uuid> already exists"}}

처음에는 좀 당황했습니다. upsert면 덮어쓰는 거 아닌가? 왜 이미 존재한다고 화를 내지? 싶었거든요. 코드를 따라가 보니, AI가 만들어준 저장 로직이 제 데이터 흐름과 잘 안 맞았습니다. 같은 문서를 다시 처리할 때 ID 생성 방식이 애매했고, 일부 로직에서는 insert처럼 동작하는 코드가 섞여 있었습니다. 게다가 비동기 처리 흐름에서 저장이 끝나기 전에 다음 작업이 이어지면서 상태 확인도 꼬였고요.

제가 정리한 방식은 이랬습니다.

from qdrant_client.http.models import PointStruct

points = [
    PointStruct(
        id=point_id,
        vector=embedding,
        payload={
            "text": chunk_text,
            "source_file": source_file,
            "project_name": project_name,
            "chunk_index": chunk_index
        }
    )
]

client.upsert(
    collection_name="my_docs",
    points=points,
    wait=True
)

여기서 핵심은 id 생성 기준을 명확히 잡는 것wait=True를 명시하는 것이었습니다. 저는 문서명, 청크 인덱스, 프로젝트명을 조합해서 안정적인 ID를 만들었습니다. 같은 문서를 다시 넣으면 같은 ID가 나오게요. 이렇게 하면 재처리할 때도 덮어쓰기 흐름이 자연스러워집니다.

물론 모든 상황에서 wait=True가 정답은 아닙니다. 대량 데이터를 아주 빠르게 밀어 넣어야 하는 상황이라면 비동기 처리나 batch 전략을 더 잘 설계해야 합니다. 그런데 개발 초기나 데이터 정합성이 더 중요한 단계라면 저는 wait=True를 선호합니다. 적어도 지금 저장이 끝났는지 안 끝났는지 헷갈리는 일은 줄어드니까요.

대량 upsert 때는 배치 크기도 은근히 중요했습니다

문서가 적을 때는 아무렇게나 넣어도 잘 들어갑니다. 문제는 데이터가 늘어날 때예요. 청크가 몇만 개쯤 되면 한 번에 밀어 넣는 방식이 부담스러워집니다. 저는 처음에 AI가 만들어준 코드대로 전체 points를 한 번에 upsert하려고 했는데, 중간에 timeout이 나거나 응답이 불안정한 경우가 있었습니다.

그래서 배치를 나눴습니다.

def chunks(items, size):
    for i in range(0, len(items), size):
        yield items[i:i + size]

for batch in chunks(points, 100):
    client.upsert(
        collection_name="my_docs",
        points=batch,
        wait=True
    )

제 테스트 환경에서는 100개 단위가 꽤 안정적이었습니다. 물론 이 숫자는 서버 사양, 벡터 차원, payload 크기, 네트워크 상태에 따라 달라집니다. 중요한 건 “AI가 한 번에 넣으라고 했으니 한 번에 넣는다”가 아니라, 내 환경에서 적당한 배치 크기를 찾아야 한다는 점이에요.

제가 AI에게 프롬프트를 던질 때 바꾼 방식

처음에는 저도 대충 물어봤습니다. “Qdrant RAG 예제 만들어줘.” 그러면 AI가 대충 그럴듯한 코드를 만들어줍니다. 그런데 그 코드는 대부분 데모용입니다. 실무에 가져가려면 조건을 훨씬 구체적으로 줘야 합니다.

요즘은 이런 식으로 묻습니다.

Python FastAPI 기반으로 Qdrant를 사용하는 RAG 검색 API를 만들어줘.

조건:
  • 임베딩 모델은 text-embedding-3-small이고 벡터 차원은 1536
  • collection_name은 my_docs
  • payload에는 source_file, project_name, year, chunk_index, text가 들어감
  • project_name과 source_file은 keyword 인덱스를 생성해야 함
  • 검색 시 project_name으로 필터링할 수 있어야 함
  • upsert는 batch 단위로 처리하고 wait=True를 사용
  • Qdrant 일시 오류에 대비해 재시도 로직도 포함

이렇게 던지면 결과물이 확 달라집니다. AI가 갑자기 천재가 된다기보다는, 제가 원하는 조건을 더 잘 반영하게 되는 거죠. 저는 이게 요즘 개발자의 새로운 역량 중 하나라고 봅니다. 코드를 직접 다 쓰는 능력도 중요하지만, AI에게 좋은 작업 지시를 내리는 능력도 꽤 중요해졌어요.

조금 웃기지만, 후배 개발자에게 업무를 설명할 때와 비슷합니다. “이거 좀 해줘”라고 하면 결과물이 들쭉날쭉합니다. 그런데 입력, 출력, 예외 조건, 성능 기준, 하지 말아야 할 것까지 알려주면 훨씬 안정적으로 나오죠. AI도 비슷했습니다.

    • 사용할 라이브러리와 버전을 가능하면 명시하기
    • 임베딩 모델명과 벡터 차원을 꼭 같이 알려주기
    • payload 필드명과 타입을 미리 적어주기
    • 필터 검색, 재시도, batch 처리 같은 운영 조건을 빼먹지 않기
    • “예제”가 아니라 “내 프로젝트 조건에 맞는 코드”를 요청하기

AI 바이브 코딩은 빠르지만, 방향키는 개발자가 잡아야 합니다

이번에 Qdrant를 붙이면서 느낀 걸 한 문장으로 말하면 이겁니다. AI는 정말 좋은 동료가 될 수 있지만, 시니어 개발자의 판단까지 대신해주지는 않습니다.

AI 덕분에 초반 작업 속도는 확실히 빨라졌습니다. 예전 같으면 공식 문서 훑고, 샘플 코드 찾아보고, 직접 붙여보는 데 반나절은 썼을 일을 훨씬 짧게 시작할 수 있었어요. 특히 FastAPI endpoint, Qdrant client 초기화, 기본 search 함수 같은 건 AI가 만들어준 초안을 손보는 방식이 꽤 효율적이었습니다.

하지만 실제로 프로덕션에 가까운 형태로 만들려면 결국 개발자의 경험이 필요했습니다. 임베딩 차원 확인, payload 인덱스, 필터 타입, batch upsert, wait=True, 재시도 로직, dashboard 확인. 이런 것들은 그냥 코드 몇 줄의 문제가 아니라 시스템을 안정적으로 굴리는 감각에 가깝습니다.

저는 그래서 AI 바이브 코딩을 이렇게 쓰는 게 제일 좋다고 봅니다. 초안은 AI에게 맡기되, 설계와 검증은 개발자가 책임지는 방식이요. AI가 만들어준 코드를 보면서 “오, 이거 괜찮네” 하고 넘어가는 게 아니라, “이게 내 데이터 크기에서도 괜찮을까?”, “장애 나면 어떻게 되지?”, “필터 성능은 문제 없나?” 하고 계속 물어봐야 합니다. 약간 귀찮죠. 그런데 그게 개발입니다.

이 글은 이런 분들에게 특히 도움이 될 것 같습니다.

    • RAG나 의미 검색을 직접 구현해보고 싶은데 벡터DB가 아직 낯선 분
    • Qdrant를 로컬에서 붙여보고 실제 프로젝트에도 적용해보고 싶은 분
    • Cursor, GitHub Copilot 같은 도구로 AI 바이브 코딩을 해보고 있지만 결과물을 어디까지 믿어야 할지 고민되는 분
    • 임베딩 차원, payload 인덱스, 필터 검색 같은 실무 포인트에서 한 번쯤 막혀본 분

제 기준에서 Qdrant는 꽤 마음에 드는 벡터DB였습니다. 개발자 친화적이고, 로컬 테스트도 쉽고, 필터 검색도 잘 갖춰져 있습니다. 다만 AI가 만들어준 예제 코드만 믿고 들어가면 분명히 어딘가에서 한 번은 걸립니다. 특히 임베딩 차원, payload 인덱스, 필터 타입, upsert 처리 방식은 꼭 직접 확인해보세요.

뭐랄까, AI 바이브 코딩은 좋은 내비게이션 같은 느낌입니다. 길은 빨리 찾아주지만, 실제 운전은 내가 해야 합니다. 공사 중인 길인지, 골목이 너무 좁은지, 주차할 곳이 있는지는 결국 내가 봐야 하거든요. 개발도 딱 그 정도 거리감이 좋은 것 같습니다. 편하게 도움받되, 중요한 판단은 내 손에 쥐고 가는 것. 저는 앞으로도 그렇게 써보려고 합니다.

댓글