Published on

Pinecone·Milvus 필터 누락을 막는 인덱스 설계

Authors

RAG나 시맨틱 검색을 운영하다 보면 “필터를 걸었는데도 다른 테넌트 문서가 섞인다”, “최신 문서만 보고 싶은데 오래된 문서가 뜬다” 같은 사고가 종종 발생합니다. 대개 애플리케이션 버그로 보이지만, 실제로는 인덱스 설계와 메타데이터 모델링이 원인인 경우가 많습니다.

이 글에서는 Pinecone·Milvus를 기준으로 **필터링 누락(혹은 기대와 다른 필터 결과)**이 생기는 지점을 분해하고, 운영에서 사고를 줄이는 인덱스 설계 패턴을 제시합니다.

필터링 누락이 위험한 이유

필터가 누락되면 단순히 검색 품질이 떨어지는 수준이 아닙니다.

  • 권한/테넌시 침해: tenant_id 필터가 빠지면 다른 고객 데이터가 노출될 수 있습니다.
  • 정합성 붕괴: is_deleted=false, status=published 같은 조건이 누락되면 폐기 문서가 재등장합니다.
  • 평가 지표 왜곡: 오프라인 평가에서는 필터를 적용했는데 온라인에서는 누락되면, 실험 결과가 무의미해집니다.

따라서 필터는 “옵션”이 아니라 **검색 파이프라인의 계약(contract)**으로 취급해야 합니다.

Pinecone·Milvus에서 흔한 필터 누락 시나리오

1) 업서트 시 메타데이터 일부가 비어 있음

Pinecone에서는 벡터마다 metadata를 넣고 필터링합니다. 그런데 업서트 시점에 tenant_iddoc_type이 누락되면, 이후 필터가 의도대로 동작하지 않습니다(필터 조건과 매칭되지 않아 결과에서 빠지는 게 정상인데, 반대로 앱에서 “필터가 먹지 않는다”로 오해하기도 합니다).

Milvus는 컬렉션 스키마가 명시적이라 필드 누락이 덜하지만, JSON 필드나 nullable 필드를 섞으면 유사한 문제가 생깁니다.

예방: 업서트 전에 메타데이터 스키마 검증을 강제하세요(서버에서).

2) 필터 타입 불일치(문자열 vs 숫자)

tenant_id를 어떤 문서는 문자열, 어떤 문서는 숫자로 넣는 순간 필터는 “부분적으로만” 동작합니다. 특히 이벤트 기반 파이프라인에서 생산자가 여러 개인 경우 자주 발생합니다.

예방: 타입을 고정하고, 변환은 인입 경계에서 한 번만.

3) 필터를 검색 단계가 아니라 후처리에서 적용

가장 위험한 패턴입니다.

  • 벡터 검색으로 topK=50을 뽑고
  • 애플리케이션에서 tenant_id로 걸러서
  • 남은 것 중 상위 k를 반환

이렇게 하면, 필터가 빡센 경우 결과가 텅 비거나, 반대로 “필터가 누락된 것처럼” 보이는 이상 현상이 나옵니다. 왜냐하면 필터를 고려하지 않은 topK 후보군이 이미 편향되어 있기 때문입니다.

예방: 필터는 반드시 DB의 검색 연산 내부에서 적용하고, 필요하면 topK를 키우는 방식으로 리콜을 보정하세요.

4) 인덱스/파티션 설계가 필터와 충돌

Milvus는 파티션/샤딩 설계를 어떻게 하느냐에 따라 특정 필터 조건이 “사실상 필수”가 됩니다. 예를 들어 테넌트별 파티션을 만들었는데 검색 시 파티션을 지정하지 않으면, 전체 스캔에 가까운 비용이 발생하거나 운영 정책상 막아야 합니다.

Pinecone도 네임스페이스를 테넌트 경계로 쓸 수 있는데, 네임스페이스와 메타데이터 필터를 중복 설계하면 “어떤 경계를 신뢰해야 하는지”가 불명확해져 실수 확률이 올라갑니다.

예방: 테넌시 경계는 한 레이어에서 강제하고(네임스페이스 혹은 파티션), 나머지는 보조 필터로만 사용하세요.

인덱스 설계 원칙: 필터는 ‘데이터 모델’로 강제한다

원칙 1) 필수 필터 키는 절대 nullable로 두지 않는다

  • tenant_id
  • visibility
  • is_deleted
  • doc_status
  • source

이런 키는 “있을 수도 있는 속성”이 아니라 **항상 존재해야 하는 축(axis)**입니다.

  • Pinecone: 업서트 시 누락되면 실패시키거나 기본값을 강제
  • Milvus: 스키마에서 해당 필드를 필수로 두고, 기본값 전략을 명확히

원칙 2) 테넌시 경계는 1차 격리로, 메타 필터는 2차 조건으로

권장 패턴은 다음 중 하나입니다.

  • Pinecone: namespace=tenant_id + 메타데이터로 doc_type, lang, updated_at_bucket
  • Milvus: partition=tenant_id + 스칼라 필드 필터로 doc_type, lang, is_deleted

