RAG 자료창고 구축: 대반열반경 청크 만들고 검증하기

지난 글 요약

지난 글에서는 불교 자료창고에 자료를 쌓을 때 쓸 데이터 구조를 7개 필드로 확정했다.

{
“id”: “출처 추적용 고유번호”,
“source”: “원출처(경전명, 책 제목 등)”,
“text”: “원문 인용구”,
“translation”: “표준/공식 번역(없으면 null)”,
“note”: “개인적인 해석·감상(항상 이 필드로만)”,
“tags”: [“검색 품질 검증용 개념어”],
“domain”: [“자료가 속한 학문 영역”]
}

핵심 원칙은 세 가지였다.

  • translation과 note는 역할이 고정된다 — 표준 번역이 있으면 translation, 개인적인 감상은 항상 note에. 둘을 섞지 않는다.
  • 청크 크기는 길이가 아니라 의미 완결성이 기준이다 — “이 덩어리가 하나의 완결된 의미를 담고 있는가”로 자른다.
  • 구조는 고정된 설계도가 아니다 — 반야심경만 다룰 땐 6개 필드로 충분했지만, 공부 범위가 불교·정신분석·실존철학으로 넓어지면서 domain 필드가 뒤늦게 필요해졌다. 필요가 생길 때 넓혀가면 된다.

그리고 저장 형식은 JSON이 아니라 JSONL로 가기로 했다 — 청크 하나당 한 줄씩, 배열 기호 없이 파일 맨 아래에 계속 추가하는 방식. 저장 전에는 json.loads(line)로 한 줄씩 검증하는 코드를 직접 돌려본다.

청크를 어떻게 나눌 것인가

이번엔 그 구조를 실제로 써보는 차례다.
대반열반경, 붓다의 유언 구절을 자료창고에 넣어보기로 했다.

원문은 이렇다.

아난다여, 비구는 몸[身]을 관찰하며 몸에 머물면서, 열심히 주의집중을 하고 알아차려 세간에 대한 탐욕과 불만을 제거해야 한다. 감정[受]을 관찰하며 감정에 머물면서, 열심히 주의집중을 하고 알아차려 세간에 대한 탐욕과 불만을 제거해야 한다. 마음[心]을 관찰하며 마음에 머물면서, 열심히 주의집중을 하고 알아차려 세간에 대한 탐욕과 불만을 제거해야 한다. 법[法]을 관찰하며 법에 머물면서, 열심히 주의집중을 하고 알아차려, 세간에 대한 탐욕과 불만을 제거해야 한다.

아난다여, 이와 같이 하는 것이 비구가 자신을 등불로 삼고, 자신을 귀의처로 삼고, 다른 것을 귀의처로 삼지 않으며, 가르침을 등불로 삼고, 가르침을 귀의처로 삼고, 다른 것을 귀의처로 삼지 않고 살아가는 것이다.

이 구절, 사실 두 부분으로 나뉜다.
사념처를 관찰하며 머물라는 방법 부분과, 자신과 가르침을 등불 삼으라는 결론 부분.
처음엔 이 둘이 하나로 붙어야 하지 않나 싶었다.
결론이 방법을 통해 달성되는 거니까, 둘을 하나의 청크로 묶어야 할 것 같았다.

그런데 지난 글에서 세운 청킹 기준은 “연관이 있는가”가 아니라 “각각 완결된 의미를 가졌는가”였다.
“사념처가 뭐야?”와 “자등명 법등명이 뭐야?”는 실제로 다른 질문이다.
또 RAG는 질문 하나에 청크 하나만 가져오는 게 아니라, 관련도 높은 청크를 여러 개 같이 가져온다.
상황에 따라 필요한 만큼 조합해서 가져올 수 있으니,
완결된 의미를 가졌는지를 기준으로 두개의 청크로 나눴다.

<청크1>

{“id”: “nirvana_sutra_001”, “source”: “대반열반경 (디가니까야, 이중표 역, 2.26)”, “text”: “몸[身]을 관찰하며 몸에 머물면서, 열심히 주의집중을 하고 알아차려 세간에 대한 탐욕과 불만을 제거해야 한다. 감정[受]을 관찰하며 감정에 머물면서, 열심히 주의집중을 하고 알아차려 세간에 대한 탐욕과 불만을 제거해야 한다. 마음[心]을 관찰하며 마음에 머물면서, 열심히 주의집중을 하고 알아차려 세간에 대한 탐욕과 불만을 제거해야 한다. 법[法]을 관찰하며 법에 머물면서, 열심히 주의집중을 하고 알아차려, 세간에 대한 탐욕과 불만을 제거해야 한다.”, “translation”: null, “note”: “受·心·法이 다 우리가 흔히 말하는 ‘마음’에 대한 설명인 것 같은데 뭐가 다른지 헷갈렸다. 受는 경험의 느낌 색조(즐거움/괴로움/무덤덤), 心은 그 순간 마음 전체의 성질(분노 상태인가, 탐욕이 있는 상태인가 등), 法은 그걸 오온·오개 같은 붓다의 분류틀로 이름 붙이고 조건에 따라 생멸하는 과정까지 보는 것 — 이라고 정리는 했지만, 50년 겪어온 내 마음인데도 글로는 잘 안 잡힌다. 실제 관찰해보면서 알아갈 수밖에 없을 것 같다.”, “tags”: [“사념처”, “신수심법”, “법념처”], “domain”: [“불교”]}

