12장. 문서 로딩과 청킹
메타데이터 — 조각에 출처를 적어 둔다
한 줄 요약
청크에는 본문만이 아니라 "이 조각이 어느 문서의 어느 조항에서 왔는가" 를 함께 적어 둡니다. 이것을 메타데이터(metadata) 라고 합니다. 답변에 근거를 붙이려면 반드시 필요하고, 자를 때 붙이지 않으면 나중에는 붙일 수 없습니다.
1. 왜 필요한가
표준 질문 Q3의 검증 포인트는 이렇습니다.
"회사 전액 부담" + (근거: 제N조) 표기
답변에 "(근거: 반품교환환불정책 제2조)"를 붙이려면, 검색으로 찾은 청크가 자기가 어디서 왔는지 알고 있어야 합니다.
청크의 본문만 봅시다.
① 하루포인트는 구매확정 다음 날 등급별 적립률에 따라 자동 적립된다. ② 적립금·쿠폰으로 결제한 금액에는 적립되지 않는
다. ③ 리뷰 작성 시 일반 리뷰 100포인트, 포토 리뷰 500포인트가 추가 적립된다(상품당 1회).
이 조각만 보고 "멤버십 정책 제3조"라는 것을 알아낼 방법은 없습니다. 원문과 이어져 있는 자르는 순간에만 알 수 있습니다.
2. 청크 하나의 모양
이번 장에서 만드는 청크는 딕셔너리입니다.
{
"id": "policy-002",
"text": "제3조 (하루포인트 적립)\n① 하루포인트는 구매확정 다음 날 …",
"metadata": {
"source": "하루마켓_멤버십정책.pdf",
"doc_title": "멤버십정책",
"chunk_no": 2,
"article": "제3조 (하루포인트 적립), 제4조 (하루포인트 사용), 제5조 (하루포인트 유효기간과 소멸)",
},
}
| 키 | 뜻 | 쓰이는 곳 |
|---|---|---|
id |
청크의 고유 번호 | 저장하고 다시 찾을 때 (13장) |
text |
청크 본문 | 검색 대상, 모델에게 건네는 근거 |
source |
원본 파일 이름 | 어느 파일인지 |
doc_title |
사람이 읽을 문서 제목 | 답변의 근거 표기 (14장) |
chunk_no |
몇 번째 청크인지 | 순서를 따질 때 |
article |
이 청크에 내용이 담긴 조항 제목 전부 | 답변의 근거 표기 (14장) |
article은 문자열 하나입니다. 조항이 여럿이면 쉼표로 이어 적습니다.
3. 청크와 조항은 일대일이 아니다
조항 하나는 150~400자쯤이고 청크는 500자까지 채웁니다. 그래서 한 청크에 조항이 두세 개 들어갑니다. 겹침 때문에 앞 조항의 끝부분으로 시작하는 청크도 있습니다.
(policy-003의 시작)
내한다. ③ 유효기간이 지나 소멸된 포인트는 복구되지 않는다. … ← 제5조의 끝부분. 제목 줄은 없다
제6조 (등급 조정과 제외)
① 반품·취소로 실결제액이 산정 기준에 미달하게 되면 …
이 청크의 첫 문장을 근거로 답한다면 출처는 제5조여야 합니다. 그런데 청크 안에는 "제5조"라는 제목 줄이 없습니다. 그래서 꼬리표를 붙일 때 세 가지를 합니다.
| 하는 일 | 왜 |
|---|---|
| 청크 안의 조항 제목 줄을 모두 모은다 | 한 청크에 조항이 여럿 들어 있다 |
| 청크가 제목 줄로 시작하지 않으면 직전 조항을 물려받는다 | 앞머리는 앞 조항에서 이어진 내용이다 |
| 청크가 제목 줄에서 끝나면 그 조항은 뺀다 | 제목만 걸쳤고 내용은 다음 청크에 있다 |
4. 메타데이터를 만드는 코드
문서 제목은 파일 이름에서 만듭니다.
# 파일명에서 문서 제목 추출: 하루마켓_멤버십정책.pdf → 멤버십정책
doc_title = source_name.replace("하루마켓_", "").replace(".pdf", "")
조항 제목 줄은 정규식으로 찾습니다. 정규식(regular expression)은 글자의 모양을 적어 두고 그 모양에 맞는 곳을 찾는 방법입니다.
HEADING = re.compile(r"^(?:제\d+조 |자주 묻는 질문|부칙).*$", re.MULTILINE)
| 부분 | 뜻 |
|---|---|
^ … $ |
줄의 처음부터 끝까지. re.MULTILINE을 주면 글 전체가 아니라 각 줄의 처음과 끝을 뜻합니다 |
제\d+조 |
"제", 숫자 하나 이상, "조", 빈칸. 제3조 (하루포인트 적립) 같은 줄입니다 |
자주 묻는 질문, 부칙 |
조항 번호가 없는 두 부분도 제목으로 봅니다 |
이 정규식으로 꼬리표를 만드는 함수입니다.
def find_articles(text: str, chunk: str) -> str:
names = HEADING.findall(chunk) # 청크 안에 있는 조항 제목 전부
before = HEADING.findall(text[:text.find(chunk)])
if before and not HEADING.match(chunk):
names.insert(0, before[-1])
if names and chunk.endswith(names[-1]):
names.pop()
return ", ".join(dict.fromkeys(names)) # 중복 제거 (순서 유지)
| 줄 | 하는 일 |
|---|---|
HEADING.findall(chunk) |
청크 안의 제목 줄을 순서대로 모두 모읍니다 |
text[:text.find(chunk)] |
원문에서 이 청크보다 앞에 있는 부분입니다. 거기서 마지막 제목이 직전 조항입니다 |
not HEADING.match(chunk) |
청크가 제목 줄로 시작하지 않을 때만 직전 조항을 맨 앞에 넣습니다 |
chunk.endswith(names[-1]) |
청크가 제목 줄에서 끝나면 그 조항을 뺍니다 |
", ".join(dict.fromkeys(names)) |
같은 이름이 두 번 들어가지 않게 하고 쉼표로 잇습니다 |
text(원문 전체)를 함께 넘기는 이유가 여기 있습니다. 직전 조항은 청크만 봐서는 알 수 없고 원문에서 청크의 자리를 찾아야 알 수 있습니다. 출처는 자르는 순간에만 붙일 수 있다는 말의 실제 모습입니다.
5. 다른 장이 가져다 쓸 수 있게
청크 목록은 13장과 15장에서 다시 씁니다. 그래서 함수로 내놓습니다.
def load_policy_chunks():
"""정책 PDF → 청크 목록. 13장(인덱싱)에서 import 해서 사용."""
(중략)
return documents
8장에서 haru_tools.py를 여러 장이 불러 쓰게 만든 것과 같은 방식입니다. 다른 파일에서는 이렇게 가져옵니다.
from lesson12_rag_chunking import load_policy_chunks
chunks = load_policy_chunks()
파이썬은 다른 파일에서 무엇을 가져올 때 그 파일을 처음부터 끝까지 한 번 실행합니다. 그래서 이 파일은 화면에 찍는 일을 모두 맨 끝의 if __name__ == "__main__": 아래에 두었습니다. 그 아래의 코드는 이 파일을 직접 실행할 때만 돕니다. 다른 파일이 load_policy_chunks를 가져갈 때는 아무것도 찍히지 않습니다.
핵심 정리
- 메타데이터는 청크가 어디서 왔는지 적어 둔 정보입니다. 답변에 근거를 붙이는 재료입니다.
- 출처는 자르는 순간에만 알 수 있습니다. 나중에는 붙일 수 없습니다.
- 청크 하나는
id,text,metadata(source,doc_title,chunk_no,article)로 이루어집니다. article에는 청크에 내용이 담긴 조항 제목을 모두 적습니다. 제목 없이 시작하는 청크는 직전 조항을 물려받습니다.- 청크 목록은
load_policy_chunks()로 다른 장에서 가져다 씁니다. 화면 출력은if __name__ == "__main__":아래에 둡니다.