Published on

Pinecone 멀티테넌트 RAG - 네임스페이스·필터 최적화

Authors

멀티테넌트 RAG에서 가장 먼저 무너지는 지점은 “격리”가 아니라 “성능의 예측 가능성”입니다. 초기에는 테넌트 수가 적어 네임스페이스 하나에 다 넣고 필터로만 분리해도 잘 돌아갑니다. 하지만 테넌트가 늘고 문서가 쌓이면, 특정 테넌트의 대량 인덱싱이나 긴 꼬리 질의가 전체 지연시간을 흔들고, 비용도 예측이 어려워집니다.

이 글은 Pinecone에서 멀티테넌트 RAG를 운영할 때 자주 쓰는 두 축인 namespace 와 메타데이터 filter 를 어떻게 조합해야 하는지, 그리고 어떤 경우에 어떤 선택이 “최적화”인지(성능, 비용, 운영 난이도 관점) 기준을 제공합니다.

또한 필터가 인덱스를 못 타는 상황처럼, “왜 느린지”를 원인별로 분해하는 접근이 중요합니다. 데이터베이스에서 인덱스가 안 타는 이유를 진단하듯이 벡터 검색도 분해해서 봐야 합니다. 참고로 진단 프레임은 PostgreSQL 인덱스 안타는 이유 9가지와 해결 글의 사고방식을 응용하면 도움이 됩니다.

멀티테넌트 RAG에서 격리 단위 정하기

Pinecone에서 멀티테넌트를 분리하는 대표 선택지는 크게 3가지입니다.

  1. 인덱스 분리: 테넌트마다 인덱스 1개
  2. 네임스페이스 분리: 인덱스 1개, 테넌트마다 namespace
  3. 필터 분리: 인덱스 1개, metadata.tenant_id 로 필터

현실에서는 2번과 3번을 섞습니다. 예를 들어 “테넌트는 네임스페이스로 격리하고, 그 안에서 문서 타입이나 권한은 필터로 좁힌다”가 가장 흔한 형태입니다.

인덱스 분리 vs 네임스페이스 분리 vs 필터 분리

  • 인덱스 분리

    • 장점: 격리 최강, 성능 예측 쉬움, 장애 영향 최소
    • 단점: 운영 오버헤드(인덱스 수 증가), 비용 관리 복잡
    • 추천: 엔터프라이즈 상위 고객, 규제 요건, 테넌트별 SLA가 다른 경우
  • 네임스페이스 분리

    • 장점: 격리와 운영성 균형, 테넌트 단위 삭제 쉬움
    • 단점: 네임스페이스 수가 매우 커지면 운영 도구/관측이 필요
    • 추천: 대부분의 SaaS 멀티테넌트 기본값
  • 필터 분리

    • 장점: 구조 단순, 네임스페이스 없이도 구현 가능
    • 단점: 필터 선택도에 따라 성능 요동, “잘못된 필터”가 격리 사고로 이어짐
    • 추천: 테넌트 수가 적거나, 테넌트별 데이터량이 매우 작고 균질한 경우

핵심은 “격리”만 보면 인덱스 분리가 좋지만, RAG는 인덱싱 파이프라인과 재처리(리임베딩), 문서 삭제/만료 같은 운영 이벤트가 많아 네임스페이스가 실무적으로 가장 균형이 좋습니다.

Pinecone 네임스페이스 설계: 테넌트 키를 어디에 둘 것인가

권장 패턴: namespace = tenant_id

가장 단순하고 안전합니다.

  • 업서트할 때 무조건 해당 테넌트 네임스페이스로만 쓰기
  • 검색할 때도 무조건 해당 네임스페이스로만 질의

이렇게 하면 “필터를 빼먹어서 다른 테넌트가 섞이는 사고”를 구조적으로 줄일 수 있습니다.

다만 네임스페이스만으로는 “권한”이나 “문서 그룹”을 표현하기 어렵습니다. 예를 들어 한 테넌트 내부에서도 프로젝트별 접근 제어가 필요하면, 네임스페이스는 테넌트로 고정하고 나머지는 필터로 처리하는 편이 낫습니다.

