RAG 시스템을 만들면 저장소가 하나로 끝나지 않습니다. 원본 문서, 메타데이터, 벡터 인덱스, 경우에 따라 지식 그래프까지 — 최소 서너 곳에 같은 지식이 다른 모양으로 들어갑니다.
여기서 생기는 문제는 “어떤 벡터 DB를 쓸까”가 아니라 “이것들이 어긋났을 때 어떻게 되는가”입니다. 이 글은 그 순서로 갑니다.
1. 벡터 저장소 선택
세 갈래
| 갈래 | 예 | 성격 |
|---|---|---|
| 전용 벡터 DB | Pinecone, Qdrant, Weaviate, Milvus, Chroma | 벡터에 특화. 인덱싱 옵션 풍부 |
| 검색엔진 확장 | Elasticsearch / OpenSearch | BM25와 벡터를 한 엔진에서. 하이브리드가 자연스러움 |
| RDB 확장 | PostgreSQL + pgvector | 기존 트랜잭션 데이터와 같은 DB에 |
판단 기준
“어떤 게 제일 빠른가”로 고르면 대개 틀립니다. 실제로 결정을 가르는 것은 이쪽입니다.
| 질문 | 답이 “예”라면 |
|---|---|
| 이미 Elasticsearch로 검색을 하고 있는가 | ES에 벡터 필드 추가. 새 인프라를 늘리지 않는 게 가장 큰 이득 |
| 키워드 검색이 반드시 필요한가 | ES/OpenSearch. 별도 벡터 DB를 쓰면 하이브리드를 직접 구현해야 함 |
| 메타데이터 필터가 복잡한가 (조인·범위·다중 조건) | ES 또는 pgvector. 전용 벡터 DB는 필터가 단순한 편 |
| 문서가 수십만 건 이하인가 | pgvector로 충분. 운영 부담이 가장 적음 |
| 문서가 수억 건이고 벡터만 다루는가 | 전용 벡터 DB. 샤딩·복제가 벡터에 맞춰져 있음 |
| 원본 데이터가 이미 PostgreSQL에 있는가 | pgvector. 조인 한 번으로 끝나는 걸 두 번 왕복할 이유가 없음 |
교육 도메인처럼 “정형 메타데이터 + 문서 + 하이브리드 검색”이 섞인 경우, 새 컴포넌트를 늘리는 것보다 기존 검색엔진을 확장하는 쪽이 대개 낫습니다. 하이브리드 검색과 필터를 한 질의로 끝낼 수 있다는 점이 운영에서 계속 이득으로 돌아옵니다.
새 컴포넌트를 늘릴 때 따라오는 비용
전용 벡터 DB를 도입하면 보이지 않던 일들이 생깁니다.
+ 백업·복구 절차 하나 더
+ 모니터링·알람 하나 더
+ 접근 통제·네트워크 정책 하나 더
+ 버전 업그레이드 주기 하나 더
+ 장애 시 조사 대상 하나 더
+ 두 저장소 간 정합성 관리 ← 이게 제일 비쌉니다
성능 몇 %를 위해 운영 대상을 하나 늘리는 게 맞는지는 따로 판단해야 합니다.
2. 저장소가 넷이 되는 문제
RAG 시스템의 지식은 보통 이렇게 흩어집니다.
① 원본 문서 S3 / 파일시스템 / CMS
└ 진짜 원본. PDF, HWP, 마크다운
② 메타데이터 MySQL / PostgreSQL
└ 문서 ID, 제목, 학과, 연도, 버전, 갱신일
③ 벡터 인덱스 Elasticsearch / 벡터 DB
└ 청크 텍스트 + 임베딩 + 필터용 메타 사본
④ 지식 그래프 트리플스토어 / Neo4j (쓰는 경우)
└ 과목·역량·선수관계
같은 사실이 네 군데에 있습니다. 그리고 반드시 어긋납니다.
문서가 개정됨
→ ① 원본은 바뀜
→ ② 메타데이터 갱신됨
→ ③ 벡터는 아직 옛날 것 ← 사용자에게 옛 규정이 나감
→ ④ 그래프는 손도 안 댐
단일 진실 원천을 정합니다
어긋남을 없앨 수는 없고, 어느 쪽이 맞는지를 정해 둘 수는 있습니다.
SSOT(Single Source of Truth) = ① 원본 + ② 메타데이터
③ 벡터 인덱스와 ④ 그래프는 파생물이다
→ 언제든 ①②로부터 전부 다시 만들 수 있어야 한다
→ 벡터 인덱스에만 있는 정보를 만들지 않는다
“벡터 인덱스는 지우고 다시 만들 수 있는 것” 이라는 원칙이 중요합니다. 이걸 지키면 인덱스가 망가져도 복구가 절차의 문제가 되지, 데이터 손실이 되지 않습니다.
파생 관계를 코드로 표현합니다
# 파생 관계가 단방향임을 코드 구조로 드러냅니다
def reindex_document(doc_id: str):
doc = source_store.get(doc_id) # ① 원본
meta = metadata_db.get(doc_id) # ② 메타데이터
chunks = chunk(doc, meta) # 파생
vectors = embed(chunks) # 파생
vector_index.upsert(doc_id, chunks, vectors) # ③ 덮어쓰기
graph.upsert_entities(doc_id, extract(doc)) # ④ 덮어쓰기
upsert 가 아니라 “해당 문서의 기존 청크를 전부 지우고 다시 넣는” 방식이어야 합니다. 문서가 개정되며 청크 개수가 줄면, 단순 upsert로는 옛 청크가 남아 검색에 걸립니다. 실무에서 자주 나오는 버그입니다.
vector_index.delete_by_query({"term": {"doc_id": doc_id}}) # 먼저 지우고
vector_index.bulk_insert(new_chunks) # 다시 넣는다
3. 증분 색인
전체 재색인은 비쌉니다. 임베딩 API 호출이 문서 수에 비례하기 때문입니다.
해시로 변경분만 골라냅니다
# 메타데이터 DB 에 문서/청크 해시를 저장해 둡니다
def sync():
for doc in source_store.list():
stored = metadata_db.get_hash(doc.id)
current = sha256(doc.content)
if stored == current:
continue # 안 바뀜. 건너뜀
reindex_document(doc.id)
metadata_db.set_hash(doc.id, current)
# 원본에서 사라진 문서 정리
for doc_id in metadata_db.list_ids() - source_store.list_ids():
vector_index.delete_by_query({"term": {"doc_id": doc_id}})
metadata_db.delete(doc_id)
마지막 삭제 처리를 빠뜨리는 경우가 많습니다. 폐지된 규정이 인덱스에 남아 계속 검색되는 사고가 여기서 납니다.
청크 단위 해시가 더 낫습니다
문서 100페이지 중 한 문단만 바뀌었는데 전체를 다시 임베딩하는 건 낭비입니다.
for chunk in chunks:
h = sha256(chunk.text)
if h in existing_hashes:
reuse_vector(h) # 임베딩 호출 없음
else:
embed_and_store(chunk) # 바뀐 것만
다만 청킹 경계가 앞쪽 수정 때문에 밀리면 해시가 전부 달라집니다. 구조 기반 청킹(제목 단위)을 쓰면 이 문제가 덜합니다. 절 하나를 고쳐도 다른 절의 경계는 안 움직이기 때문입니다.
파이프라인으로 돌립니다
Airflow DAG (일 1회 또는 문서 변경 이벤트)
① 원본 목록 조회
↓
② 해시 비교 → 변경분 추출
↓
③ 파싱 → 청킹 (변경분만)
↓
④ 임베딩 생성 (배치 API 로 비용 절감)
↓
⑤ 인덱스 반영 (삭제 후 삽입)
↓
⑥ 검증 — 문서 수·청크 수 대조, 샘플 질의 실행
↓
⑦ 메타데이터 해시 갱신
⑥ 검증 단계를 반드시 넣습니다. 색인이 절반만 되고 성공으로 끝나는 경우가 있습니다. 색인 후 골든셋 질의 몇 개를 돌려 리콜이 급락하지 않았는지 확인하면 조용한 실패를 잡을 수 있습니다.
4. 무중단 재색인
임베딩 모델을 바꾸거나 청킹 전략을 바꾸면 전체 재색인이 필요합니다. 이때 서비스를 멈출 수 없습니다.
별칭 전환
현재: alias "docs" ──► docs_v1 (서비스 중)
1) docs_v2 인덱스를 새로 만들어 전체 색인
2) docs_v2 에 대해 골든셋 평가 실행 → 지표 비교
3) 통과하면 별칭을 원자적으로 전환
이후: alias "docs" ──► docs_v2
docs_v1 은 며칠 보관 후 삭제 (롤백 대비)
Elasticsearch라면 별칭 전환이 한 번의 원자적 연산입니다.
POST /_aliases
{
"actions": [
{ "remove": { "index": "docs_v1", "alias": "docs" } },
{ "add": { "index": "docs_v2", "alias": "docs" } }
]
}
애플리케이션은 항상 별칭만 바라봅니다. 인덱스 이름을 코드에 박으면 이 전략을 못 씁니다.
임베딩 모델 교체는 되돌릴 수 없습니다
서로 다른 모델의 벡터는 같은 공간에 있지 않습니다. 섞으면 거리가 무의미해집니다.
나쁨: 기존 인덱스에 새 모델 벡터를 조금씩 덮어씀
→ 중간 상태에서 검색 결과가 뒤죽박죽
좋음: 새 인덱스에 전량 색인 → 평가 → 별칭 전환
그래서 임베딩 모델 선택은 초기에 신중해야 하고, 바꿀 때는 위 절차를 밟아야 합니다. 차원 수가 달라지면 매핑부터 다시 만들어야 하므로 새 인덱스가 강제됩니다.
5. 정형 데이터 연계 — MySQL, Athena
RAG가 문서를 다룬다면, 정형 데이터는 별도 경로로 붙입니다.
"제 이수 학점은 몇 점인가요?"
→ 문서에 답이 없습니다. DB 조회가 필요합니다
도구로 노출합니다
{
"name": "get_student_credits",
"description": (
"학번으로 재학생의 이수 학점 현황을 조회한다. "
"본인 인증이 완료된 경우에만 사용한다."
),
"inputSchema": {
"type": "object",
"properties": {
"student_id": {"type": "string", "description": "학번"}
},
"required": ["student_id"],
},
}
여기서 지켜야 할 것들입니다.
- 파라미터화된 질의만. LLM이 SQL 문자열을 통째로 만들게 하지 않습니다. 함수 인자만 받아 서버가 질의를 구성합니다
- 읽기 전용 계정. 만에 하나 SQL을 생성하게 하더라도 쓰기 권한은 없어야 합니다
- 본인 확인. 학번을 인자로 받되, 세션의 인증된 학번과 일치하는지 서버에서 검증합니다. 모델이 넘겨준 학번을 믿으면 남의 정보를 조회하게 됩니다
- 결과 크기 제한.
LIMIT없이 나가면 컨텍스트가 터집니다
Athena 는 성격이 다릅니다
Athena는 S3 위에서 도는 분석용 엔진입니다. 챗봇 응답 경로에 직접 붙이기엔 지연이 큽니다(수 초~수십 초).
나쁨: 사용자 질문 → Athena 질의 → 답변 (너무 느림)
좋음: Athena 는 배치 분석에 (일 1회)
→ 결과를 MySQL/캐시에 적재
→ 챗봇은 그 캐시를 조회
“통계·집계는 미리 계산해 둔다” 가 원칙입니다. “이번 학기 수강신청이 가장 많은 과목”은 매번 계산할 게 아니라 하루 한 번 집계해 두면 됩니다.
6. 클라우드 운영
임베딩 서빙을 어디서
| 방식 | 장점 | 단점 |
|---|---|---|
| 관리형 API (OpenAI 등) | 운영 부담 없음, 즉시 시작 | 데이터가 외부로 나감, 호출당 비용 |
| 자체 호스팅 (SageMaker, GPU 인스턴스) | 데이터가 내부에 머묾, 대량 처리 시 저렴 | GPU 비용, 모델 관리 |
교육 도메인은 개인정보가 섞이기 쉬워 자체 호스팅 요구가 나오는 경우가 많습니다. 다만 임베딩 모델은 LLM보다 훨씬 작아서 자체 호스팅 부담이 상대적으로 적습니다.
색인용과 질의용을 분리하는 것도 실용적입니다.
색인(배치): 대량, 지연 무관 → 스팟 인스턴스 / 배치 API
질의(온라인): 소량, 지연 민감 → 상시 가동 엔드포인트
비용이 새는 곳
| 항목 | 주의 |
|---|---|
| 임베딩 재생성 | 파이프라인 버그로 매일 전체를 다시 임베딩하는 사고가 흔합니다 |
| 벡터 인덱스 메모리 | HNSW는 메모리에 있어야 빠릅니다. 양자화로 먼저 줄입니다 |
| LLM 입력 토큰 | 검색 문서 개수가 곧 비용입니다. 재순위화로 적게 넣습니다 |
| 개발 환경 방치 | 임베딩 엔드포인트를 띄워 두고 잊는 경우 |
배포 구성
[사용자]
│
[API Gateway / ALB]
│
[챗봇 서비스] ──► [임베딩 엔드포인트]
│ ──► [Elasticsearch / OpenSearch]
│ ──► [MySQL] (메타데이터·정형)
│ ──► [LLM API]
│
[로그·트레이스] ──► [S3 / 관측 도구]
[배치]
Airflow ──► 문서 수집 → 색인 파이프라인 → 검증
챗봇 서비스는 상태를 갖지 않게 만들고(대화 상태는 Redis 같은 외부 저장소에), 스케일 아웃이 가능하게 합니다. 트래픽이 수업 시작 전후·수강신청 기간에 극단적으로 몰리는 도메인이라 오토스케일링이 실제로 필요합니다.
정리
| 질문 | 답 |
|---|---|
| 벡터 저장소를 어떻게 고르나 | 속도가 아니라 기존 스택과 필터 복잡도. 이미 ES 를 쓰면 ES 확장이 대개 정답 |
| 새 컴포넌트의 진짜 비용은 | 백업·모니터링·업그레이드, 그리고 저장소 간 정합성 관리 |
| 저장소가 어긋나는 문제는 | SSOT 를 원본+메타데이터로 정하고, 벡터·그래프는 언제든 다시 만들 수 있는 파생물로 |
| 재색인에서 흔한 버그는 | upsert 만 하고 옛 청크를 안 지우는 것, 그리고 삭제된 문서를 인덱스에서 안 빼는 것 |
| 무중단 재색인은 | 새 인덱스 전량 색인 → 골든셋 평가 → 별칭 원자적 전환. 코드에 인덱스명을 박지 않는다 |
| 임베딩 모델을 바꾸면 | 벡터 공간이 달라 섞을 수 없다. 전량 재색인 + 별칭 전환 |
| DB 조회 도구에서 조심할 것 | 파라미터화된 질의만, 읽기 전용 계정, 모델이 준 학번을 믿지 말고 세션과 대조 |
| Athena 를 응답 경로에 | 붙이지 않는다. 배치로 집계해 캐시에 적재하고 챗봇은 캐시를 본다 |
참고 문헌
- pgvector: https://github.com/pgvector/pgvector
- Elasticsearch — Aliases: https://www.elastic.co/guide/en/elasticsearch/reference/current/aliases.html
- AWS — Amazon Athena 모범 사례: https://docs.aws.amazon.com/athena/latest/ug/best-practices-general.html
- OWASP Top 10 for LLM Applications: https://owasp.org/www-project-top-10-for-large-language-model-applications/