벡터 DB와 지식 저장소 연계 설계

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 조회가 필요합니다

도구로 노출합니다

도구 사용 패턴MCP에서 다룬 구조 그대로입니다.

{
    "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 를 응답 경로에 붙이지 않는다. 배치로 집계해 캐시에 적재하고 챗봇은 캐시를 본다

참고 문헌