벡터 검색(Vector Search)과 RAG: 입문 가이드
요약
개인적인 마크다운 노트를 활용하여 벡터 검색과 RAG 파이프라인을 구축한 입문 가이드입니다. 로컬 임베딩 모델과 sqlite-vec을 사용하여 데이터 청킹, 임베딩, 벡터 인덱싱 및 검색 과정을 실습하며 얻은 통찰을 공유합니다.
핵심 포인트
- 벡터 검색은 키워드 일치가 아닌 임베딩을 통한 의미적 유사성을 기반으로 함
- 효과적인 RAG를 위해서는 적절한 데이터 청킹(Chunking) 전략이 필수적임
- sqlite-vec을 활용하면 로컬 환경에서도 가벼운 벡터 데이터베이스 구축 가능
- 벡터 DB는 의미를 이해하는 것이 아니라 임베딩 모델이 생성한 좌표를 관리함
주말 프로젝트를 통해 얻은 짧은 학습 경로입니다. 저는 개인적인 마크다운 노트(~800개 청크)를 인덱싱하고, 몇 가지 로컬 임베딩 모델(embedding models)을 시도했으며, 동일한 벡터를 네 가지 다른 백엔드에 저장하고 간단한 RAG를 연결해 보았습니다. 프로덕션 가이드는 아닙니다. 그저 추론하기에 충분히 작은 코퍼스(corpus)를 바탕으로 얻은 정직한 결과와 기초적인 내용일 뿐입니다.
전문 용어의 나열을 제외한 핵심 아이디어
**키워드 검색 (Keyword search)**은 공유된 단어를 찾습니다. **벡터 검색 (Vector search)**은 텍스트를 숫자 리스트(임베딩 (embedding))로 변환하고, 이 리스트를 공간상의 한 점으로 취급하여 가까운 점들을 찾아냅니다. 의미가 유사하면 단어가 다르더라도 서로 가까이 위치하게 됩니다.
이것이 **RAG (Retrieval-Augmented Generation, 검색 증강 생성)**의 검색(retrieval) 단계입니다:
문서 → 청크(chunks)로 분할 → 임베딩 → 벡터 인덱스에 저장
질문 → 임베딩 → 가장 가까운 청크 찾기 → 청크를 LLM에 전달 → 답변 생성
벡터 데이터베이스는 언어를 이해하지 못합니다. 오직 벡터를 저장하고 이웃을 찾을 뿐입니다. 모든 "의미"는 상류(upstream)에서 선택한 임베딩 모델로부터 나옵니다.
인덱싱한 내용
| 코퍼스 (Corpus) | 나의 개인 개발 노트 위키 — 데이터베이스 노트, 시스템 디자인 요약, 프론트엔드/백엔드 치트시트 |
| ... |
청킹(Chunking)은 모델만큼이나 중요합니다. 잘못된 분할은 엉뚱한 문단에서 올바른 주제를 가져오게 만듭니다. 벡터의 마법도 이를 해결할 수는 없습니다.
1단계 — 로컬에서 파이프라인 검증하기 (sqlite-vec)
저는 가능한 가장 작은 스택으로 시작했습니다: 로컬 임베딩 모델 (local embedding model) (제 노트북에서 실행되며 API 키가 필요 없음)과 sqlite-vec — SQLite 파일에 벡터를 저장하고 SQL에서 코사인 유사도(cosine similarity) 검색을 수행합니다. Docker도, 서버도 필요 없습니다.
첫 번째 성과: _"clickhouse merge tree vs mysql"_를 검색했을 때, 가장 상위에 나온 결과는 제 ClickHouse 노트였습니다. 이는 열 지향 저장소(column-oriented storage)와 행 저장소(row stores)에 대한 비교 테이블이었습니다. 공유된 키워드가 필요하지 않았습니다. 임베딩이 의도를 포착했습니다.
깨달은 점:
- 하나의 청크(chunk) → 공간상의 한 점(one point). 제가 최종적으로 선호하게 된 모델의 경우, 이는 384개의 숫자입니다. 단어당 하나의 숫자가 아니라, 문단 전체를 위한 하나의 좌표입니다.
- **코사인 거리 (cosine distance)**가 낮을수록 = 더 나은 매칭 (지도상의 km/mile가 아니라 방향 사이의 각도로 생각하세요).
- 약 800개의 청크 정도라면, 모든 벡터와 비교하는 브루트 포스 (brute-force) 방식도 충분히 빠릅니다. 첫날부터 화려한 인덱스 (index)가 필요하지는 않습니다.
- sqlite-vec조차도 메타데이터 (metadata) (파일 경로, 텍스트)를 **벡터 (vectors)**와 분리합니다. 이는 모든 벡터 데이터베이스 (vector DB)가 사용하는 동일한 패턴이며, 단지 이름만 다를 뿐입니다.
2단계 — 모든 임베딩 모델이 동일하게 동작하는 것은 아니다
동일한 코퍼스 (corpus), 세 가지 모델, 세 가지 인덱스 (index). 동일한 다섯 가지 테스트 쿼리 (query). 저는 모델 간의 절대적인 점수(모델마다 서로 다른 공간에 존재함)를 비교하는 대신, 최상위 검색 결과가 실제로 유용했는지를 비교했습니다.
| 모델 | 차원 (Dimensions) | 인덱싱 시간 |
|---|---|---|
| MiniLM | 384 | ~20초 |
| ... |
검색에 최적화된 일부 모델들은 인덱싱 시점과 쿼리 시점에 서로 다른 접두사 (prefix)를 요구합니다 (예: BGE는 문서에는 passage:, 질문에는 query:를 사용함). MiniLM은 접두사가 필요 없습니다. 잘못된 접두사를 사용해도 벡터는 생성되지만, 품질을 조용히 떨어뜨릴 뿐입니다.
동일한 쿼리, 다른 최상위 결과
하나의 쿼리를 예로 들어보겠습니다: "ClickHouse는 MySQL과 어떻게 비교되나요?" MiniLM은 ClickHouse를 스치듯 언급만 한 노트를 반환했습니다. 반면 BGE는 열 지향 (column-oriented) 대 행 지향 (row storage) 저장 방식에 대한 전용 비교 노트를 반환했습니다. 동일한 코퍼스, 동일한 질문임에도 RAG에 입력된 청크가 달랐던 것입니다.
저는 세 모델 모두에 대해 이와 같은 방식으로 다섯 개의 쿼리를 실행했습니다. 아래 표는 파일명이 아닌 결과 (outcomes), 즉 어떤 파일이 이겼느냐가 아니라 최상위 검색 결과가 유용했는지를 나타냅니다.
| 쿼리 (일반 영어) | MiniLM | bge-small | Nomic |
|---|---|---|---|
| 데이터베이스 복제 (Database replication) / 리더-팔로워 (leader–follower) | 정확한 주제, 모호한 섹션 | 정확한 주제, 최적의 섹션 | 정확한 주제, 모호한 섹션 |
| ... |
깨달은 점:
- 훈련 목표(Training objective)가 차원 수(dimension count)보다 중요합니다. BGE와 MiniLM은 모두 384차원입니다. BGE는 5개의 쿼리 중 4개에서 top-1 성능으로 승리했습니다.
- 더 많은 차원이 반드시 더 나은 것은 아닙니다. Nomic (768d)은 top-1에서 BGE를 한 번도 이기지 못했으며, 인덱싱 속도도 훨씬 느렸습니다.
- 기록하지 않은 것은 검색할 수 없습니다. 저에게는 육각형 아키텍처(hexagonal architecture)에 대한 노트가 없습니다. 검색은 "모릅니다"라고 답하는 대신 가장 가까운 이웃(nearest neighbor)을 반환합니다.
- 간략한 언급은 전용 문서에 밀립니다. CAP는 저의 일관성(consistency) 노트에 스치듯 지나갑니다. 찾아낼 수 있는 깔끔한 CAP 설명 청크(chunk)는 존재하지 않습니다.
- 자신의 쿼리로 평가하세요. 공개 벤치마크는 시작점일 뿐이며, 여러분의 코퍼스(corpus)가 진짜 테스트입니다.
이 단계 이후로 저는 모든 작업에 bge-small을 유지했습니다.
3단계 — 네 가지 벡터 저장소, 하나의 교훈
다음으로 저는 **동일한 청크(chunks)와 동일한 임베딩(embeddings)**을 네 가지 백엔드(backends)에 인덱싱했습니다:
| 저장소 | 실습에서의 역할 | 검색 방법 |
|---|---|---|
| sqlite-vec | 운영이 필요 없는(Zero-ops) 로컬 파일 | 정확한 KNN |
| ... |
개념은 모든 저장소에 걸쳐 매핑됩니다:
| 개념 | sqlite-vec | Qdrant | Redis | Milvus |
|---|---|---|---|---|
| 벡터 + 메트릭(metric) | vec0 테이블 | 컬렉션(collection) | HASH 필드 | FLOAT_VECTOR 컬럼 |
| ... |
HNSW (Hierarchical Navigable Small World)는 대규모 환경에서 통상적으로 사용되는 근사 인덱스(approximate index)입니다. 모든 벡터를 스캔하는 대신 그래프를 따라 이동합니다. 약 800개의 청크 규모에서는 브루트 포스(brute force) 방식과 **동일한 상위 결과(top hits)**를 반환했습니다.
백엔드 간에 결과가 달랐나요?
아니요 — 순위(ranking) 측면에서는 달랐지 않습니다. 동일한 임베딩 + 동일한 코사인 메트릭(cosine metric) → 이 규모에서는 동일한 이웃(neighbors)이 반환됩니다.
| 쿼리 | 네 가지 모두 일치했는가? |
|---|---|
| clickhouse merge tree vs mysql | 예 (코퍼스가 커진 후에는 제가 직접 작성한 벡터 검색 글이 가끔 1위를 차지하기도 했습니다. 이는 백엔드 버그가 아니라 ClickHouse를 언급한 메타 문서가 순위를 가져간 것입니다) |
| database replication leader follower | sqlite, Qdrant, Redis, Milvus 모두 동일한 상위 결과 반환 |
**지연 시간(Latency)**은 달랐습니다. 이는 품질의 문제가 아니라 인프라의 차이였습니다:
| 백엔드 (Backend) | 일반적인 검색 (Typical search) | 이유 (Why) |
|---|---|---|
| Redis | ~6–15 ms | 모든 데이터가 RAM에 있음 |
| ... | ||
| 헤드라인: 수백 개의 청크(chunks) 단계에 도달하면, 특정 모델이 텍스트를 더 잘 "이해"해서가 아니라 **운영(ops)과 확장성(scale)**을 기준으로 저장소를 선택하십시오. |
각 도구가 유용했던 경우
| 가장 적합한 경우 | |
|---|---|
| sqlite-vec | 학습, 오프라인 POC, 인프라가 없는 경우 |
| ... | |
| Redis를 통해 일반 redis-server ≠ Redis Stack이라는 점을 배웠습니다. 벡터 검색(vector search)을 위해서는 RediSearch 모듈이 필요합니다. 또한 Redis는 코퍼스 크기(corpus size) ≈ RAM 예산이라는 점을 보여주었습니다 (~800개의 청크는 몇 MB 수준이었지만, 수백만 개는 같은 방식으로 담을 수 없습니다). |
Qdrant와 Milvus 모두 다음을 강조했습니다: **검색 중에 필터링(filter during search)**해야 하며, 검색 후에 필터링해서는 안 됩니다. 만약 전역적으로 상위 100개(top-100)를 가져온 뒤 애플리케이션 코드에서 원치 않는 결과들을 버린다면, 결국 유용한 결과가 하나도 남지 않는 상황이 쉽게 발생할 수 있습니다.
4단계 — RAG: 검색(Retrieval)이 한계치(Ceiling)를 결정한다
검색(Retrieval)만으로는 순위가 매겨진 청크(chunks)를 얻을 뿐입니다. RAG는 생성(generation)을 추가합니다: 상위 k개(top-k)의 청크 → 프롬프트(prompt) → 인용(citations)이 포함된 LLM 답변.
성공했던 예시: "ClickHouse의 컬럼 스토리지(column storage)란 무엇인가요?" → 나의 ClickHouse 비교 청크들을 검색함 → LLM이 컬럼 지향 스토리지(column-oriented storage)를 설명하고 올바른 출처를 인용함.
도움이 되었던 프롬프트 패턴: "아래의 컨텍스트(context)만을 사용하여 답변하세요. [n]과 같이 인용하세요.". 검색(retrieval)이 잘 이루어지면 환각(hallucination)이 줄어듭니다. 검색이 잘못되면 LLM은 어차피 자신 있게 틀린 답을 내놓습니다.
잊지 못할 교훈: RAG의 품질 한계 = 검색(retrieval)의 품질입니다. 모델을 탓하기 전에 청크(chunks)를 먼저 디버깅하십시오. 저는 LLM을 건너뛰는 "컨텍스트 전용(context only)" 모드를 추가했는데, 답변이 그럴듯해 보이지만 틀린 경우에 매우 유용했습니다.
또한: top-k × chunk_size는 LLM의 컨텍스트 윈도우(context window)에 들어갈 수 있어야 합니다. 약 800자 크기의 청크와 k=5인 경우 관리가 가능하지만, 규모가 커지면 리랭킹(rerank)하거나 압축(compress)해야 합니다.
전체 그림
┌─────────────┐ ┌──────────┐ ┌─────────────────┐ ┌──────────────┐
│ 원본 문서 (Raw docs) │────▶│ 청커 (Chunker) │────▶│ 임베딩 모델 (Embedding model) │────▶│ 벡터 저장소 (Vector store) │
└─────────────┘ └──────────┘ └─────────────────┘ ──────┬───────┘
...
당신의 역할: 청킹 (chunking), 임베딩 (embedding), 재색인 정책 (re-index policy), 쿼리 임베딩 (query embedding), RAG 프롬프트 (RAG prompt), LLM.
벡터 DB의 역할: 벡터 저장, KNN/ANN 실행, 메타데이터 (metadata) 부착, 검색 중 필터링.
임베딩은 **파생 데이터 (derived data)**입니다. 즉, 텍스트가 변경되면 다시 임베딩해야 합니다. 벡터 인덱스는 문서의 원천 데이터 (source of truth)가 아닙니다.
주의해야 할 사항
- 임베딩 모델을 절대 혼용하지 마세요. 하나의 인덱스 내에서 서로 다른 모델을 사용하는 것은 금지됩니다. 차원이 같은 384차원 모델 두 개를 사용하더라도, 이들은 서로 호환되지 않는 공간을 형성합니다.
- 모델 변경 = 전체 재색인 (full re-index). 기존 벡터에 모델만 교체하여 적용할 수는 없습니다.
- 코퍼스 (Corpus) 커버리지가 영리한 검색보다 중요합니다. 주제가 누락되면 → 그럴듯해 보이는 잘못된 이웃 (neighbor)을 찾게 됩니다.
- 색인할 내용을 선별하세요. 제가 작성한 벡터 검색 관련 글이 때때로 ClickHouse 공식 문서를 검색 순위에서 앞선 적이 있는데, 이는 비교 표에서 ClickHouse를 언급했기 때문입니다.
- 위치 기반 청크 ID (Positional chunk IDs) (
File.md::5)는 문서가 수정되면 깨집니다. 증분 동기화 (incremental sync)가 필요하다면 안정적인 콘텐츠 해시 (content hashes)를 사용하세요. - 벡터는 의미를 담고, 페이로드 (payload)는 규칙을 담습니다. 소스 경로, 날짜, 태그 등은 임베딩의 마법이 아니라 필터링 필드 (filter fields)로 처리해야 합니다.
의도적으로 (아직은) 배우지 않은 것들
이 연습 과정에서 약 800개 이상의 청크를 넘어가는 부분은 대부분 **확장 (scaling)**에 관한 것이었습니다. 개념은 동일하지만 운영 난이도가 높아집니다:
- 샤딩 (Sharding), 복제 (replication), 별도 서비스로서의 임베딩
- 쿼리 시점 필터를 사용하는 하나의 공유 인덱스 vs 여러 개의 분리된 인덱스
- 리랭킹 (Reranking), 하이브리드 검색 (hybrid search) 튜닝, 평가 프레임워크 (evaluation harnesses)
- 수십억 개의 벡터 인덱스 유형 (IVF, product quantization)
이것이 실제 프로덕션 작업입니다. 이는 위의 기초 지식을 바탕으로 구축되는 것이지, 기초 지식을 이해하는 것을 대체하지 않습니다.
만약 다섯 가지만 기억해야 한다면
- 임베딩(Embeddings)은 텍스트를 공간상의 점으로 변환합니다 — 유사한 의미는 가까이 위치하며, 키워드는 선택 사항입니다.
- 사용자의 쿼리에 대해 하나의 임베딩 모델을 선택하고 평가하세요 — 검색에 최적화된(retrieval-tuned) 소형 모델이 종종 더 큰 범용 모델보다 성능이 뛰어납니다.
- 청킹(Chunking)을 잘 해야 합니다 — 검색 결과는 파일이 아니라 단락(paragraph)을 반환합니다. 잘못된 청크는 RAG의 품질을 제한합니다.
- 소규모 규모에서 벡터 DB(Vector DB)의 선택은 대부분 인프라의 문제입니다 — 이웃(neighbors)은 동일하지만, RAM/디스크/운영(ops) 측면의 트레이드오프가 다를 뿐입니다.
- RAG = 먼저 검색하고, 그다음에 생성합니다 — LLM을 튜닝하기 전에 검색(retrieval) 단계부터 해결하세요.
맺음말
약 800개의 청크만으로도 의미론적 검색(semantic search)이 작동하는 방식, 모델이 순위를 잘못 매기는 모습, 그리고 무엇이 검색되었느냐에 따라 RAG가 성공하거나 실패하는 과정을 충분히 확인할 수 있었습니다.
저는 이런 것들을 또 다른 다이어그램을 읽는 것이 아니라 직접 해보면서 배웁니다. 여러분도 똑같이 하고 싶다면, 이미 가지고 있는 작은 데이터(노트, README, 런북 등)를 인덱싱해 보세요. 모델 하나, 저장소 하나, 그리고 여러분이 중요하게 생각하는 5개의 쿼리만 정하면 됩니다. 그것만으로도 시작하기에 충분합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기