안티 패턴: namespace = tenant_id:project_id

처음에는 깔끔해 보이지만, 프로젝트 생성/삭제가 잦거나 권한 체계가 바뀌면 네임스페이스 폭발이 발생합니다.

  • 삭제는 쉬워도 관측과 비용 추적이 어려워짐
  • “사용자 그룹” 같은 교차 축이 생기면 네임스페이스 설계를 다시 해야 함

프로젝트 단위로 완전 격리가 필요하다면 차라리 인덱스 분리를 고려하거나, 네임스페이스는 테넌트로 고정하고 project_id 는 필터로 처리하는 쪽이 장기적으로 안전합니다.

메타데이터 필터 최적화: 선택도와 카디널리티가 전부다

벡터 검색에서 필터는 “후처리”처럼 느껴지지만, 실제로는 검색 후보군을 얼마나 줄이느냐에 따라 지연시간과 비용이 크게 달라집니다. 특히 멀티테넌트에서는 필터가 잘못 설계되면 특정 테넌트가 전체를 느리게 만들 수 있습니다.

여기서 중요한 개념은 다음 두 가지입니다.

  • 선택도(selectivity): 필터가 전체 중 얼마나 적은 레코드를 남기는가
  • 카디널리티(cardinality): 값의 다양성(예: tenant_id 는 높고, lang 는 낮을 수 있음)

필터 키 설계 원칙

  1. 테넌트 격리는 가능하면 네임스페이스로 해결
  2. 필터는 “권한/스코프”를 표현하는데 사용
  3. 필터 키는 고정 스키마로 유지하고, 값만 바꾸기
  4. tags 처럼 자유도가 높은 배열 필드는 신중히(성능과 운영 복잡도 증가)

실무에서 자주 쓰는 메타데이터 예시는 아래와 같습니다.

  • doc_id: 원문 문서 식별자
  • source: confluence, gdrive, notion
  • project_id: 프로젝트/워크스페이스
  • visibility: public, private, internal
  • acl: 사용자 또는 그룹 식별자 배열(주의 필요)
  • chunk_version: 청크 스키마/임베딩 버전
  • deleted: 소프트 삭제 플래그(가능하면 TTL이나 물리 삭제 권장)

멀티테넌트 RAG 추천 조합 3가지

1) 기본형: namespace = tenant_id + 최소 필터

  • 대상: 대부분의 B2B SaaS
  • 질의: 네임스페이스로 테넌트 고정 후, project_id 정도만 필터

장점은 단순함과 사고 방지입니다. “테넌트 누락 필터” 같은 치명적 버그를 구조적으로 줄입니다.

2) 권한 강화형: namespace = tenant_id + acl 필터

  • 대상: 테넌트 내부에서 사용자별 문서 접근이 엄격한 경우
  • 접근: acl 을 그대로 넣기보다 “권한 토큰”을 설계

예를 들어 사용자별로 수백 개 그룹이 붙는다면, 질의 시 acl 배열에 대해 복잡한 조건이 생깁니다. 이때는 다음 전략을 고려합니다.

  • 문서 단위로 acl_groups 만 저장하고, 사용자 세션에서 그룹 리스트를 가져와 필터를 구성
  • 그룹을 너무 많이 넣어야 한다면 “프로젝트 단위”로 권한을 단순화해 project_id 로 축소

필터가 복잡해질수록 지연시간 분산이 커지니, SLA가 중요하면 권한 모델을 단순화하는 것이 장기적으로 더 싸게 먹힙니다.

3) 비용 최적화형: 핫 테넌트 분리

  • 대상: 일부 테넌트만 데이터량/트래픽이 압도적으로 큰 경우
  • 접근: 큰 테넌트는 인덱스를 분리하고, 나머지는 공유 인덱스+네임스페이스

이는 데이터베이스에서 “핫 테이블 분리”와 유사합니다. 한두 고객 때문에 전체가 흔들릴 때 가장 효과적입니다.

