"RAG 핵심 학습 (7/26) — 메타데이터 설계: 검색 필터·권한·출처"
청크가 만들어지고 보강도 끝났다. 이제 청크 옆에 무엇을 같이 저장할지를 정한다 — 이것이 검색을 사용자·시간·문서 종류로 가르는 핵심이다.
메타데이터는 RAG의 조용한 절반이다. 청크 본문 옆에 함께 저장된 필드가 없으면 "이번 분기 보안 정책만", "이 사용자가 접근 가능한 문서만", "최신 버전만" 같은 질문 자체가 불가능하다. 좋은 메타데이터 설계는 검색 정확도를 5~30%p 끌어올리고, 잘못된 설계는 권한 사고와 출처 추적 실패를 만든다. 본 편은 7개 핵심 필드 — document_id / chunk_id / version / page / section / security_level / namespace — 와 함께 권한 모델·출처 추적·필터 비용을 한 자리에서 푼다.
0. Prerequisites
- 3편(Ingestion 설계)의 Document Boundary, Filter-first Retrieval.
- 5~6편의 청크 생성 흐름.
- 벡터DB가 "메타데이터 필터"를 지원한다는 사실 — 9편에서 깊이 다룬다.
1. 학습 목표
- 7개 핵심 메타데이터 필드의 역할을 한 줄로 안다.
- 권한·버전·출처 필터를 식 하나로 표현한다.
- 필터 카디널리티가 검색 성능에 미치는 영향을 안다.
- 메타데이터 누락이 만드는 5가지 실패 양상을 안다.
2. 핵심 요약
청크 본문이 "무엇을 안다"라면 메타데이터는 "어디·언제·누구에게서 왔는가"를 답한다. 7개 핵심 필드 — document_id(문서 식별), chunk_id(청크 내 위치), version(버전 추적), page/section(원문 위치), security_level(권한), namespace(논리적 검색 공간) — 은 모든 청크에 반드시 함께 저장돼야 한다. 검색은 pre-filter → ANN → post-filter 3단으로 흐른다. pre-filter는 카디널리티가 낮은 필드(예: tenant_id)에 강하고, post-filter는 높은 필드(예: timestamp 범위)에 적합. 권한은 반드시 pre-filter로 — post-filter는 결과가 비는 사고를 만든다.
3. 직관 — 같은 코퍼스, 다른 사용자
같은 벡터DB에 전 직원의 문서와 경영진 전용 문서가 함께 인덱싱돼 있다. 일반 직원의 query에 경영진 청크가 top-K로 잡히면 권한 사고다. 단순한 dense retrieval은 이를 구분할 수 없다 — 메타데이터 필터가 그 일을 한다.
왜 pre-filter가 먼저인가: ANN이 허용 안 된 청크를 top-K에 넣으면 그 청크는 사용자에게 흘러나간다. ANN 뒤에서 권한을 거르면 candidate가 비는 경우가 생긴다(예: top-10 모두 권한 없음 → 답 0개). 권한은 검색 공간 자체를 좁히는 pre-filter여야 한다.
4. 정의 — 7개 핵심 필드
| 필드 | 무엇을 담나 | 예시 값 | 누가 쓰나 |
|---|---|---|---|
document_id |
문서 식별자 | policy-sec-v3.2 |
출처 표시, dedup |
chunk_id |
문서 내 청크 위치 | policy-sec-v3.2#c014 |
청크 단위 인용 |
version |
문서 버전 | 3.2, 2026-05-15 |
최신만/특정 버전만 |
page |
원문 페이지 | 42 |
인용 위치 표시 |
section |
헤더 경로 | ["5", "5.2", "5.2.1"] |
6편 Header Injection 재사용 |
security_level |
접근 등급 | public, internal, confidential, secret |
권한 pre-filter (필수) |
namespace |
논리적 검색 공간 | policy, manual, meeting_notes |
3편 Filter-first |
보조 필드 (선택, 상황별 추가):
source_url— 원문 링크.author,owner_team— 책임자 추적.created_at,updated_at— 시간 필터.language—ko,en. 다국어 코퍼스 분리.tenant_id— 멀티테넌트 SaaS의 최우선 pre-filter.
핵심 원칙: 청크가 만들어질 때 부모 문서의 모든 메타데이터가 복제돼야 한다. 5편 8.5절(메타데이터 누락)에서 다뤘다.
5. 식 — 필터 비용과 카디널리티
필터의 효율은 카디널리티 \(C\)와 선택률 \(s\)로 정리된다.
- \(N\) = 인덱스 전체 청크 수
- \(C\) = 필터 필드의 고유값 개수 (카디널리티)
- \(s\) = 필터 통과 비율 (예: tenant_id=A가 전체의 10% → \(s=0.1\))
- \(k\) = top-K
Pre-filter 비용 (HNSW 등 ANN 인덱스에 metadata 필터를 얹어 검색):
$$\text{Cost}_{\text{pre}} \approx \mathcal{O}(\log N) + \mathcal{O}(s \cdot N)$$
\(s\)가 작을수록 효율적. 카디널리티가 낮고 선택률이 낮은 필드(tenant_id, security_level)에 강함.
Post-filter 비용 (ANN top-K' 검색 후 필터):
$$\text{Cost}_{\text{post}} \approx \mathcal{O}(\log N) + k'$$
\(k'\)은 post-filter 후 충분한 K를 확보하기 위해 미리 가져오는 candidate 수. \(s\)가 작으면 \(k'\)가 기하급수적으로 커진다 (\(k' \approx k/s\)). 따라서 작은 선택률에는 부적합.
결정 표:
| 필드 종류 | 카디널리티 | 선택률 | 권장 |
|---|---|---|---|
security_level, tenant_id |
낮음 (4~10) | 낮음 (5~30%) | Pre-filter (필수) |
namespace, language |
낮음 (3~10) | 중간 (10~50%) | Pre-filter |
version=latest |
낮음 (2) | 높음 (80~95%) | Pre or Post |
created_at > 2026-01-01 |
높음 (날짜) | 가변 | Post-filter |
page >= 10 |
높음 (페이지) | 가변 | Post-filter |
벡터DB별 지원 편차가 크다 — 9편에서 도구 비교.
6. 원리 워크스루 — 메타데이터를 청크에 부착
6.1 청크 생성 시 메타데이터 복제
from langchain_core.documents import Document
doc_meta = {
"document_id": "policy-sec-v3.2",
"version": "3.2",
"security_level": "internal",
"namespace": "policy",
"language": "ko",
"tenant_id": "acme",
"source_url": "https://intranet.acme.com/docs/sec-3.2.pdf",
}
chunks = []
for i, chunk_text in enumerate(split_results):
chunks.append(Document(
page_content=chunk_text,
metadata={
**doc_meta,
"chunk_id": f"{doc_meta['document_id']}#c{i:04d}",
"page": page_map[i],
"section": section_map[i],
},
))
vectordb.add_documents(chunks)
핵심: 부모 문서의 모든 필드가 청크에 복제된다. 청크가 인덱스에서 독립적으로 검색되기 때문이다.
6.2 권한 + 버전 + 출처 필터 query
filter = {
"$and": [
{"tenant_id": "acme"}, # 최우선 (carrier)
{"security_level": {"$in": ["public", "internal"]}}, # 사용자 등급
{"namespace": "policy"}, # 검색 공간 축소
{"version": "3.2"}, # 버전 고정
]
}
results = vectordb.similarity_search(
"예외 신청 절차?",
k=5,
filter=filter, # pre-filter: ANN 전에 적용
)
final = [r for r in results if 10 <= r.metadata["page"] <= 50]
Pinecone, Qdrant, Weaviate 모두 유사한 MongoDB 스타일 and/in 연산자 문법. Chroma는 덜 표현적인 dict 필터. FAISS는 기본 지원 없음 → IDList 사전 계산 필요.
6.3 출처 인용을 답에 끼우기
def cite_answer(answer: str, results: list[Document]) -> str:
refs = []
for r in results:
m = r.metadata
refs.append(
f"- [{m['document_id']} §{m['section'][-1]} p.{m['page']}]({m['source_url']}) (v{m['version']})"
)
return f"{answer}\n\n## 출처\n" + "\n".join(refs)
source_url + section + page + version이 함께 있어야 재방문 가능한 인용이 된다. 인용 없는 RAG는 환각 위험이 높다.
7. 변형과 사례
7.1 namespace vs collection — 같은 개념의 다른 이름
- 무엇이 바뀌나: 일부 도구는 물리적 분리(다른 인덱스), 일부는 논리적 필드로 구현.
- 왜 쓰나: 검색 공간을 목적별(정책 vs 매뉴얼 vs 회의록)로 분리하면 재현율이 흐려지지 않음 (3편 35장 4절).
- 무엇이 가능해졌나: 같은 임베딩 모델로도 목적이 섞이지 않은 검색.
- 어디에 적합한가: 거의 모든 멀티 도메인 RAG.
- 한계: namespace를 잘못 자르면 교차 도메인 답이 막힘. 4~10개의 큰 단위가 실무 sweet spot.
7.2 security_level 모델 — 4단계가 표준
- 무엇이 바뀌나:
public < internal < confidential < secret같은 총순서 모델. - 왜 쓰나: 사용자 등급 \(u\)와 청크 등급 \(c\)을 \(c \le u\) 조건으로 간결하게 비교.
- 무엇이 가능해졌나:
$lte(작거나 같음) 한 줄로 권한 필터링. - 어디에 적합한가: 회사·정부·금융의 총순서 분류 체계.
- 한계: 권한이 교차하는 경우(예: 부서 + 비밀 등급) 단일 필드로 부족. 복합 권한은 7.3절.
7.3 ABAC 스타일 권한 — 다차원 속성
- 무엇이 바뀌나: 등급 1개 대신 속성 집합 (
dept,project,region,nda_signed). - 왜 쓰나: 실제 조직의 권한은 교차한다. "마케팅 부서 + APAC 리전 + NDA 서명자"만 볼 수 있는 문서.
- 무엇이 가능해졌나: 정교한 권한, 사용자별 맞춤 검색 공간.
- 어디에 적합한가: 엔터프라이즈, 멀티 프로젝트, 규제 산업.
- 한계: 필터 조건이 복잡해져 벡터DB의 표현력 한계(Chroma 등)에 부딪힘. 권한이 자주 바뀌면 재인덱싱 필요.
7.4 version 정책 — 최신만 vs 전 버전 동시
- 무엇이 바뀌나:
version=latest만 검색 vs 전 버전 동시 검색 후 시간 가중치. - 왜 쓰나: 정책 문서는 최신만. 법령·논문은 모든 버전을 추적.
- 무엇이 가능해졌나: 답에 현재 유효한 조항만 포함, 또는 변경 이력 함께 인용.
- 어디에 적합한가: 법무·컴플라이언스(전 버전), 일반 사내 정책(최신만).
- 한계: 전 버전 동시 검색은 인덱스 크기 \(\times N_{\text{versions}}\). 비용·저장이 비례 증가.
7.5 source_url deep link — 페이지·섹션까지
- 무엇이 바뀌나: 단순 URL 대신
https://...pdf#page=42같은 깊은 링크. - 왜 쓰나: 사용자가 인용 클릭 시 해당 페이지로 직행.
- 무엇이 가능해졌나: 검증 가능한 RAG 답변.
- 어디에 적합한가: 모든 내부 문서 RAG.
- 한계: PDF viewer가
#page=앵커를 지원해야 함(대부분 지원). HTML은 anchor id가 있어야.
8. 한계와 실패 양상
8.1 권한 post-filter로 답이 빈다
- 왜 본질적인가: ANN이 top-K=10을 가져온 뒤 권한 필터링하면, 그 사용자가 볼 수 있는 청크가 candidate에 없을 수 있다.
- 진단: 사용자별 답 empty rate 측정. 일반 직원의 empty rate가 눈에 띄게 높음.
- 완화: 권한을 pre-filter로. candidate 수를 키울지(\(k'=50\))는 임시방편.
- 다음 편: 9편(벡터DB의 pre-filter 지원).
8.2 metadata 누락 — 필터 자체가 불가능
- 왜 본질적인가: 청크 생성 시
version,security_level을 빠뜨리면 필터 조건의 키 자체가 없는 청크가 섞임. 도구별 동작이 다름(어떤 도구는 통과, 어떤 도구는 제외). - 진단: 인덱스 내 필드 통계 — null 비율이 0이 아니면 위험.
- 완화: 청크 생성 단계의 스키마 검증 (Pydantic, JSON Schema). 누락 발견 시 즉시 재인덱싱.
- 다음 편: 8.2와 같은 데이터 위생을 9편(벡터DB 운영).
8.3 카디널리티 폭주 — 필터가 느려진다
- 왜 본질적인가:
chunk_id처럼 고유성이 높은 필드는 pre-filter로 부적합. 인덱스가 모든 값을 색인해야 해 메모리·속도 모두 악영향. - 진단: 인덱스 빌드 시간 비정상 증가, 쿼리 latency p95 증가.
- 완화: 고유 필드는 post-filter로. 해시 버킷으로 축소(예:
chunk_hash_bucket = hash(chunk_id) % 256). - 다음 편: 9편(벡터DB별 필터 카디널리티 권장값).
8.4 version=latest 일관성 깨짐
- 왜 본질적인가: 새 버전 인덱싱 후 옛 버전 청크 삭제 누락하면
version=latest필터에 둘 다 들어옴. 답에 모순된 정책 두 개가 인용. - 진단: 같은
document_id의 서로 다른 version 청크가 동시에 top-K에 등장. - 완화: 새 버전 publish는 atomic하게 —
BEGIN → 새 청크 insert → 옛 청크 delete → COMMIT패턴. 또는is_active=true플래그를 별도 필드로. - 다음 편: 9편(벡터DB의 transactional update 지원).
8.5 출처 인용 단절 — 재방문 불가
- 왜 본질적인가: 답에
document_id만 표시하고source_url,section,page가 빠지면 사용자가 원문을 찾을 수 없음. 환각 검증 불가. - 진단: 답 인용 클릭률(CTR) 측정. 0%면 인용 자체가 없는 것.
- 완화: 6.3절 같은 완전한 인용 포맷 강제. 답 생성 프롬프트에 "각 주장에 [출처]를 붙이라" 명시.
- 다음 편: 22장(답 검증과 출처 강제).
8.5 Common Pitfalls
- "권한 필터는 답 만든 후 거르면 된다." — 8.1절. 권한 누출 사고의 단골 원인.
- "메타데이터는 나중에 추가하면 된다." — 청크가 이미 인덱싱된 후엔 재인덱싱 필요. 처음부터.
- "모든 필드를 pre-filter로." — 8.3절. 고카디널리티 필드는 인덱스를 부풀린다.
- "
version=latest한 줄이면 충분." — 8.4절. 옛 버전 삭제까지 atomic해야. - "인용은
document_id만으로." — 8.5절. URL+section+page+version 모두 필요.
9. 정리된 결론
Q1. 7개 핵심 메타데이터 필드를 한 줄씩 말하라.
document_id(문서), chunk_id(위치), version(버전), page(페이지), section(헤더 경로), security_level(권한), namespace(검색 공간).
Chapter: 4장.
Q2. 권한 필터는 왜 pre-filter여야 하는가?
post-filter는 ANN이 허용 안 된 청크를 candidate에 넣은 뒤 거른다. candidate가 전부 권한 밖이면 답이 비는 사고. pre-filter는 검색 공간 자체를 좁힌다. Chapter: 3장, 8.1절.
Q3. 카디널리티가 높은 필드와 낮은 필드의 필터 권장이 다른 이유는?
낮은 카디널리티(security_level, tenant_id)는 선택률이 명확해 pre-filter 비용이 작다. 높은 카디널리티(timestamp, page)는 후처리가 가벼움. Chapter: 5장.
Q4. 새 문서 버전 publish 시 atomic해야 하는 이유는?
옛 버전 청크 삭제가 누락되면 version=latest 필터에 옛+새가 동시 통과. 답에 모순된 정책이 동시 인용.
Chapter: 8.4절.
Q5. 출처 인용에 반드시 포함해야 할 4가지는?
source_url, section(또는 헤더 경로), page, version. 이 네 가지가 있어야 사용자가 원문을 재방문하고 환각을 검증할 수 있다.
Chapter: 6.3절, 8.5절.
10. 추가 학습
1차 자료
- Pinecone. Filtering with metadata 공식 문서. Pre/post filter의 internal 동작 다이어그램.
- Qdrant. Payload-based filtering 문서. 필터-우선 검색의 표준 구현.
- Weaviate. Filtered Vector Search 문서. ABAC 권한과 결합 사례.
- Anthropic. Building RAG with permissions (2024 cookbook). security_level 기반 multi-tenant 패턴.
- NIST. Attribute-Based Access Control (ABAC) SP 800-162. 7.3절 권한 모델의 표준.
공식 docs
- LangChain Vectorstores filter API:
https://python.langchain.com/docs/concepts/vectorstores/#metadata-filtering - Pinecone Filtering:
https://docs.pinecone.io/guides/data/filtering-with-metadata - Qdrant Filtering:
https://qdrant.tech/documentation/concepts/filtering/ - Weaviate Filters:
https://weaviate.io/developers/weaviate/api/graphql/filters
보조 자료
- 사용자 노트 6장 — 메타데이터 설계.
- 사용자 노트 35장 4절·5절·6절 — Document Boundary, Multi-collection, Filter-first.
Cheat Sheet
| 필드 | 카디널리티 | 필터 위치 | 메모 |
|---|---|---|---|
tenant_id |
낮음 | Pre | 멀티테넌트 최우선 |
security_level |
낮음(4) | Pre (필수) | 권한 사고 방지 |
namespace |
낮음(3~10) | Pre | 검색 공간 축소 |
version |
낮음(2~10) | Pre | atomic publish 필수 |
language |
낮음(2~5) | Pre | 다국어 분리 |
page |
높음 | Post | 인용 표시용 |
section |
높음 | Post | 인용 + Header Injection 재사용 |
chunk_id |
매우 높음 | Post 또는 인덱스 키 | 고유 식별 |
created_at |
매우 높음 | Post | 시간 범위 |
설계 원칙 한 줄: 권한·격리 필드는 pre-filter, 표시·범위 필드는 post-filter. 청크 생성 시 모든 필드를 복제.
Bridge — 다음 편
다음 — RAG 핵심 학습 (8/26) 임베딩 모델 비교: BGE-M3 / OpenAI / Upstage / E5 / Jina / Voyage.
청크와 메타데이터가 준비됐다면, 다음 결정은 어떤 임베딩 모델로 인덱싱할 것인가다. 6개 주류 모델을 벡터 차원·다국어 성능·비용·로컬 실행 여부로 비교하고, 모델 카드 → ingestion schema 매핑(사용자 노트 35장 2절)을 풀어 낸다.
시리즈 전체 안내: 시리즈 목차
댓글
댓글 쓰기