왜 청크 단위 검색만으로는 부족한가

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 패턴은 임베딩 모델 교체나 리랭커 도입보다 구현 비용이 낮으면서 체감 효과가 크다. 검색이 맞는데 답이 어긋난다면, 리트리버를 갈아엎기 전에 컨텍스트 단위부터 분리해보는 편이 빠르다.