- Published on
Pinecone·Milvus 멀티테넌시 튜닝 - 네임스페이스·필터
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
SaaS 형태의 RAG/검색 서비스를 운영하다 보면 “테넌트별 데이터 격리”와 “검색 성능”이 동시에 필요해집니다. 격리를 강하게 하면 인덱스가 쪼개져 비용이 늘고, 격리를 약하게 하면 필터가 무거워져 지연이 늘어납니다. 특히 Pinecone는 namespace 중심, Milvus는 collection·partition·스칼라 필터 중심으로 멀티테넌시를 구성하기 때문에 같은 요구사항이라도 튜닝 포인트가 다릅니다.
이 글은 Pinecone·Milvus에서 멀티테넌시를 설계할 때 흔히 겪는 병목(필터가 느려짐, 테넌트 수가 늘수록 p95가 튐, 메모리/스토리지 비용이 비정상적으로 증가)을 기준으로, 네임스페이스·필터 성능을 실무적으로 튜닝하는 방법을 정리합니다.
관련해서 Milvus의 인덱스 파라미터 자체 튜닝은 아래 글이 더 깊습니다.
멀티테넌시 모델 3가지: 무엇을 격리할 것인가
멀티테넌시는 결국 “쿼리 스코프를 어디서 끊을지”의 문제입니다. 아래 3가지 모델을 먼저 결정하면, Pinecone·Milvus 모두에서 설계가 단순해집니다.
1) 인덱스(또는 컬렉션) 단위 격리
- 장점: 테넌트 간 간섭이 거의 없음(성능 예측 가능, 삭제/백업/복구 쉬움)
- 단점: 테넌트 수가 많을수록 오브젝트 수가 폭증(관리·비용 증가)
- 추천: 엔터프라이즈 소수 테넌트, 테넌트별 SLA가 강한 경우
2) 네임스페이스/파티션 단위 격리
- 장점: 논리적 격리와 운영 편의의 균형
- 단점: 파티션/네임스페이스가 너무 많아지면 메타데이터·컴팩션·캐시 효율 이슈
- 추천: 중간 규모 B2B SaaS, 테넌트 수 수백~수천
3) 공유 인덱스 + 메타데이터 필터로 격리
- 장점: 인덱스 수를 최소화(비용 효율)
- 단점: 필터가 병목이 되기 쉬움, 테넌트 skew가 심하면 “큰 테넌트”가 전체 성능을 망칠 수 있음
- 추천: B2C 다수 테넌트, 테넌트별 데이터가 작고 균등한 경우
현실적으로는 2)와 3)의 하이브리드가 가장 많습니다. 예를 들어 “대형 테넌트는 별도 네임스페이스/파티션(또는 별도 인덱스)로 승격” 같은 정책이 필요합니다.
Pinecone 멀티테넌시: namespace를 어떻게 쪼갤까
Pinecone에서 멀티테넌시는 보통 namespace로 시작합니다. 핵심은 “필터를 최소화하고, 네임스페이스로 최대한 스코프를 좁힌 뒤” 검색하는 것입니다.
Pinecone에서 흔한 병목 패턴
- 테넌트 격리를 메타데이터 필터로만 처리
tenant_id필터가 항상 붙음- 테넌트 수가 증가하며 필터 처리량이 증가
- p95가 점진적으로 상승하거나 특정 시간대에 스파이크
이때의 처방은 간단합니다.
- 가능하면
tenant_id는 필터가 아니라namespace로 올린다 - 필터는 “테넌트 내부의 추가 제약”에만 사용한다
네임스페이스 키 설계: tenant_id만으로 충분한가
가장 단순한 설계는 namespace = tenant_id 입니다.
- 장점: 쿼리마다 스캔 범위를 테넌트로 강제 축소
- 단점: 테넌트 내부에서 다시 환경(dev/prod), 문서 타입, 권한 스코프를 나누고 싶을 때 필터가 늘어남
그래서 실무에서는 다음 중 하나를 택합니다.
namespace = tenant_id+ 메타데이터 필터(권한, 문서타입)namespace = tenant_id:env처럼 2차원까지 네임스페이스에 반영
주의할 점은 “네임스페이스를 너무 세분화하면 핫/콜드 데이터가 섞여 캐시 효율이 나빠지고, 운영상 관리 포인트가 늘어난다”는 것입니다. 보통은 테넌트 스코프까지만 네임스페이스로 끊고, 그 외는 필터로 처리하는 쪽이 무난합니다.
Pinecone 쿼리 예시: 네임스페이스 우선, 필터 최소화
import { Pinecone } from "@pinecone-database/pinecone";
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index("docs");
type QueryInput = {
tenantId: string;
vector: number[];
topK: number;
tags?: string[];
docType?: "policy" | "manual" | "faq";
};
export async function queryTenant(input: QueryInput) {
const filter: Record<string, any> = {};
// 테넌트 격리는 namespace로 강제
// 필터는 테넌트 내부 조건만
if (input.docType) filter.docType = { $eq: input.docType };
if (input.tags?.length) filter.tags = { $in: input.tags };
const res = await index.namespace(input.tenantId).query({
vector: input.vector,
topK: input.topK,
includeMetadata: true,
...(Object.keys(filter).length ? { filter } : {}),
});
return res.matches ?? [];
}
튜닝 관점에서 중요한 점은 다음입니다.
namespace를 지정하지 않고filter로tenant_id를 거는 설계는 피하기- 필터 키의 카디널리티가 너무 높아지지 않게 관리(특히 사용자 단위 권한을 필터로 직접 표현하면 폭발)
권한(ACL) 필터는 “유저 단위”를 피하고 “그룹 단위”로
RAG에서 자주 하는 실수는 allowed_user_ids 같은 배열을 메타데이터에 넣고 $in으로 필터링하는 것입니다. 유저 수가 늘수록 메타데이터가 비대해지고 필터가 무거워집니다.
대안은 다음 중 하나입니다.
- 문서에
group_ids만 부여하고, 유저는 그룹에 매핑 - “검색 후보를 뽑은 뒤” 애플리케이션에서 최종 ACL 검증(단, 유출 방지 위해
topK를 보수적으로)
Milvus 멀티테넌시: collection·partition·필터의 균형
Milvus는 구조적으로 선택지가 더 많습니다.
collection: 스키마/인덱스 단위 격리partition: 같은 컬렉션 내 논리 구획(검색 시 파티션 지정 가능)- 스칼라 필터:
expr로 조건 지정
멀티테넌시에서 가장 중요한 튜닝 포인트는 “필터가 벡터 검색 전/후 어느 단계에서 얼마나 후보를 줄이는지”와 “파티션이 너무 많아졌을 때의 관리 비용”입니다.
Milvus에서 추천하는 기본 전략
- 테넌트 수가 적고 데이터가 큰 경우: 테넌트별
collection - 테넌트 수가 많고 데이터가 작은 경우: 공유
collection+partition또는 필터 - 대형 테넌트만 분리: 기본은 공유, 임계치 넘으면 테넌트 전용
collection으로 승격
파티션을 테넌트로 쓰는 경우의 장단점
partition = tenant_id는 직관적이지만, 테넌트 수가 수만 단위로 가면 파티션 메타데이터와 운영 복잡도가 커집니다. 또한 파티션별 데이터량 편차가 크면 컴팩션/세그먼트 관리가 비효율적일 수 있습니다.
실무 팁은 다음입니다.
- 파티션은 “테넌트”가 아니라 “테넌트 버킷”으로 설계
- 예:
partition = hash(tenant_id) % 256 - 쿼리 시에는 버킷 파티션만 지정하고,
expr로tenant_id를 추가로 제한
이 방식은 파티션 수를 제한하면서도 검색 범위를 크게 줄일 수 있습니다.
Milvus 스키마 예시: 테넌트 버킷 + 테넌트 필터
아래 예시는 tenant_bucket을 파티션 키처럼 쓰고, tenant_id는 스칼라 필터로 최종 격리하는 패턴입니다.
from pymilvus import (
connections, FieldSchema, CollectionSchema, DataType, Collection
)
connections.connect(alias="default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="tenant_id", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="tenant_bucket", dtype=DataType.INT64),
FieldSchema(name="doc_type", dtype=DataType.VARCHAR, max_length=32),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
]
schema = CollectionSchema(fields, description="multitenant docs")
col = Collection(name="docs_shared", schema=schema)
# 인덱스는 예시이며, 실제는 데이터/리콜 요구에 맞게 조정
col.create_index(
field_name="embedding",
index_params={
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
},
)
col.load()
쿼리 시에는 다음처럼 “버킷 파티션만 대상으로” 검색하도록 구성합니다.
import mmh3
def tenant_bucket(tenant_id: str, buckets: int = 256) -> int:
# 안정적인 해시를 쓰는 게 중요
return mmh3.hash(tenant_id, signed=False) % buckets
def search(col, tenant_id: str, vector, topk: int, doc_type: str | None = None):
bucket = tenant_bucket(tenant_id)
expr_parts = [f'tenant_id == "{tenant_id}"']
if doc_type:
expr_parts.append(f'doc_type == "{doc_type}"')
expr = " and ".join(expr_parts)
# 파티션 이름을 버킷 기반으로 미리 만들어 두는 운영이 필요
partition_name = f"b{bucket:03d}"
res = col.search(
data=[vector],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=topk,
expr=expr,
partition_names=[partition_name],
output_fields=["tenant_id", "doc_type"],
)
return res
이 접근의 장점은 다음입니다.
- 파티션 수를 고정(예: 256개)해 운영 복잡도를 제한
- 검색 시 불필요한 세그먼트 접근을 줄여 지연을 안정화
- 테넌트별 데이터가 작아도 “전체 컬렉션”을 훑는 상황을 피함
단점도 있습니다.
- 파티션을 미리 생성/관리해야 함(버킷 수 변경이 어려움)
- 테넌트가 특정 버킷에 쏠리면 그 버킷이 핫스팟이 될 수 있음(해시 선택과 버킷 수로 완화)
필터 성능 튜닝 체크리스트: Pinecone·Milvus 공통
멀티테넌시에서 필터 성능은 “쿼리당 비용”과 직결됩니다. 아래 항목은 벡터 DB가 달라도 그대로 적용됩니다.
1) 필터는 항상 “선택도”를 의식한다
선택도(selectivity)가 낮은 필터, 즉 대부분의 레코드가 조건을 만족하는 필터는 성능에 도움이 되지 않습니다.
- 나쁜 예:
is_active == true같은 거의 항상 참인 조건 - 좋은 예:
doc_type == "policy"처럼 후보를 확 줄이는 조건
테넌트 격리는 선택도가 매우 높아야 하므로, 가능한 한 namespace(Pinecone)나 partition_names(Milvus)처럼 “물리적 스코프 제한”으로 처리하는 편이 유리합니다.
2) 고카디널리티 키를 필터로 남발하지 않는다
user_id,session_id,request_id같은 값은 필터 키로 부적절한 경우가 많습니다.- 특히 “문서마다 allowed user 목록이 다름”은 최악의 패턴 중 하나입니다.
대신 다음을 고려하세요.
- 그룹/역할 기반으로 축약
- 앱 레벨에서 후처리 검증
- 대형 테넌트는 별도 인덱스로 분리
3) 테넌트 skew를 관측하고, 승격 정책을 둔다
멀티테넌시 성능을 깨는 주범은 “상위 1퍼센트 테넌트가 데이터의 80퍼센트를 차지”하는 skew입니다.
권장 정책 예시는 다음과 같습니다.
- 테넌트 벡터 수가 N을 넘으면 전용
namespace또는 전용collection/index로 승격 - 승격 테넌트는 더 공격적인 캐시/리소스 할당
- 공유 영역은 작은 테넌트 위주로 유지
4) 삭제가 잦으면 컴팩션/세그먼트 정책을 점검한다
RAG에서 문서 재색인, 만료, 권한 변경이 잦으면 “삭제 후 공간 회수”가 지연되면서 성능이 흔들릴 수 있습니다.
- Pinecone: 네임스페이스 단위 삭제 전략(테넌트 전체 리셋) 같은 운영 패턴이 효율적일 때가 있음
- Milvus: 삭제 후 세그먼트가 누적되면 검색 성능과 메모리 효율이 떨어질 수 있어 컴팩션 전략을 점검
5) 필터 조합 수를 제한한다
필터 조건이 조합 폭발을 일으키면 캐시가 무력화되고, 쿼리 플래닝 비용이 늘어납니다.
- “자주 쓰는 필터 조합”을 3~5개 정도로 표준화
- 그 외 복잡한 조건은 앱 레벨 후처리로 넘기는 것을 검토
실전 설계 예시: B2B RAG에서 흔한 요구사항 매핑
요구사항을 벡터 DB 개념으로 매핑하면 결정이 빨라집니다.
요구사항 A: 테넌트별 완전 격리 + 삭제/백업 단순화
- Pinecone: 테넌트별 인덱스 또는 테넌트별
namespace+ 별도 스토리지 정책 - Milvus: 테넌트별
collection
요구사항 B: 테넌트 수 수천, 대부분 소형, 일부 대형
- Pinecone: 기본은
namespace = tenant_id, 대형 테넌트는 별도 인덱스 고려 - Milvus: 공유
collection+ 버킷 파티션 +tenant_id필터, 대형 테넌트는 전용collection승격
요구사항 C: 문서 권한이 자주 바뀜(ACL 업데이트 빈번)
- 가능한 한 “문서 메타데이터 재작성”을 줄인다
- 권한은 그룹 단위로 단순화
- 변경이 잦은 권한은 벡터 DB가 아니라 별도 권한 서비스에서 최종 검증
관측(Observability): 멀티테넌시 튜닝은 지표 없이는 불가능
멀티테넌시 튜닝은 “평균 지연”이 아니라 “꼬리 지연(p95/p99)”과 “테넌트별 분포”를 봐야 합니다.
추천 지표:
- 테넌트별
query_count,p95_latency_ms,timeout_rate - 필터 사용 여부별 지연(필터 없는 쿼리 vs 필터 있는 쿼리)
- 테넌트별 벡터 개수, 최근 삽입/삭제량
- 상위 테넌트가 전체 QPS/데이터에서 차지하는 비율
이 지표를 기반으로 “승격 정책(전용 인덱스/컬렉션)”과 “버킷 수 조정” 같은 의사결정을 자동화할 수 있습니다.
마무리: 결론은 ‘테넌트 격리는 물리 스코프로, 필터는 최소로’
Pinecone·Milvus에서 멀티테넌시 성능을 안정화하는 핵심은 동일합니다.
- 테넌트 격리는 가능한 한
namespace(Pinecone) 또는partition_names/컬렉션(Milvus)처럼 물리적 스코프로 제한 - 필터는 테넌트 내부의 “정말 필요한” 조건만 남기고, 선택도와 카디널리티를 관리
- 테넌트 skew를 전제로 설계하고, 대형 테넌트를 전용 리소스로 승격하는 운영 정책을 둔다
이 3가지만 지켜도 “테넌트 수가 늘수록 점점 느려지는” 전형적인 멀티테넌시 문제를 상당 부분 예방할 수 있습니다.