AI/Natural language processing

[NLP] RAG - Milvus DB 사용하기

hjjummy 2025. 9. 23. 10:46

처음엔 “그냥 벡터 넣고 검색하면 되겠지”라고 생각했다. 막상 붙여보니 그게 다가 아니었다.😅

스키마 설계, 인덱스 선택, 텍스트 전처리 전략, 하이브리드 검색 결합, 운영 편의성까지 고루 손을 봐야 했다. 

RAG 파이프라인을 구축하면서 처음으로 Milvus 벡터 데이터베이스를 사용해보았는데, 잊지 않기 위해서 설계·연결한 과정을 개발하면서 고민한 포인트 중심으로 기록하려 한다.

 

“Milvus가 뭔데?”라는 기초적인 부분부터 “현업 RAG 파이프라인에서 어떻게 넣고(ingest), 어떻게 찾는지(search)”까지 

직접 구현한 코드 기준으로 정리했다!


📌 vectorDB Milvus를 선정한 이유?

우리가 흔히 쓰는 MySQL, PostgreSQL 같은 관계형 데이터베이스(RDBMS)는
“사전에 정의된 스키마”에 맞춘 정형 데이터를 잘 다룬다.
하지만 텍스트, 이미지, 오디오, 비디오 같은 비정형 데이터는 구조가 들쭉날쭉해서 RDB로는 다루기 어렵다.

 

👉 이때 필요한 게 벡터 데이터베이스(Vector DB)이다.

비정형 데이터를 임베딩 벡터로 변환하여 저장하, 쿼리 검색 할 때도 벡터 형태로 변환하여 벡터 간의 거리/유사도를 계산한다

Milvus는 이 목적에 딱 맞게 만들어진 DB다.

  • 1조 개 규모의 벡터 인덱스 지원
  • Dense(임베딩 기반) + Sparse(BM25 기반) 검색 둘 다 지원
  • 분산 확장/클라우드 친화적
  • 다양한 언어 SDK + REST API 지원

즉, RAG 같은 대규모 검색 시스템에 가장 적합한 데이터베이스 중 하나다.


📌 Milvus란?

Milvus 주요 용어 정리 📝

🧱 Collection

  • RDB의 테이블과 비슷한 개념.
  • 하나의 주제/도메인 데이터를 담는 단위.
  • 스키마(필드 구조)와 인덱스 정의를 가짐.

📑 Field

  • 컬렉션 안의 열(column).
  • 일반 타입: INT64, VARCHAR, FLOAT 등
  • 벡터 타입: FLOAT_VECTOR, SPARSE_FLOAT_VECTOR

🗂️ Index 

  • 검색 속도를 빠르게 하기 위해 벡터를 구조화하는 방식.
  • 예: FLAT, IVF, HNSW, IVF_PQ 등
인덱스 유형 설명
FLAT - 전수 비교. 정확하지만 느림
- 소규모 데이터셋에서만 적합
IVF_FLAT - 벡터를 여러 클러스터로 나누고 일부만 탐색.
- 정확도와 속도의 균형이 좋다
IVF_SQ8 / IVF_PQ - 양자화 기반 인덱스
- 메모리 절감 + 속도 향상. 다만 정확도는 조금 떨어진다.
HNSW - 그래프 기반인덱스
- 매우 빠르고 정확하지만 메모리 사용량이 많음

📏 Metric (유사도 지표)

  • 벡터 간의 유사도를 측정하는 방법.
  • 대표적 예시:
    • L2(유클리드 거리): 주로 컴퓨터 비전
    • IP(내적), COSINE: 자연어 임베딩에 많이 사용
    • BM25, Jaccard, Hamming: 희소 벡터(키워드 매칭)에 적합
Milvus에서 지원하는 유사도 메트릭
L2 (Euclidean Distance) 두 점 사이의 직선 거리. CV에서 많이 사용.
IP (Inner Product) 내적. NLP에서 자주 쓰임.
COSINE 코사인 유사도. IP와 비슷하지만 벡터를 정규화해서 방향성 위주로 비교.
Hamming 바이너리 벡터 간 비트 차이 개수
Jaccard 집합 간 유사도. 주로 분자 구조 검색 등에 활용.
BM25 전통 정보검색(IR)에서 쓰이는 키워드 매칭 기반 메트릭

📚 Partition

  • 대규모 데이터를 더 잘 관리하기 위해 Collection 내부 논리적으로 나누는 단위.

🔍 Hybrid Search

  • 두 가지 의미로 쓰임:
    1. 스칼라 필터 + 벡터 검색 (예: “카테고리=교통” 조건 후 유사도 검색)
    2. Dense(임베딩) + Sparse(키워드) 결과를 결합하는 방식 (RRF 등으로)

