문서 로드 · 청킹 — 내 문서를 다룰 수 있는 크기로 자른다
Article
15강까지 우리는 Claude 에게 도구를 쥐어줘서 계산을 하고, npm 레지스트리를 조회하게 만들었다. 오늘부터는 방향이 바뀐다. 도구로 "행동"을 시키는 대신, 내 문서를 검색해 답하게 만드는 여정을 시작한다. Part 5, RAG(Retrieval-Augmented Generation)의 시작이다.
RAG 의 첫걸음은 모델도, 프롬프트도 아니다. 문서를 다룰 수 있는 크기로 자르는 것이다. 오늘은 코드에서 Claude 를 한 번도 부르지 않는다. 그래도 이 작업 없이는 RAG 가 성립하지 않는다.
왜 통째로 넣으면 안 되나
우리 회사에 연차·원격근무·경비 규정을 담은 사내 문서 3개가 있다고 하자. 총 3,185자다. 지금 배운 것만으로 "내 문서에 답하는 어시스턴트"를 만든다면 이렇게 하고 싶을 것이다. 문서 전체를 시스템 프롬프트에 그냥 붙여넣는다.
문서가 3개일 땐 버틴다. 하지만 "내 문서"가 늘어날수록 이 방식은 비용도, 정확도도 무너진다. 진짜 필요한 건 질문과 관련 있는 부분만 골라 보여주는 것인데, 그러려면 먼저 문서를 검색 가능한 단위로 쪼개둬야 한다. 그 단위를 청크(chunk) 라고 부른다.
- 문서 로드 · 청킹 (오늘)문서를 문단 단위 조각으로 자른다
- 임베딩 (17강)각 조각을 의미가 담긴 숫자 벡터로 바꾼다
- 벡터 저장 · 검색 (18강)질문과 가장 가까운 조각을 찾는다
- RAG 파이프라인 (19강)찾은 조각만 프롬프트에 넣어 Claude 에게 묻는다
예제 문서를 만든다
ai-lab-b 저장소에 lesson-16 폴더를 만들고, 15강과 같은 방식으로 프로젝트를 세팅한다.
mkdir -p lesson-16/my-ai-assistant/docs
cd lesson-16/my-ai-assistant
npm init -y
npm i @anthropic-ai/sdk
npm i -D typescript tsx @types/node
docs 폴더 안에 사내 규정 문서 3개를 마크다운으로 넣는다. 각 문서는 제목(# ) 하나에 문단 여러 개로 구성했다. 실제 사내 위키 문서와 비슷한 모양이다.
- docs
- 연차-휴가-정책.md
- 원격근무-정책.md
- 경비-처리-가이드.md
통짜 자르기의 문제
가장 단순한 방법은 글자 수로 무 자르듯 써는 것이다. 220자마다 자른다. 그런데 이러면 문장 한가운데가 잘려나간다.
- ...최대 11일까지 쌓을 수 있다. 연차는 1일 단위 또는 반차(0.5일) 단위로 사용할 수 있고, 반차는 오전·오후 중 선
- ── 221번째 글자에서 뚝 ──
- 택한다. 연차 사용을 원하는 직원은...
"반차는 오전·오후 중 선"에서 끊기면, 이 청크만 검색에 걸렸을 때 문장이 뭘 말하는지 알 수 없다. 자연스러운 경계(문단)를 지키면서 자르는 게 오늘 코드의 핵심이다.
문단 단위로 합쳐 자르는 함수
전략은 이렇다. 문서를 빈 줄(\n\n) 기준으로 문단을 나눈 뒤, 문단을 하나씩 이어 붙이다가 최대 크기(220자)를 넘기기 직전에 새 청크로 넘어간다. 문단 중간은 절대 자르지 않는다.
chunk.ts 파일을 만든다.
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
interface Chunk {
source: string;
index: number;
text: string;
}
const MAX_CHUNK_SIZE = 220;
function splitIntoChunks(text: string, source: string): Chunk[] {
const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const chunks: Chunk[] = [];
let buffer = "";
let index = 0;
const flush = () => {
if (buffer.trim()) {
chunks.push({ source, index, text: buffer.trim() });
index++;
buffer = "";
}
};
for (const para of paragraphs) {
if (buffer && buffer.length + para.length + 2 > MAX_CHUNK_SIZE) {
flush();
}
buffer += (buffer ? "\n\n" : "") + para;
}
flush();
return chunks;
}
async function loadAndChunkDocs(dir: string): Promise<Chunk[]> {
const files = await readdir(dir);
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
const allChunks: Chunk[] = [];
for (const file of mdFiles) {
const content = await readFile(path.join(dir, file), "utf-8");
const chunks = splitIntoChunks(content, file);
allChunks.push(...chunks);
}
return allChunks;
}
const chunks = await loadAndChunkDocs("./docs");
console.log(`문서 ${new Set(chunks.map((c) => c.source)).size}개 → 청크 ${chunks.length}개\n`);
for (const c of chunks) {
console.log(`[${c.source} #${c.index}] (${c.text.length}자)`);
console.log(c.text.replace(/\n/g, " "));
console.log("");
}
flush 는 지금까지 모은 buffer 를 청크 하나로 확정 짓고 비우는 내부 함수다. 문단을 하나씩 훑으면서 더하면 220자를 넘기는 순간, 더하기 전에 먼저 지금까지 쌓인 걸 청크로 밀어낸다. 그래서 어떤 청크도 문단 중간에서 끊기지 않는다.
실제로 돌려본다
npx tsx chunk.ts
- 문서 3개 → 청크 9개
- [경비-처리-가이드.md #0] (94자)
- # 경비 처리 가이드 업무상 지출은 원칙적으로 법인카드를 사용한다. 법인카드를 소지하지 않았거나 사용이 불가능한 상황에서만 개인카드로 결제하고 추후 정산받을 수 있다.
- [경비-처리-가이드.md #1] (125자)
- 개인카드 정산을 받으려면 결제일로부터 30일 이내에 경비 시스템에 영수증과 사용 목적을 등록해야 한다. 30일이 지난 건은 팀장 승인 없이는 정산되지 않는다. 영수증은 사진이 아니라 원본 스캔본 또는 전자 영수증이어야 한다.
- [경비-처리-가이드.md #2] (191자)
- 식대는 1인당 1만 5천 원, 야근 식대는 2만 원까지 인정된다. 거래처 접대비는 팀장 사전 승인이 있어야 하며, 1건당 20만 원을 초과하면 본부장 승인이 추가로 필요하다. 매월 경비 마감일은 25일이다. 25일 이후 등록 건은 다음 달 정산으로 넘어간다. 국외 출장 경비는 별도 규정을 따르며, 환율은 결제일 기준 매매기준율을 적용한다.
- [연차-휴가-정책.md #0] (139자)
- # 연차 휴가 정책 입사 1년 차부터 연 15일의 연차 휴가가 발생한다. 입사 첫해에는 매달 만근 시 1일씩 발생하며, 최대 11일까지 쌓을 수 있다. 연차는 1일 단위 또는 반차(0.5일) 단위로 사용할 수 있고, 반차는 오전·오후 중 선택한다.
- [연차-휴가-정책.md #1] (130자)
- 연차 사용을 원하는 직원은 사용 예정일 최소 3일 전까지 팀장에게 승인을 요청해야 한다. 다만 본인 또는 가족의 병가처럼 긴급한 사유는 사후 승인으로 대체할 수 있다. 팀장은 특별한 업무 공백 사유가 없는 한 승인을 거부할 수 없다.
- [연차-휴가-정책.md #2] (212자)
- 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사용하지 못한 일수에 한해 다음 해로 최대 5일까지 이월할 수 있다. 이월 신청은 매년 12월 첫째 주까지 인사팀에 제출한다. 연차 소진 촉진 제도를 운영한다. 매년 10월, 남은 연차가 5일 이상인 직원에게 인사팀이 사용 계획을 안내하고, 안내에도 사용하지 않은 연차는 회사 귀책 사유가 없는 한 12월 31일자로 소멸한다.
- [원격근무-정책.md #0] (138자)
- # 원격근무 정책 전 직원은 주 3일까지 원격 근무를 신청할 수 있다. 원격 근무를 하려면 전날 18시까지 팀 채널에 근무 형태(원격/출근)를 공유해야 한다. 사전 공유 없이 당일 아침에 갑자기 원격으로 전환하는 것은 원칙적으로 허용하지 않는다.
- [원격근무-정책.md #1] (198자)
- 코어타임은 10시부터 16시까지이며, 이 시간에는 원격 근무 중이라도 화상 연결이 가능한 상태를 유지해야 한다. 회의 요청에는 10분 이내 응답하는 것을 기본 원칙으로 한다. 신규 입사자는 입사 후 3개월간 매일 출근하며 팀 적응을 우선한다. 이후부터 원격 근무 신청 자격이 주어진다. 수습 기간 중 원격 근무가 필요한 경우 팀장과 별도 협의를 거친다.
- [원격근무-정책.md #2] (122자)
- 해외에서의 원격 근무(워케이션)는 연 최대 14일까지 허용하며, 최소 2주 전 인사팀에 신청해 시차로 인한 협업 공백이 없는지 검토받아야 한다. 보안 정책상 사내 시스템 접속은 반드시 VPN을 통해서만 이루어져야 한다.
3개 문서, 3,185자가 9개 청크로 나뉘었다. 청크마다 94~212자로 크기가 들쭉날쭉한데, 이게 의도한 동작이다. 문단 경계를 지키다 보니 어떤 청크는 문단 하나만으로 끝나고(94자), 어떤 청크는 문단 두 개를 담아 220자에 가깝게 찼다(212자). 문장이 중간에 끊긴 청크는 하나도 없다.
지금까지 만든 것
- my-ai-assistant
- docs/
- chunk.ts
- .env.example
- package.json
정리하면
문서를 통째로 프롬프트에 넣는 방식은 문서가 늘어나면 비용과 정확도 둘 다 무너진다. 문단 경계를 지키면서 적당한 크기로 잘라두면, 나중에 질문과 관련된 조각만 골라 쓸 수 있는 재료가 생긴다.
- 01
문서 전체를 매번 넣는 방식의 한계를 봤다
문서가 3개일 땐 버티지만, 300개가 되면 비용도 정확도도 무너진다.
- 02
문단 단위로 합쳐 자르는 청킹 함수를 만들었다
문단을 이어 붙이다가 최대 크기(220자)를 넘기기 직전에 새 청크로 넘어간다. 문단 중간은 절대 자르지 않는다.
npx tsx chunk.ts - 03
실제로 돌려 3,185자가 9개 청크로 나뉘는 것을 확인했다
청크 크기는 94~212자로 들쭉날쭉하지만, 문장이 끊긴 청크는 하나도 없다.
지금 만든 9개 청크는 아직 그냥 글자 뭉치다. 사용자가 "연차 며칠 남았어?"라고 물어도, 청크 텍스트에 "연차"라는 글자가 그대로 들어 있어야만 찾을 수 있다. 다음 17강에서는 이 한계를 넘는다. 각 청크를 의미가 담긴 숫자 벡터로 바꿔서, 표현이 달라도 뜻이 비슷하면 찾아내는 방법을 만든다.