논리적 파티셔닝 부터 임베딩 모델 편향성 검증까지는 사실 이론적이 좀 강하게 나오고 실무에서 내가 경험해보지 못한 부분이라 이론에 따라가는 면이 많아서 아직 제가 스스로 의견을 내기에는 데이터가 부족합니다. 이해해 주세요.
대규모 엔터프라이즈 환경에서 검색 증강 생성(RAG) 인프라를 설계할 때, 단일 데이터 저장소에 모든 문서를 중앙 집중식으로 적재하는 방식은 성능 오버헤드와 데이터 통제권 측면에서 여러 기술적 한계를 유발합니다. 수천만 건의 고차원 벡터 데이터가 한 영역에 믹스될 경우 비연속적인 인덱스 스캔으로 인해 검색 Latency가 기준선 위로 치솟게 되며, 서로 다른 권한이나 소속을 가진 유저 간의 데이터 교차 유출 사고 위험이 기하급수적으로 증가합니다.
Bastion-RAG 프레임워크의 검색 서빙 및 데이터 전처리 코어인 Navigator 모듈(navigator/chunker.py, navigator/searcher.py, navigator/orchestrator.py, navigator/router.py)은 이 인프라 병목을 해결하기 위해 다차원 논리적 파티셔닝(Logical Partitioning) 아키텍처를 강제 구현합니다. 이 포스트에서는 데이터 카테고리별 컬렉션 분리 라우팅, Qdrant 벡터 사전 필터링(Pre-filtering) 기반의 테넌트 분리, 그리고 문장 구조 분할을 담당하는 청커(Chunker) 파이프라인의 하부 구현 스펙을 분석해보겠습니다.

URL Site > https://github.com/zafrem/bastion-navigator
시리즈명 : Bastion-RAG – Project 보안 RAG
- [Bastion-RAG] Project 보안 RAG
- [Bastion-RAG 0] Get help from AI (아키텍처 설계)
- [Bastion-RAG 1 – Sentinel]
- [Bastion-RAG 2 – Vault]
- [Bastion-RAG 3 – Navigator]
- 하이브리드 리랭킹
- 논리적 파티셔닝 – Here!
- [Bastion-RAG 4 – Archor]
- 임베딩 노이즈 주입
- 임베딩 모델 편향성 검증
- [Bastion-RAG 5 – Tracker]
- 데이터 리니지 추적
- Honey-token 주입
- [Bastion-RAG Demo]
목차
1. 독립된 다차원 논리 파티셔닝 구조 스펙
Navigator 모듈의 논리적 파티셔닝은 데이터의 비즈니스적 속성을 분류하는 축과 인프라의 보안 경계를 설정하는 축이 상호 직교하는 독립적인 형태로 설계되었습니다.

| 파티셔닝 축 | 제어 매커니즘 | 실행 및 강제 시점 |
| 데이터 카테고리 라우팅 | 데이터 카테고리별 독립 Qdrant 컬렉션 분리 | 인덱스 빌드 및 검색 실시간 런타임 |
| 테넌트 격리 가드레일 | Qdrant 포인트 페이로드 내 tenant_id 사전 필터 주입 | 데이터베이스 검색 쿼리 실행 직전 |
이 두 가지 메커니즘은 서로 완벽하게 독립되어 동작합니다. 예를 들어 특정 기업의 고객 대응 매뉴얼 문서는 customer_docs라는 물리 컬렉션 파일 내에 라우팅되어 저장되지만, 쿼리 구동 시점에 주입되는 고유 tenant_id 필터링 제약 조건으로 인해 동일 컬렉션 내에서도 타 테넌트의 청크 공간은 메모리 스캔 범위에서 원천 배제됩니다.