테넌시를 메타데이터 필터만으로 처리하면, 애플리케이션에서 필터 누락 시 치명적입니다. 반면 네임스페이스/파티션을 사용하면 “쿼리 자체가 다른 테넌트로 넘어갈 여지”가 줄어듭니다.

원칙 3) 고카디널리티 필터와 저카디널리티 필터를 분리

  • 저카디널리티: lang, doc_type, status
  • 고카디널리티: user_id, doc_id, session_id

고카디널리티 필터를 남발하면 인덱스 효율이 떨어지고, 운영 중 특정 조건에서만 지연이 튀는 현상이 생깁니다. 가능하면 고카디널리티는

  • 네임스페이스/파티션 키로 승격하거나
  • 별도 인덱스로 분리하거나
  • 검색 후 랭킹 단계에서 사용(단, 권한/테넌시 제외)

같은 전략을 취합니다.

원칙 4) 시간 조건은 “필터”보다 “버킷/버전”으로 다루기

updated_at 같은 연속 값은 필터로 걸면 범위 조건이 잦아지고 비용이 커질 수 있습니다. 운영에서는 다음이 실용적입니다.

  • updated_at_bucketyyyy-mm 혹은 yyyy-ww로 저장
  • “최근 3개월” 같은 조건은 버킷으로 좁히고, 세부는 애플리케이션에서 정렬

Pinecone 설계 예시: 네임스페이스 + 필수 메타데이터

아래는 Pinecone에서 테넌트 경계는 네임스페이스, 그 외는 메타데이터 필터로 강제하는 예시입니다.

import { Pinecone } from "@pinecone-database/pinecone";

type VectorMeta = {
  tenant_id: string;
  doc_id: string;
  doc_type: "faq" | "policy" | "ticket";
  lang: "ko" | "en";
  is_deleted: boolean;
  updated_bucket: string; // e.g. "2026-02"
};

function assertMeta(m: Partial<VectorMeta>): asserts m is VectorMeta {
  const required = [
    "tenant_id",
    "doc_id",
    "doc_type",
    "lang",
    "is_deleted",
    "updated_bucket",
  ] as const;
  for (const k of required) {
    if (m[k] === undefined || m[k] === null) {
      throw new Error(`missing metadata: ${k}`);
    }
  }
}

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

export async function upsertDocVector(input: {
  id: string;
  values: number[];
  meta: Partial<VectorMeta>;
}) {
  assertMeta(input.meta);

  await index.namespace(input.meta.tenant_id).upsert([
    {
      id: input.id,
      values: input.values,
      metadata: input.meta,
    },
  ]);
}

export async function query(input: {
  tenant_id: string;
  vector: number[];
  doc_type?: VectorMeta["doc_type"];
  lang?: VectorMeta["lang"];
}) {
  const filter: Record<string, any> = {
    is_deleted: false,
  };
  if (input.doc_type) filter.doc_type = input.doc_type;
  if (input.lang) filter.lang = input.lang;

  return index.namespace(input.tenant_id).query({
    vector: input.vector,
    topK: 20,
    filter,
    includeMetadata: true,
  });
}

핵심은 다음입니다.

  • tenant_id를 네임스페이스로 승격해 “필터 누락”의 치명도를 낮춤
  • 필수 메타데이터는 런타임에서 스키마 검증
  • is_deleted=false 같은 안전 필터는 기본으로 강제

추가로 Node.js 런타임에서 경로/환경 구성하다가 실수로 인덱스 이름을 잘못 참조하는 경우도 많은데, ESM 환경에서 경로 처리 이슈가 있으면 Node.js ESM에서 __dirname 없는 에러 해결법도 함께 점검해 두면 배포 사고를 줄일 수 있습니다.

Milvus 설계 예시: 스키마 + 파티션 + 표현식 필터

Milvus는 컬렉션 스키마가 명시적이고, 스칼라 필드에 대해 표현식 기반 필터를 적용합니다. 권장 패턴은 테넌트 파티션 지정 + 스칼라 필터입니다.

from pymilvus import (
    connections, FieldSchema, CollectionSchema, DataType,
    Collection, utility
)

connections.connect(alias="default", uri="http://localhost:19530")

collection_name = "docs_v1"

if not utility.has_collection(collection_name):
    fields = [
        FieldSchema(name="doc_pk", dtype=DataType.VARCHAR, is_primary=True, max_length=64),
        FieldSchema(name="tenant_id", dtype=DataType.VARCHAR, max_length=64),
        FieldSchema(name="doc_type", dtype=DataType.VARCHAR, max_length=16),
        FieldSchema(name="lang", dtype=DataType.VARCHAR, max_length=8),
        FieldSchema(name="is_deleted", dtype=DataType.BOOL),
        FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
    ]
    schema = CollectionSchema(fields, description="RAG docs")
    col = Collection(collection_name, schema=schema)

    # 벡터 인덱스는 예시
    col.create_index(
        field_name="embedding",
        index_params={"index_type": "HNSW", "metric_type": "IP", "params": {"M": 16, "efConstruction": 200}},
    )