코드 예제: 업서트와 검색을 안전하게 고정하기

아래 예시는 Node.js 기반으로, 네임스페이스를 테넌트로 고정하고 필터를 선택적으로 추가하는 패턴입니다. Pinecone SDK 버전에 따라 메서드 시그니처가 다를 수 있으니, 핵심은 “namespace 를 항상 필수 입력으로 강제”하는 구조입니다.

// pseudo-code (TypeScript)
import { Pinecone } from '@pinecone-database/pinecone'

type ChunkMeta = {
  tenant_id: string
  project_id?: string
  source?: string
  doc_id: string
  chunk_version: number
  visibility?: 'public' | 'internal' | 'private'
}

const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! })
const index = pc.index(process.env.PINECONE_INDEX_NAME!)

export async function upsertChunks(params: {
  tenantId: string
  vectors: Array<{ id: string; values: number[]; metadata: Omit<ChunkMeta, 'tenant_id'> }>
}) {
  const namespace = params.tenantId

  // tenant_id는 namespace로 격리하므로 metadata에는 중복 저장하지 않아도 되지만,
  // 운영/감사 목적이라면 넣어도 좋습니다.
  await index.namespace(namespace).upsert(
    params.vectors.map(v => ({
      id: v.id,
      values: v.values,
      metadata: {
        ...v.metadata,
        tenant_id: params.tenantId,
      } satisfies ChunkMeta,
    }))
  )
}

export async function queryRag(params: {
  tenantId: string
  queryVector: number[]
  topK: number
  projectId?: string
  source?: string
  minChunkVersion?: number
}) {
  const namespace = params.tenantId

  const filter: Record<string, any> = {}
  if (params.projectId) filter.project_id = params.projectId
  if (params.source) filter.source = params.source
  if (params.minChunkVersion != null) filter.chunk_version = { $gte: params.minChunkVersion }

  // filter가 비어있으면 아예 전달하지 않는 편이 안전합니다.
  const res = await index.namespace(namespace).query({
    vector: params.queryVector,
    topK: params.topK,
    includeMetadata: true,
    ...(Object.keys(filter).length ? { filter } : {}),
  })

  return res.matches ?? []
}

위 코드의 포인트는 다음입니다.

  • index.namespace(namespace) 를 통해 테넌트 격리를 “호출 레벨”에서 강제
  • 필터는 선택적으로만 추가하고, 공통 스키마로 유지
  • chunk_version 같은 운영용 키를 넣어 리임베딩/재처리 시 혼합 결과를 방지

필터 성능을 흔드는 실전 이슈와 대응

1) 필터 누락으로 인한 테넌트 데이터 혼합

가장 위험한 버그입니다. 네임스페이스로 테넌트를 고정하면 이 위험을 크게 줄일 수 있습니다. 애플리케이션 레이어에서는 다음도 권장합니다.

  • 모든 검색 함수 시그니처에 tenantId 를 필수 파라미터로 두기
  • 미들웨어에서 tenantId 를 주입하고, 직접 호출을 막기
  • 테스트에서 “다른 테넌트 문서가 섞이지 않는지” 회귀 케이스 고정

이런 식의 구조적 안전장치는 동시성/스레드 모델과 무관하게 중요합니다. 고트래픽에서 작은 실수가 큰 장애로 번지는 패턴은 gRPC 마이크로서비스 503·데드라인 초과 디버깅 같은 글에서 다루는 “꼬리 지연” 문제와 결이 같습니다.

2) 저선택도 필터로 후보군이 너무 넓어짐

예: lang = ko 같은 필터는 대부분의 문서에 걸려 후보군을 거의 줄이지 못합니다. 이런 필터는 “정확도”에는 도움될 수 있어도 “성능 최적화”에는 기여가 작습니다.

대응:

  • 성능을 위해서는 project_id, doc_id, visibility 처럼 후보군을 확 줄이는 키를 우선
  • lang 은 rerank 단계에서 쓰거나, 프롬프트 단계에서 활용