2. 데이터 카테고리 컬렉션 라우팅 아키텍처
Navigator 모듈은 데이터의 민감도 및 활용 목적에 따라 인프라 검색 공간의 크기를 정밀하게 축소하기 위해 컬렉션 매핑 분기 메커니즘을 구동합니다.
2.1 인덱스 타임 컬렉션 타겟 분기 스펙
데이터 적재(Ingress) 시점에 IndexRequest.category 필드로 전달된 상위 거버넌스 기호(예: customer_data, manufacturing_data)는 아래 정적 매핑 규칙에 따라 물리 데이터베이스 스페이스 명칭으로 치환됩니다.
Python
# navigator/orchestrator.py
# 데이터 카테고리 기호를 실제 Qdrant 독립 물리 컬렉션 명칭으로 바인딩
_CATEGORY_TO_COLLECTION: dict[str, str] = {
"customer_data": "customer_docs",
"manufacturing_data": "manufacturing_docs",
"hr_data": "hr_docs",
}
이 매핑 결과에 근거하여 파이프라인은 신규 데이터 삽입 전 타겟 컬렉션의 유무를 조회하고, 분산 벡터 차원 크기(1024차원)에 맞춰 원천 격리 보관 영역을 빌드합니다.
Python
# navigator/orchestrator.py
def index_document(self, req: IndexRequest) -> IndexResponse:
# ... 청킹 및 고밀도 임베딩 연산 동기식 수행 ...
# 지정된 카테고리가 누락된 경우 기본 격리 영역인 "default"로 수렴 유도
collection = req.category or "default"
# 타겟 컬렉션 유무를 조사하고 HNSW 그래프 기성 설정을 복사 생성
self._searcher.ensure_collection(collection, vector_size=len(vectors[0]))
points = []
for chunk, vec in zip(chunks, vectors):
points.append({
"id": chunk.stable_uuid(), # chunk_id 기반 결정론적 UUID5 변환
"vector": vec,
"payload": {
"document_id": req.document_id,
"chunk_id": chunk.chunk_id,
"tenant_id": req.tenant_id,
"category": req.category,
"heading_path": " > ".join(chunk.heading_path),
"char_start": chunk.char_start,
"char_end": chunk.char_end,
"last_indexed": last_indexed,
"permitted_purposes": ",".join(req.permitted_purposes),
"content": chunk.content,
**req.metadata,
},
})
self._searcher.upsert(collection, points)
2.2 검색 타임 권한 컬렉션 교차 제약 프로세스
유저가 자연어 검색을 호출하면 Navigator 오케스트레이터는 무조건 전역 검색을 수행하지 않고, 사용자의 신원 토큰에서 추출된 허용 권한 범위를 기준으로 탐색 공간을 제한하는 3단계 전처리 게이트웨이를 거치게 됩니다.
Python
# navigator/orchestrator.py
# Step 1: 상위 Vault RBAC/OPA 컨텍스트에서 추출된 사용자의 허용 카테고리 배열 로드
allowed = self._resolve_permissions(req)
# 결과 예시: ["customer_data", "manufacturing_data"]
# Step 2: 허용 카테고리를 실제 물리 컬렉션 스페이스 배열로 변환
permission_collections = self._collections_for_categories(allowed)
# 결과 예시: ["customer_docs", "manufacturing_docs"]
# Step 3: MR-01 정규식 라우터가 도메인 단어 일치 여부를 판정하여 대상 범위를 추가 압축
collections, opts = self._do_route(req.query, permission_collections, opts, tc)
# 결과 예시: ["manufacturing_docs"] (최종 연산 탐색 공간 1개로 축소 완료)
이 구조 하에서는 사용자의 세션 권한이 hr_docs 컬렉션을 허용하지 않는다면, 사용자가 아무리 우회 질의를 전개하더라도 라우터 단계에서 물리적 파일 접근 링(Ring) 자체에 해당 컬렉션 명칭이 원천 배제되므로 구조적인 접근 제어가 완벽히 달성됩니다.
3. 테넌트 격리 사전 필터링(Pre-filtering) 기법
컬렉션 수준의 격리 장벽만으로는 단일 기업 내 부서 간 데이터 분리나 SaaS 환경의 복수 고객사 데이터를 완벽히 방어할 수 없습니다. 동일 물리 영역 내에 공존하는 수많은 레코드 조각 간의 실시간 메모리 장벽을 치기 위해, Navigator 모듈은 모든 근사 근접 이웃(ANN) 탐색 쿼리 실행 직전 데이터베이스 레벨에서 사전 필터링(Pre-filtering)을 강제 집행합니다.
Python
# navigator/searcher.py
def vector_search(
self,
collection: str,
vector: list[float],
filters: dict[str, str],
top_k: int,
min_score: float = 0.0,
) -> list[SearchResult]:
from qdrant_client.models import Filter, FieldCondition, MatchValue
qdrant_filter = None
if filters:
# 모든 조건절은 내부적으로 logical AND (must) 비트 매칭 연산으로 강제 묶임
# tenant_id 가 최우선 키로 자동 바인딩됨
qdrant_filter = Filter(
must=[FieldCondition(key=k, match=MatchValue(value=v))
for k, v in filters.items()]
)
hits = self._client.search(
collection_name=collection,
query_vector=vector,
query_filter=qdrant_filter, # Qdrant 내부 HNSW 그래프 탐색 시작 노드 판정에 즉시 주입
limit=top_k,
)
return [_to_search_result(h) for h in hits]
3.1 사전 필터링(Pre-filtering)과 사후 필터링(Post-filtering)의 수학적 차이