else:
    col = Collection(collection_name)

# 테넌트별 파티션을 만들고, 검색 시 반드시 지정

def ensure_partition(tenant_id: str):
    if not col.has_partition(tenant_id):
        col.create_partition(tenant_id)


def search(tenant_id: str, qvec: list[float], doc_type: str | None = None):
    expr = "is_deleted == false"
    if doc_type:
        expr += f" and doc_type == \"{doc_type}\""

    return col.search(
        data=[qvec],
        anns_field="embedding",
        param={"metric_type": "IP", "params": {"ef": 64}},
        limit=20,
        expr=expr,
        partition_names=[tenant_id],
        output_fields=["tenant_id", "doc_type", "lang", "is_deleted"],
    )

여기서 중요한 포인트:

  • partition_names를 강제하지 않으면 테넌시 경계가 애매해집니다.
  • expr는 “항상 적용되어야 하는 안전 조건”을 기본으로 포함합니다.

필터 누락을 구조적으로 막는 “검색 API 계약” 패턴

애플리케이션 코드에서 필터를 매번 조립하면, 언젠가 누락됩니다. 다음 패턴을 추천합니다.

  1. 검색 요청 DTO에 tenant_id를 필수로 둔다
  2. 서버 내부에서만 필터를 조립한다
  3. “안전 필터”는 함수 내부에서 무조건 적용한다
  4. 로그에 최종 필터를 남기고, 샘플링 검증을 돌린다

TypeScript로 예를 들면:

type SearchRequest = {
  tenant_id: string;
  query: string;
  doc_type?: "faq" | "policy" | "ticket";
};

function buildSafetyFilter(req: SearchRequest) {
  return {
    tenant_id: req.tenant_id, // 메타로도 중복 저장했다면 이중 안전장치
    is_deleted: false,
    ...(req.doc_type ? { doc_type: req.doc_type } : {}),
  };
}

이렇게 “필터 빌더”를 단일화하면, 신규 엔드포인트가 추가되어도 안전 필터가 자동 상속됩니다.

운영 체크리스트: 필터 누락을 조기에 잡는 법

1) 업서트 검증 실패율을 지표로 둔다

  • 메타데이터 누락/타입 불일치로 업서트가 실패한 건 “좋은 실패”입니다.
  • 실패율이 갑자기 늘면 생산자 스키마가 깨진 겁니다.

2) 카나리 쿼리로 테넌시 누출을 감시한다

예: 특정 테넌트에서 절대 나올 수 없는 doc_id를 주기적으로 검색해, 결과에 섞이면 즉시 알람.

3) 필터 적용 여부를 로그로 남긴다

최종적으로 DB에 전달된 필터를 로그에 남기고, 요청 ID로 추적 가능하게 하세요.

4) 성능 튜닝은 “필터 포함” 상태로 벤치마크한다

벡터 검색은 필터 유무에 따라 레이턴시 분포가 크게 변합니다. DB 단에서 필터를 적용하면 CPU/메모리 사용량 패턴도 달라집니다. 메모리 압박이 큰 환경에서는 모델/임베딩 단계의 OOM도 함께 고려해야 하며, GPU를 쓴다면 CUDA OOM? PyTorch 메모리 단편화 해결법처럼 단편화 이슈가 검색 품질 이전에 장애를 만들 수 있습니다.

언제 “인덱스를 분리”해야 하나

다음 조건이면 단일 인덱스에서 필터로 버티기보다, 인덱스를 분리하는 편이 안전합니다.

  • 테넌트별 데이터 규모 편차가 너무 큼(핫 테넌트가 전체 성능을 망침)
  • 권한 모델이 복잡해 필터 조합이 폭발함
  • 보관 정책이 다름(테넌트별 TTL, 삭제 정책)

분리 전략은 보통 아래 중 하나입니다.

  • 테넌트별 인덱스(가장 강력, 운영 비용 증가)
  • 환경/도메인별 인덱스(예: prod_docs, support_tickets)
  • “최근 데이터” 전용 인덱스 + “전체” 인덱스(시간 조건 최적화)

마무리

Pinecone·Milvus에서 필터링 누락 문제는 “쿼리 한 줄”의 실수가 아니라, 대개 테넌시 경계, 메타데이터 스키마, 파티션/네임스페이스 전략이 불명확해서 생깁니다.

정리하면:

  • 테넌시 경계는 네임스페이스/파티션으로 1차 격리
  • 필수 메타데이터는 nullable 금지, 업서트 시 검증
  • 안전 필터는 검색 API 내부에서 강제
  • 필터 포함 상태로 성능/정확도를 측정

이 원칙대로 인덱스를 설계하면 “필터가 가끔 빠지는” 류의 운영 사고를 구조적으로 줄일 수 있습니다.