<청크2>

{“id”: “nirvana_sutra_002”, “source”: “대반열반경 (디가니까야, 이중표 역, 2.26)”, “text”: “비구가 자신을 등불로 삼고, 자신을 귀의처로 삼고, 다른 것을 귀의처로 삼지 않으며, 가르침을 등불로 삼고, 가르침을 귀의처로 삼고, 다른 것을 귀의처로 삼지 않고 살아가는 것이다.”, “translation”: null, “note”: “붓다로드 숙제로 이 문장만 따로 떼서 주문처럼 외워왔다. ‘가르침’과 법념처의 ‘법[法]’이 헷갈렸는데, 가르침은 붓다가 설한 교법 전체(삼보 중 법보)를 가리키는 거고, 법념처의 법은 오온·오개 같은 관찰 대상이라 서로 다른 층위였다. 같은 한자 ‘法’이 두 뜻을 다 품고 있어서 헷갈렸던 것.”, “tags”: [“자등명”, “법등명”, “자귀의”, “법귀의”], “domain”: [“불교”]}

translation은 null로 뒀다.
반야심경 때는 한문 원문과 한글 번역이 따로 있었지만, 이 구절은 이중표 교수님이 이미 한글로 번역한 거라 text 자체가 번역본이다.
translation을 또 채우면 중복이라 판단했다.

JSONL로 저장하고 검증하기

두 청크를 nirvana_sutra.jsonl 파일에 한 줄씩 넣고, 검증 코드를 돌려봤다.

import json
with open('nirvana_sutra.jsonl', encoding='utf-8') as f:
    for line in f:
        json.loads(line)

처음엔 encoding=’utf-8′ 옵션을 넣지 않아서 다음과 같은 에러가 났다.
“UnicodeDecodeError: ‘cp949’ codec can’t decode byte 0xeb in position 39: illegal multibyte sequence”

윈도우가 파일을 기본값인 cp949로 읽으려다, utf-8로 저장된 한글 텍스트를 못 읽어서 난 에러였다.
open()에 encoding=’utf-8’을 추가해서 해결했다.

다시 돌리니 아무 메시지 없이 다음 명령줄로 넘어갔다. 에러 없이 통과했다는 뜻이다.
이번 편에서는 지난 글에서 정한 구조와 원칙을 실제 자료로 채워보고, JSONL로 저장하고, 검증까지 마쳤다.

다음은 이 청크들을 임베딩해서, 질문을 던졌을 때 검색이 되는지 확인해볼 차례다.

Q1. RAG 자료창고에서 하나의 경전 구절을 여러 청크로 나눌 때, 기준은 뭔가요?

“두 부분이 서로 연관이 있는가”가 아니라 “각각 완결된 의미를 가졌는가”가 기준이에요. 방법과 결론처럼 서로 이어지는 내용이라도, 실제 각각이 완결된 의미를 가졌다면 나누는 게 검색에 유리해요.

Q2. 청크를 나누면 서로 연관된 내용끼리는 같이 검색이 안 되나요?

아니요, 같이 검색될 수 있어요. RAG는 질문 하나에 청크 하나만 가져오는 게 아니라 관련도 높은 청크를 여러 개(top-k) 함께 가져오기 때문에, 복합적인 질문이 들어오면 나눠둔 청크들이 같이 검색될 수 있어요.

Q3. 파이썬으로 한글이 포함된 JSONL 파일을 읽을 때 UnicodeDecodeError가 나면 어떻게 하나요?

open() 함수에 encoding=’utf-8′ 인자를 추가하면 돼요. 윈도우 환경에서는 파일을 기본값인 cp949로 읽으려다, utf-8로 저장된 한글 텍스트를 못 읽어서 이 에러가 나는 경우가 많아요.

Q4. JSON에서 한 필드에 값을 여러 개 넣고 싶을 때는 어떻게 하나요?

대괄호([ ])로 감싸서 배열 형태로 만들면 돼요. 값이 하나뿐이어도 나중에 여러 개가 필요해질 수 있는 필드라면 처음부터 배열로 설계해두는 게 좋아요.

관련 글 보기