RRF (Reciprocal Rank Fusion)

  • Dense와 Sparse 검색 결과를 합치는 기법.
  • 랭킹 점수를 1 / (k + rank) 형태로 환산해 서로 다른 검색 결과를 합산 → 두 검색 결과의 장점을 모두 취하면서 랭킹 가능.

정리

  • Vector DB는 비정형 데이터를 임베딩 벡터로 변환해 유사도 검색을 수행하는 전용 DB임.
  • Milvus는 이 목적에 특화된 오픈소스 DB로, 대규모 RAG/추천/검색 시스템에서 많이 쓰임.
  • 이해해야 할 핵심 용어는 Collection, Field, Index, Metric, Hybrid Search, RRF.
  • 인덱스/메트릭 선택은 도메인 특성과 데이터 규모에 따라 달라진다.
  • Dense + Sparse 조합은 실제 서비스 품질을 끌어올리는 핵심 전략임.

📌 Milvus에 데이터 적재하며 고민했던 포인트

🏗️ 스키마 설계 – 표시용과 검색용을 분리하자

내가 가장 먼저 고민했던 건 텍스트를 어떻게 저장할까였다.
처음엔 텍스트를 한 필드에만 넣었다. 그런데 LLM이 읽을 원문과 검색 최적화 텍스트의 요구사항이 다르다는것을 나중에 피드백을 듣고 깨달았다. 그래서 이원화했다.

 

  • text_gen → 표시/LLM 입력용. 사람이 읽을 때 쓰는 원문, table tag 값 등 읽을 때 도움이 되는 정보 유지.
  • text_emb → 검색을 위한 정제본. 마크업 제거, 불용어 제거 + Kiwi 토크나이즈 등.

 

그래서 스키마를 이렇게 나눴다 👇

PK: chunk_id

공통 메타
- chunk_id (PK)
- category
- filenm_hash       # 저장/추적용 파일명
- filenm_ko         # 해시 → 원본 파일명 매핑
- page_range        # "12-13"

텍스트
- text_gen          # 표시/LLM용 원문
- text_emb          # 검색용 정제본(토큰화/불용어 제거)

벡터
- dense_vector      # FLOAT_VECTOR (임베딩)
- sparse_vector     # SPARSE_FLOAT_VECTOR (BM25)

체감상 이 분리 하나로 디버깅/운영 난이도가 확 내려갔다. “왜 이게 검색되었는지”가 보이고, LLM에 검색한 정보를 던질 때도 안정적이었다.


🏗️ 인덱스 & 메트릭 선정 

두번째는 어떤 인덱스와 매트릭을 선정할 지 고민하였다.

Milvus는 다양한 인덱스를 지원한다. 여기서는 크게 고민하지는 않고 대부분 많이 사용하는 방법을 선택했다.

  • Dense → IVF_FLAT + COSINE
    이유: 초기에 튜닝이 단순하고 속도/정확도 균형이 좋음. 규모가 커지면 nlist/nprobe만 만져도 방향이 잡힌다.
  • Sparse → SPARSE_INVERTED_INDEX + BM25
    : 키워드 매칭 강점이 있어서 짧은 한글 질의(용어/명사)에 강력하고, 제목/핵심어 매칭을 잘 잡아준다.

즉,

  • Dense는 “의미가 비슷한데 표현이 다른 텍스트”를 끌어오고,
  • Sparse는 “정확히 그 단어가 들어간 텍스트”를 보정한다.
  • 둘을 RRF로 합치면 결과가 안정된다. (어느 한쪽이 삐끗해도 반대편이 잡아준다)

🏗️ 인제스트 파이프라인 설계 – JSON → Chunk → Milvus

1) JSON → Chunk 변환

PDF를 미리 파싱하여 JSON형태로 저장해놓은 것을 받아서, 각 문단을 SimpleNamespace로 변환했다.

out.append(SimpleNamespace(
    text=(it.get("text_for_embedding") or "").strip(),      # → text_emb
    preview=(it.get("text_for_generation") or "").strip(),  # → text_gen
    metadata=meta,  # source_file / page_range / chunk_index
    category=_infer_category(json_path, it),  # 상위 폴더명 fallback
    chunk_id=it.get("id") or "",
))

2) 파일명 매핑

저장된 PDF 파일명을 해시값으로만 변환했기 때문에 DB의 파일명은 해시값으로 올라간다. 그래서 해시값과 원본 파일명을 맵핑한 mapping_csv를 만들어 이를 읽어 hash32 → 원본 파일명을 복원했다.
DB에는 검색 결과에서 사람이 읽을 수 있도록 filenm_ko와 실제 파일명인 filenm_hash 둘다 저장했.