일반적인 저성능 RAG 시스템이 채택하는 사후 필터링은 임베딩 벡터 간 유사도 거리를 기준으로 모든 후보군 문서 top k 개수를 먼저 뽑아온 뒤, 메모리 루프를 돌며 if record.tenant_id != user.tenant_id 조건문으로 남의 데이터를 지우는 방식을 취합니다. 이 경우, 탑케이 범위 내에 타 테넌트 데이터가 다수 밀집되어 있다면 유저 권한에 맞는 실제 결과물 개수가 단 1~2건에 불과하게 되거나 검색 결과가 통째로 증발하는 결함이 발생합니다.
Navigator 모듈이 구동하는 사전 필터링은 인덱스 그래프 탐색 자체를 개시하기 전, tenant_id == "acme-corp" 비트맵 마스킹 연산을 데이터베이스 코어 단에서 선제 집행합니다.
따라서 그래프 내부의 유효 연결 에지(Edge)를 타고 이동하는 연산 범위 자체가 해당 테넌트의 데이터 포인트 자산으로만 완벽하게 제약되며, 타 테넌트의 고차원 행렬 정보는 CPU 레지스터 단에서 단 1바이트도 비교 연산이 유발되지 않으므로 보안 누출 공백을 원천 소거합니다.
4. 정규식 라우터 기반의 도메인 축소 메커니즘 (MR-01)
사용자 권한이 허가한 전체 컬렉션 범위 내에서도 질의의 의도에 부합하는 컬렉션만 정밀 타겟팅하기 위해, router.py 패키지는 가벼운 언어 지표로 동작하는 토픽 프록시 매칭 기법을 적용합니다.
Python
# navigator/router.py
# 한국어와 영미권 핵심 도메인 단어가 복합 결합된 정적 가이드 딕셔너리 명세
_COLLECTION_DOMAINS: dict[str, list[str]] = {
"customer_docs": ["customer", "account", "purchase", "고객", "계좌", "구매", "주문"],
"manufacturing_docs": ["defect", "production", "factory", "line", "worker", "불량", "생산", "공장", "공정"],
"hr_docs": ["employee", "salary", "leave", "hr", "직원", "급여", "연차", "인사", "휴가", "근태"],
}
4.1 Keyword Hit-Counting 알고리즘 소스 구현
사용자가 질의한 평문 스트링 내에서 위 딕셔너리에 기재된 문자열 파편이 매칭되는 빈도수를 동기식 스캔 루프로 연산합니다.
Python
# navigator/router.py
def _select_collections(
self,
query: str,
available: list[str],
) -> tuple[list[str], list[str]]:
q_lower = query.lower()
hits: dict[str, int] = {}
for col in available:
keywords = _COLLECTION_DOMAINS.get(col, [])
# 질의문 문자열 내에 각 컬렉션별 도메인 단어가 포함되었는지 단순 서브스트링 매칭 누적
hits[col] = sum(1 for kw in keywords if kw in q_lower)
if max(hits.values(), default=0) == 0:
# 도메인 단어가 단 하나도 검출되지 않은 모호한(AMBIGUOUS) 질의인 경우 보수적 접근 정책 집행
# 임의로 데이터를 생략하지 않고 권한이 허용한 모든 컬렉션을 Fail-open으로 개방 스캔
return list(available), []
selected = [c for c, h in hits.items() if h > 0]
excluded = [c for c, h in hits.items() if h == 0]
return selected, excluded
만약 유저가 "공장 라인 A의 불량률 조회"라는 문장을 던졌다면, manufacturing_docs 컬렉션의 히트 카운트 점수만 올라가게 되므로 나머지 customer_docs와 hr_docs 영역에 불필요하게 쿼리를 병렬 전송하여 유발되는 자원 낭비(DB CPU 병목)를 입구에서 즉각 컷오프 처리합니다.
5. 문장 구조 보존형 정적 청킹(Chunker) 파이프라인 명세
벡터 데이터베이스는 문서 통째가 아니라 최소 정보 단위인 청크(Chunk) 레벨로 조회를 수행하므로, 청킹 알고리즘이 문맥 장벽을 어떻게 보존하느냐가 논리 파티셔닝의 최종 품질을 결정짓습니다. navigator/chunker.py 가 강제하는 핵심 3대 제약 조건과 구현 코드는 다음과 같습니다.