3) acl 배열이 커져 필터가 복잡해짐

권한을 세밀하게 할수록 필터 조건은 커집니다. 이때는 다음 중 하나를 택해야 합니다.

  • 권한 모델을 상위 스코프로 올려 단순화(프로젝트 단위)
  • “권한 토큰”을 별도로 발급해 문서에 저장(예: policy_id)
  • 민감 테넌트는 인덱스 분리

4) 소프트 삭제 플래그가 누적됨

deleted = true 로 남겨두면 시간이 갈수록 “죽은 벡터”가 늘어 검색 후보군과 비용에 악영향을 줄 수 있습니다.

대응:

  • 가능하면 물리 삭제를 주기적으로 수행
  • 데이터 파이프라인에서 TTL 또는 배치 삭제 잡 운영

운영 잡이 기대대로 안 도는 문제는 벡터DB와 무관하게 자주 발생합니다. 배치가 조용히 실패하는 경우는 리눅스 crontab 안 돈다? 로그·환경·쉘 차이 같은 체크리스트가 그대로 적용됩니다.

RAG 품질과 성능을 함께 잡는 인덱싱 전략

멀티테넌트 최적화는 “검색을 빠르게”만이 아니라, “원치 않는 문서가 섞이지 않게”와 “재현 가능한 결과”가 함께 가야 합니다.

청크 버전과 임베딩 버전 관리

  • chunk_version: 청킹 규칙 변경(문단 단위, 슬라이딩 윈도우 등)
  • embedding_version: 모델 변경(예: text-embedding-3-large 에서 다른 모델로)

버전 키를 메타데이터로 넣고, 질의 시 최신 버전만 조회하도록 필터링하면 “혼합 결과”를 줄일 수 있습니다.

// 최신 버전만 검색하도록 강제
const filter = {
  chunk_version: { $eq: 3 },
  embedding_version: { $eq: 2 },
  visibility: { $in: ['public', 'internal'] },
}

주의할 점은 버전 필터가 너무 자주 바뀌면 캐시 효율이 떨어지고, 운영 복잡도가 올라간다는 것입니다. 하지만 멀티테넌트에서는 재처리 기간 동안 구버전과 신버전이 공존하기 때문에, 버전 키는 사실상 필수에 가깝습니다.

체크리스트: 어떤 조합이 내 서비스에 맞나

아래 질문에 답하면 설계가 빨라집니다.

  1. 테넌트 간 데이터 혼합이 발생하면 법적/계약적 리스크가 큰가
    • 크다: namespace = tenant_id 를 기본으로, 필요 시 인덱스 분리
  2. 테넌트별 데이터량 편차가 큰가
    • 크다: 핫 테넌트 인덱스 분리 고려
  3. 테넌트 내부 권한이 복잡한가
    • 복잡하다: acl 을 직접 확장하기 전에 스코프 단순화 또는 policy_id 같은 토큰화 고려
  4. 문서 삭제/만료가 잦은가
    • 잦다: 소프트 삭제에 의존하지 말고 물리 삭제/TTL/배치 삭제를 운영 체계로 포함

마무리

Pinecone 멀티테넌트 RAG에서 네임스페이스와 필터 최적화는 “기술 옵션”이 아니라 “운영 모델”의 선택입니다. 안전한 기본값은 namespace 로 테넌트를 강하게 격리하고, 필터는 테넌트 내부 스코프(프로젝트, 가시성, 버전)만 담당하게 하는 것입니다. 이후 테넌트 편차가 커질 때만 핫 테넌트를 분리해 비용과 지연시간의 예측 가능성을 회복하는 순서가 가장 시행착오가 적습니다.

다음 단계로는 (1) 질의 패턴별 topK 와 rerank 비용 최적화, (2) 캐시 계층 도입, (3) 관측 지표 설계(네임스페이스별 QPS, p95, 후보군 크기 추정)까지 확장하면 멀티테넌트 운영이 한 단계 안정화됩니다.