3) Dense 업서트

  • text_gen을 기반으로 vLLM 임베딩을 생성
  • 결과를 dense_vector 필드에 저장
  • 이때 나는 청킹 전략으로 max_chunk_tokens=1536 기준으로 이진 탐색 슬라이싱을 걸어 길이 초과 실패를 원천 차단하였.

4) Sparse 업서트

  • text_emb를 Kiwi로 토큰화(+불용어 제거)해 BM25 입력 품질을 높인다.
  • Milvus의 FunctionType.BM25로 text_emb → sparse_vector를 DB 내부에서 생성한다.

🏗️ 검색 — Dense/Sparse 각각 돌리고, RRF로 랭킹

  • Dense: 질의 임베딩 → COSINE 유사도 Top-K
  • Sparse: 질의 토큰화 → BM25 점수 Top-K
  • RRF: 두 결과의 랭킹을 1/(k+r)로 환산해 합산 → 최종 랭킹 산출

실제 체감은 이렇다.

  • 키워드 위주의 짧은 질의에서는 BM25가 선방하고,
  • 문장형/의미 중심 질의에서는 Dense가 리드를 잡는다.
  • RRF로 합치면 둘 다 놓치지 않는 결과가 나온다 

 

내가 부딪힌 문제와 고른 해답들에 대해  정리하자면

  • 텍스트 하나로 다 하려다 품질이 애매함 → text_gen/text_emb 이원화로 해결됨.
  • 임베딩 요청 실패/지연토큰 길이 가드(이진탐색 슬라이싱)로 안정화됨.
  • 짧은 한글 질의에서 엉뚱한 결과 → BM25 추가 + Kiwi 토크나이즈로 해소됨.
  • dense/sparse 중 하나가 흔들리는 케이스RRF로 균형 잡힘.
  • 운영 가시성 부족 → 파일명 해시→원본명 매핑을 결과 필드에 같이 저장.

튜닝 체크리스트 

  • Dense 메트릭: 언어 임베딩이면 COSINE(또는 정규화된 IP)로 시작.
  • IVF 파라미터: nlist(버킷 수) 1024~4096에서 시작 → nprobe로 리콜 조절.
  • BM25: 한국어는 토크나이저 품질이 체감 성능을 좌우함(Kiwi 추천).
  • 결합 전략: Dense 단독 → BM25 추가 → RRF 결합 순서로 단계적 개선이 안전함.
  • 스칼라 필터: 카테고리/기간/기관 등으로 후보군을 좁힌 뒤 벡터 검색을 올리면 지연이 줄고 품질이 오른다.

마무리

이번 작업을 하며 깨달은 것은 Upsert 단계에서도 검색 전략을 설계한다는 것이다. 단순히 청킹한 데이터들을 DB에 밀어 넣는 걸로 끝나는 게 아니다. 어떤 DB를 선택하고, 그 DB에 어떻게 적재할지 고민해야하며 이게 곧 검색 품질을 좌우하고, 결국 RAG의 성능에도 직결된다. 

그래서 나는 이번 Milvus 적용을 “데이터 적재”가 아니라 검색 아키텍처 설계라고 해도 되지 않을까..라고 생각이 들었다.😅
스키마 이원화, 인덱스·메트릭 선택, 토크나이즈·정규화, Dense+Sparse 결합(RRF), 그리고 운영 편의를 위한 원클릭 인제스트·파일명 매핑까지…
이 모든 걸 하나의 패키지로 묶어서 봐야 비로소 실서비스에 쓸 수 있는 품질이 나온다.

 

생각해보면 RAG뿐만 아니라 AI 전체가 그렇다. 한 단계, 한 단계의 설계가 사소해 보이더라도 최종 성능에 영향을 준다.
즉, 작은 결정 하나가 전체 성능을 흔들 수 있다는 거다.🥲

늘 느끼는 것이지만 뭔가 "AI는 다 흐름만 보면 간단한데?" 싶다가도 "파고 들면 끝이 없구나 라는것을 느끼고 더욱 알아야하는것과 공부해야하는게 많다고 느껴졌다!!

 

 

다음 글에서는 여기서 만든 Milvus 백엔드를 LangGraph RAG와 연결해, 스트리밍 응답 + 정확한 문서/페이지 인용까지 붙이는 흐름을 정리해보겠다. 프롬프트 구성, 스트림릿 UI, 중간 추론 로그까지 그대로 보여줄 예정이다.