왜 청크 단위 검색만으로는 부족한가
RAG의 기본 구조는 문서를 작은 청크로 나눠 임베딩하고, 질의와 유사한 청크를 top-k로 뽑아 LLM에 넣는 방식이다. 문제는 검색 정확도와 답변 완결성이 서로 다른 청크 크기를 요구한다는 데 있다. 작은 청크(256~512 토큰)는 의미가 밀집돼 임베딩 유사도가 정확하지만, 정작 답변에 필요한 앞뒤 맥락이 잘려나간다. 반대로 큰 청크는 맥락은 살지만 한 벡터에 여러 주제가 섞여 검색 노이즈가 커진다.
실무에서 흔히 겪는 증상은 이렇다. 검색은 정답 문단을 잘 찾아냈는데, 그 문단이 "위 표에서 두 번째 항목"이나 "앞서 설명한 조건"처럼 앞 문맥을 전제로 쓰여 있어 LLM이 헛다리를 짚는다. 이건 리트리버 성능 문제가 아니라 컨텍스트 단위 설계 문제다.
핵심 아이디어: 검색 단위와 생성 단위를 분리한다
계층 인덱싱(hierarchical indexing) 또는 parent-document 패턴의 요지는 단순하다. 검색은 작은 자식 청크로, 생성은 큰 부모 청크로 한다. 자식 청크를 임베딩해 정밀하게 매칭하되, 매칭된 자식이 속한 부모 문단(또는 섹션 전체)을 LLM에 전달한다. 검색의 정밀도와 생성의 맥락을 동시에 얻는 방식이다.
| 전략 | 검색 정밀도 | 맥락 완결성 | 인덱스 비용 |
|---|---|---|---|
| 고정 소형 청크 | 높음 | 낮음 | 낮음 |
| 고정 대형 청크 | 낮음 | 높음 | 낮음 |
| parent-document | 높음 | 높음 | 중간 |
데이터 모델: 부모-자식 관계를 어떻게 저장하나
자식 청크는 벡터 스토어에 넣되, 부모 원문은 별도 도큐먼트 스토어(RDB, KV, 오브젝트 스토리지)에 둔다. 벡터에는 parent_id만 메타데이터로 붙인다. 벡터 DB에 부모 원문을 통째로 중복 저장하면 인덱스가 비대해지고 재인덱싱 비용이 커지므로 피한다.
-- 부모 문단 원문 (도큐먼트 스토어)
CREATE TABLE parent_chunks (
parent_id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL,
content TEXT NOT NULL,
section TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 벡터 스토어에는 자식 청크 임베딩 + parent_id 메타데이터만 저장
-- payload: { "child_id": "...", "parent_id": "...", "doc_id": "..." }
CREATE INDEX idx_parent_doc ON parent_chunks(doc_id);
인덱싱과 검색 파이프라인
인덱싱 단계에서 부모(예: 섹션·문단 단위 1500 토큰)를 먼저 자르고, 각 부모를 다시 자식(300 토큰, 50 토큰 오버랩)으로 쪼갠다. 검색 시에는 자식 top-k를 뽑은 뒤 parent_id로 중복을 제거하고 부모 원문을 로드한다.
from collections import OrderedDict
def retrieve_with_parents(query, vstore, docstore, k=8, max_parents=4):
# 1) 자식 청크로 정밀 검색
hits = vstore.search(embed(query), top_k=k)
# 2) parent_id 기준 dedup (점수 높은 자식 우선)
parent_ids = list(OrderedDict.fromkeys(h.payload["parent_id"] for h in hits))
parent_ids = parent_ids[:max_parents]
# 3) 부모 원문 로드 → LLM 컨텍스트로 확장
parents = docstore.mget(parent_ids)
return [p.content for p in parents if p]
핵심은 2단계의 dedup이다. 같은 부모에 속한 자식이 여러 개 히트되면 부모 원문이 중복 삽입돼 토큰만 낭비된다. 부모 단위로 접어야 top-k를 늘려도 컨텍스트가 폭주하지 않는다.
청크 크기와 토큰 예산 튜닝
부모 크기는 답변에 필요한 최소 맥락 단위로 잡되, max_parents × parent_size가 모델 컨텍스트 예산의 절반을 넘지 않게 설계한다. 나머지 절반은 시스템 프롬프트·질의·출력에 남긴다.
chunking:
parent:
max_tokens: 1500
split_by: section # 헤딩/문단 경계 우선
child:
max_tokens: 300
overlap: 50
retrieval:
child_top_k: 8
max_parents: 4 # 1500 x 4 = 6000 토큰, 예산 내 유지
주의점과 흔한 함정
- 부모 경계는 의미 단위로. 고정 토큰으로만 자르면 표·코드블록·리스트가 중간에서 끊긴다. 헤딩과 문단 경계를 우선 기준으로 두고 토큰 상한은 보조로 쓴다.
- 재인덱싱 정합성. 원문이 바뀌면 자식 벡터와 부모 원문이 어긋난다.
doc_id단위로 부모·자식을 함께 삭제 후 재삽입하는 트랜잭션 경계를 만든다. - 과도한 확장 경계. 부모를 섹션 전체로 잡으면 관련 없는 문단까지 딸려와 오히려 정확도가 떨어진다. 확장은 "잘린 맥락 복원" 수준에서 멈춘다.
- 평가는 두 축으로. 리트리버는 recall@k(자식 기준), 최종 답변은 정답 근거 포함 여부(부모 기준)로 나눠 측정해야 어느 계층이 병목인지 보인다.
parent-document 패턴은 임베딩 모델 교체나 리랭커 도입보다 구현 비용이 낮으면서 체감 효과가 크다. 검색이 맞는데 답이 어긋난다면, 리트리버를 갈아엎기 전에 컨텍스트 단위부터 분리해보는 편이 빠르다.