Python
@dataclass
class ChunkerConfig:
max_chars: int = 1200 # 최대 글자수 상한 제어선 (BGE-M3 컨텍스트 한계 최적화)
overlap_chars: int = 120 # 파쇄 경계선상의 문맥 전달을 위한 슬라이딩 오버랩 길이
min_chars: int = 80 # 파편화된 자투리 청크 병합을 위한 최소 글자수 임계치
5.1 블록 파서(Block Parser) 기반의 테이블 및 코드 펜스 구조 영구 보존
일반적인 글자 수 기반의 단순 청킹은 마크다운 테이블 코드 라인 한가운데를 무작위로 절단하여 임베딩 데이터 수치 공간을 오염시킵니다. 이를 방지하기 위해 Navigator 청커는 평문을 파싱할 때 _parse_blocks() 전처리 엔진을 가동하여 테이블(|) 및 코드 펜스(““ “ ) 단락 전체를 단일 구조적 불변 블록(_Block)으로 강제 캡슐화 처리하여 중간 절단을 영구 금지합니다.
5.2 헤딩 트리 경로 박제 및 임베딩 노이즈 방어 (embed_text)
추출된 문자열 단락들은 훗날 독립 청크 파일로 보관될 때 원래 문서의 어느 장/절에 소속되어 있었는지에 대한 구조적 정보(Context)를 잃어버리게 됩니다. 이를 보완하기 위해 각 청크가 생성되는 매 순간 상위 헤딩 스택 배열을 복사 복제하여 고유 자산 필드 헤더에 직접 결합합니다.
Python
# navigator/chunker.py
@dataclass
class Chunk:
chunk_id: str
parent_document_id: str
chunk_index: int
content: str
heading_path: list[str] = field(default_factory=list) # 상위 마크다운 헤딩 트리 스택 정보
char_start: int = 0
char_end: int = 0
func embed_text(self) -> str:
"""실제 고성능 임베딩 모델에 주입하기 직전 텍스트 버퍼를 동적 재조합"""
if self.heading_path:
# 헤딩 계층 명세를 기호 스니펫 문장으로 변환하여 본문 최전방에 박제 결합
breadcrumb = " > ".join(self.heading_path)
return f"{breadcrumb}\n\n{self.content}"
return self.content
func stable_uuid(self) -> str:
"""chunk_id를 소스로 삼아 정적 네임스페이스 기반 UUID5 결정론적 변환 실행"""
return str(uuid.uuid5(_UUID_NS, self.chunk_id))
이 embed_text() 처리를 통과한 데이터셋은 만약 본문 내용 자체는 완전히 동일하더라도 상위 경로가 ## Security > ### Injection Defense 인지 혹은 전혀 다른 카테고리 장에 소속되어 있는지에 따라 분산 임베딩 공간 상의 벡터 가중치 좌표가 판이하게 분리 유도됩니다.
따라서 RAG 유사도 검색 정합성이 극대화됩니다. 또한 stable_uuid() 구현을 통해 문서 재색인(Re-indexing)을 실행하더라도 동일 청크 ID는 언제나 동일 공간 좌표 ID로 매핑 덮어쓰기(Upsert) 처리되므로 고스트 청크 파편 자산이 누적 보관되는 메모리 오염 현상을 원천 방어합니다.
6. 결론: 인프라 오버헤드 통제와 거버넌스 가드레일 수립
Navigator 모듈이 구동하는 논리적 파티셔닝 전략은 단순한 소스 코드 로직의 분기가 아니라 벡터 인프라의 특수성과 행렬 연산 거동을 치밀하게 계산하여 구축된 하드웨어 최적화 장벽입니다.
KMS 마스터 키 분리에 기반한 암호학적 격리 계층인 Vault 와의 대칭형 연동 구조 속에서, 최전방에서 수립된 메타데이터 조건절을 쿼리 실행 직전 비트맵 레벨 사전 필터링(Pre-filtering)으로 강제 주입함으로써 테넌트 간 간섭 가능성을 수학적으로 영구 차단합니다.
글자 수 단위 파쇄 경계면을 방어하는 문장 구조 보존형 청커 아키텍처와 프로메테우스 수집 계보 연동을 통해, Navigator 모듈은 분산 스캔 레이턴시 마찰 비용을 최소화하여 글로벌 하이브리드 검색 처리 지연 시간 < 50ms 이내의 엔터프라이즈 급 가용성 표준을 완벽하게 충족하고 있습니다. 데이터 거버넌스 법률 사양을 철저히 충족하면서 고성능 AI 추론 파이프라인의 검색 정합성을 극한으로 끌어올려야 하는 플랫폼 아키텍트들에게 본 시스템 파티셔닝 명세는 프로덕션에 즉시 투입 가능한 명확한 엔지니어링 해법이 될 것입니다.