RAG 시리즈 2편 — 청크를 어떻게 자르고 임베딩할 것인가, 정확도 올리는 6가지 입력 가공
RAG 정확도가 안 나올 때 모델 교체나 파인튜닝부터 떠올리지 마세요. GPU 없이 OpenAI 임베딩만으로도 파인튜닝 효과의 85~90%를 낼 수 있어요. 시맨틱 청킹·메타데이터 주입 임베딩·쿼리 확장·Qdrant 사전 필터·Parent-Child 청킹·HyDE 6가지 입력 가공 기법을 정리했어요.
한 줄 요약
RAG 정확도가 안 나올 때 사람들은 보통 "모델을 바꿔야 하나, 파인튜닝을 해야 하나"부터 떠올려요. 하지만 GPU 없이 OpenAI 임베딩만으로도 파인튜닝 효과의 85~90%를 낼 수 있어요. 핵심은 "모델을 못 바꾸면 모델에 넣는 입력을 가공하라"예요. 이 글에서는 실제로 검증한 6가지 입력 가공 기법을 정리해요.
출발점: 고정 크기 청킹의 한계
처음 RAG를 만들 때는 보통 이렇게 시작해요.
def chunk_text(text, chunk_size=3072, overlap=300):
# 3072자씩 자르되, 문장 경계에서 분리 시도
...
문서를 일정 크기로 잘라서 각각 임베딩한 다음 벡터DB에 넣어요. 데모는 잘 돌아가요. 그런데 실제 문서가 들어오기 시작하면 문제가 보여요.
법률 문서를 예로 들어볼게요. 판결문은 보통 이런 구조예요.
사건번호: 2024구합...
[주문]
원고의 청구를 기각한다.
[이유]
1. 인정사실
...
2. 당사자 주장
...
3. 판단
...
[결론]
원고의 청구는 이유 없으므로 기각한다.
이걸 3072자로 무작정 자르면 어떻게 될까요? "주문"의 뒷부분과 "이유"의 앞부분이 한 청크에 섞여요. 사용자가 "이 사건 주문 알려줘"라고 물어봤을 때, "주문 + 이유 앞부분"이 검색되면 LLM이 헷갈려요.
문제는 문서가 가진 구조를 청킹이 무시한다는 거예요.
기법 1: 시맨틱 청킹 — 문서 구조를 따라 자르기
해결책은 단순해요. 문서 유형별로 섹션 마커를 정의하고, 그 단위로 잘라요.
SECTION_MARKERS = {
"판결문": ["주문", "판결요지", "판시사항", "이유", "결론"],
"결정문": ["주문", "이유"],
"소장": ["청구취지", "청구원인", "입증방법"],
"준비서면": ["청구취지", "주장", "입증방법", "결론"],
}
def chunk_text_semantic(text, document_type=None):
markers = SECTION_MARKERS.get(document_type)
if not markers:
return chunk_text_fixed(text) # 폴백
sections = split_by_markers(text, markers)
return [
{"content": s.text, "section_name": s.marker}
for s in sections
]
장점 두 가지.
- 검색 정밀도 향상: "주문 알려줘"라는 질문에 진짜 "주문" 섹션만 매칭됨
- 섹션명을 메타데이터로 활용 가능: 뒤에 나올 기법 2에 쓰임
주의할 점은 모든 문서가 정형이 아니라는 것이에요. 내부 메모, 비정형 자료에는 마커가 없어요. 그래서 마커 못 찾으면 기존 고정 크기로 폴백하는 안전장치가 필수예요.
기법 2: 메타데이터 주입 임베딩
여기서부터가 진짜 효과 큰 기법이에요. 단순하지만 잘 안 알려진 거예요.
문제 상황: 사용자가 "판정서 주문 보여줘"라고 물어봤어요. 그런데 "판결문의 주문"이 더 유사도가 높게 나와요. 왜냐면 임베딩 모델은 "주문"이라는 단어에 반응할 뿐, 그 주문이 어떤 문서 유형의 주문인지는 모르거든요.
해법: 임베딩할 때 메타데이터를 텍스트로 prepend해요.
# Before: 본문만 임베딩
chunk_for_embedding = "피신청인은 신청인을 원직에 복직시키고..."
# After: 메타데이터를 텍스트로 붙여서 임베딩
chunk_for_embedding = (
"[문서유형: 판정서] [섹션: 주문] [기관: 노동위원회] "
"피신청인은 신청인을 원직에 복직시키고..."
)
이렇게 하면 벡터 공간에서 "판정서의 주문"과 "판결문의 주문"이 다른 영역에 위치해요. OpenAI 임베딩은 텍스트의 의미를 잘 잡으니까, 메타데이터 텍스트도 의미적으로 반영되거든요.
중요한 디테일 하나. 임베딩에는 메타데이터를 붙이지만, LLM에 전달하는 텍스트에는 붙이지 않아요. Qdrant payload에는 원본 텍스트를 그대로 저장해요.
[임베딩 입력] "[문서유형: 판정서] [섹션: 주문] 피신청인은..."
[Qdrant text] "피신청인은..." ← LLM이 보는 텍스트
이렇게 분리하지 않으면 LLM이 답변할 때 "[문서유형: 판정서]" 같은 prefix를 그대로 인용해버려요.
기법 3: 쿼리 메타데이터 확장
청크에 메타데이터를 붙였다면, 쿼리에도 똑같은 형태로 붙여줘야 매칭이 잘 돼요. 둘 다 같은 벡터 공간으로 끌어오는 거예요.
# 1편의 QueryPlanner가 이미 분석한 정보를 활용
query_plan = {
"document_types": ["판정서"],
"court_stage": "1심",
...
}
# 쿼리 임베딩 전에 prefix 추가
expanded_query = (
"[문서유형: 판정서] [심급: 1심] "
+ user_query
)
이 한 줄로도 정확도가 눈에 띄게 올라가요. 비용은 0이에요 — LLM 호출 추가 없이 그냥 문자열 조합만 하면 되니까요.
기법 4: Qdrant 메타데이터 사전 필터링
임베딩 가공이 아니라 검색 단계에서 후보를 좁히는 방법이에요. 벡터 검색 전에 메타데이터로 일단 한 번 거르는 거예요.
# QueryPlanner가 "판정서"로 분류했다면
results = client.query_points(
collection="documents",
query=query_vector,
query_filter=Filter(
must=[
FieldCondition(
key="inferred_document_type",
match=MatchAny(any=["판정서"]),
)
]
),
limit=40,
)
벡터 유사도 검색을 "판정서" 문서들 안에서만 돌리는 거예요. 후보 자체가 작아지니 노이즈가 줄고, 속도도 빨라져요.
주의: QueryPlanner의 분류가 틀릴 수도 있어요. 그래서 보통 1~2개 유형이 확실할 때만 필터를 걸고, 애매하면 필터 없이 전체 검색해요. "확실하지 않으면 좁히지 않는다"가 안전장치예요.
기법 5: Parent-Child 청킹 — 정밀도와 컨텍스트를 동시에
청킹에는 본질적인 트레이드오프가 있어요.
- 청크를 작게 (~500자): 검색 정밀도 ↑, 하지만 LLM이 받는 맥락 부족
- 청크를 크게 (~3000자): 맥락 충분, 하지만 검색이 뭉뚱그려짐
둘 다 가질 수는 없을까요? Parent-Child 청킹이 답이에요.
원본 문서
↓
[Parent 청크: 시맨틱 섹션 "이유" 전체 (~3000자)]
↓
[Child A: 1000자] [Child B: 1000자] [Child C: 1000자]
↓ (Child만 임베딩, Parent는 DB에만 저장)
검색: Child B가 매칭됨
↓
LLM에 전달: Child B의 parent_chunk_id로 Parent 텍스트를 조회해서 전달
작은 child로 정밀하게 검색하되, 매칭되면 child가 속한 parent 전체를 LLM에 넘겨요. 정밀도와 컨텍스트를 모두 챙기는 거예요.
구현은 살짝 복잡해요. DB 모델에 chunk_level과 parent_chunk_id 필드를 추가해야 하고, 검색 결과에 parent를 주입하는 로직이 필요해요. 마이그레이션도 한 번 돌려야 해요. 그래도 검색 정확도 효과가 가장 큰 기법 중 하나라서, 한 번 깔아두면 두고두고 써먹어요.
class DocumentChunk(Base):
id: Mapped[uuid.UUID]
document_id: Mapped[uuid.UUID]
content: Mapped[str]
chunk_index: Mapped[int]
section_name: Mapped[str | None]
# Parent-Child용 신규 필드
chunk_level: Mapped[str] # "parent" | "child"
parent_chunk_id: Mapped[uuid.UUID | None]
기법 6: HyDE — 가상 답변 문서로 쿼리 갭 줄이기
마지막 기법은 좀 다른 각도예요. 쿼리는 짧고, 문서는 길어요. 둘을 같은 벡터 공간에 임베딩하면 거리가 멀어요. 길이가 너무 다르거든요.
**HyDE (Hypothetical Document Embeddings)**는 이걸 이렇게 해결해요.
원래:
쿼리 "부당해고 구제신청 인용 사례"
→ 임베딩 → [짧은 벡터]
↕ 거리 멀음
문서 (수 페이지)
→ 임베딩 → [긴 벡터]
HyDE 적용:
쿼리 "부당해고 구제신청 인용 사례"
→ LLM이 가상 답변 작성:
"[문서유형: 판정서] [섹션: 주문] 피신청인이 신청인에 대하여 한
해고는 부당해고임을 인정한다. 피신청인은 신청인을 원직에 복직시키고..."
→ 이 가상 문서를 임베딩 → [문서 형태 벡터]
↕ 거리 가까움!
실제 문서 벡터
쿼리당 gpt-4o-mini 1회 추가 호출이 들어요. 비용은 쿼리당 약 $0.0003 정도예요. 검색 정확도 효과 대비 미미해요.
기법 2(메타데이터 주입)와 같이 쓰면 시너지가 좋아요 — 가상 문서를 만들 때도 메타데이터 prefix 형태로 만들면 임베딩 매칭이 극대화돼요.
종합 비교표
| 기법 | 난이도 | 효과 | GPU | 추가 비용 |
|---|---|---|---|---|
| 1. 시맨틱 청킹 | 낮음 | 높음 | 불필요 | 없음 |
| 2. 메타데이터 주입 임베딩 | 낮음 | 높음 | 불필요 | 없음 (리인덱싱 1회) |
| 3. 쿼리 메타데이터 확장 | 낮음 | 중간 | 불필요 | 없음 |
| 4. Qdrant 사전 필터 | 낮음 | 중간 | 불필요 | 없음 |
| 5. Parent-Child 청킹 | 중간 | 높음 | 불필요 | 없음 (저장 공간 증가) |
| 6. HyDE | 중간 | 높음 | 불필요 | 쿼리당 LLM 1회 |
| 전체 조합 | 중간 | 파인튜닝 85~90% | 불필요 | 쿼리당 LLM 1회 |
전체를 조합한 최종 아키텍처는 이렇게 동작해요.
사용자: "부당해고 구제신청 인용된 판정서"
↓
[QueryPlanner] document_types: ["판정서"]
↓
[HyDE] 가상 판정서 문서 생성
↓
[쿼리 메타데이터 확장] "[문서유형: 판정서] " + 가상 문서
↓
[임베딩] 확장된 텍스트를 벡터화
↓
[Qdrant 검색] 벡터 유사도 + 문서유형 필터 → child 청크 매칭
↓
[Parent 컨텍스트 주입] child의 parent를 LLM에 전달
↓
[LLM 생성] 풍부한 맥락으로 스트리밍 응답
실전 적용 순서
전부 한 번에 깔지 말고 순서대로 가요. 각 단계에서 정확도 변화를 측정해야 하니까요.
Phase 1 (2~3일)
- 시맨틱 청킹 (기법 1)
- 메타데이터 주입 임베딩 (기법 2)
- 쿼리 메타데이터 확장 (기법 3)
- Qdrant 사전 필터 (기법 4)
Phase 2 (3~5일)
- Parent-Child 청킹 (기법 5)
- HyDE (기법 6)
Phase 1만 적용해도 효과가 꽤 커요. Phase 2는 DB 마이그레이션과 LLM 비용 증가를 감수해야 하니까 Phase 1 결과를 보고 결정해요.
리인덱싱은 한 번에 다 돌리지 말고 --dry-run, --limit 10 같은 옵션을 둬서 작은 단위로 검증한 다음 전체에 적용해요. 임베딩 API 비용이 적지 않아서 한 번 잘못 돌리면 아까워요.
정리
파인튜닝은 "내 모델을 내 데이터에 맞춘다"는 이상적인 그림이에요. 그런데 현실은 GPU 인프라, 학습 데이터 구축, 운영 복잡도가 만만치 않아요. 게다가 OpenAI 임베딩 같은 외부 API는 파인튜닝 자체가 불가능해요.
대신 입력과 출력을 가공해서 모델의 약점을 보완할 수 있어요. 이 글에서 본 6가지 기법은 모두 OpenAI 임베딩을 그대로 쓰면서, 어떤 텍스트를 임베딩에 넣고 어떤 쿼리를 던질지를 바꾼 거예요.
핵심 한 줄.
모델을 바꿀 수 없다면 모델에 넣는 입력과 모델이 내놓는 출력을 가공하라.
다음 편에서는 시각을 좀 바꿔서, "고정 6단계 파이프라인이 정말 최선일까?"를 다뤄요. LLM에게 검색 도구를 쥐여주고 자율적으로 선택하게 하는 Tool Calling 패턴이에요. 인사말에도 LLM을 4번 부르던 비용 구조가 완전히 바뀌어요.
시리즈 다른 편
- 1편. RAG가 뭐고 왜 쓰는가 — 기본 파이프라인 6단계
- 2편 (현재 글)
- 3편. 고정 파이프라인 vs Tool Calling (예정)
- 4편. 운영에서 만나는 RAG — 임베딩 파이프라인 4경로 (예정)
관련 프로젝트
댓글 0
- 아직 댓글이 없습니다. 첫 댓글을 남겨주세요.