LLM 엔지니어링11장 · 폐쇄망 문서 파이프라인과 폐쇄형 RAG17 / 23← 이전목차다음 →MetaContents

11장. 폐쇄망 문서 파이프라인과 폐쇄형 RAG

실습 3 — 정제하고 조각 나누기

실습 2의 out_docs_raw.jsonl 을 읽어 PDF 에서 뽑은 글을 고치고, 개인정보를 가리고, 똑같은 파일과 옛 판을 거른 뒤, 라벨 붙은 조각으로 나눕니다. 결과는 out_docs_clean.jsonl(문서)과 out_chunks.jsonl(조각)입니다. 실습 2를 먼저 실행해야 합니다.

1단계. PDF 에서 뽑은 글 고치기

Python
import json, re

docs = [json.loads(line) for line in open("out_docs_raw.jsonl", encoding="utf-8")]


def fix_pdf_text(text):
    """쪽 번호(- 1 -)가 있는 글만 PDF 에서 뽑은 글로 보고, 쪽 머리말과 쪽 번호를 빼고 끊긴 줄을 잇는다"""
    lines = text.split("\n")
    if not any(re.fullmatch(r"- \d+ -", l.strip()) for l in lines):
        return text                                     # PDF 에서 뽑은 글이 아니면 그대로 둔다
    header = lines[0]                                   # 쪽마다 되풀이되는 머리말
    lines = [l for l in lines if l != header and not re.fullmatch(r"- \d+ -", l.strip())]
    out = []
    for l in lines:
        if out and out[-1] and l and not out[-1].endswith("."):
            out[-1] += l                                # 앞 줄이 문장 끝(.)이 아니면 끊긴 줄로 보고 잇는다
        else:
            out.append(l)
    return "\n".join(out)


expense = next(d for d in docs if d["경로"] == "finance/경비정산기준.txt")
print("--- 고치기 전(앞 8줄) ---")
print("\n".join(expense["글"].split("\n")[:8]))
for d in docs:
    d["글"] = fix_pdf_text(d["글"])
print("--- 고친 뒤(앞 3줄) ---")
print("\n".join(expense["글"].strip().split("\n")[:3]))
출력
--- 고치기 전(앞 8줄) ---
누리솔정밀 경비 정산 기준 (2026)

제1조(목적) 이 기준은 출장과 업무에 쓴 비용을 정산
하는 방법을 정합니다.

제2조(일비) 국내 출장의 일비는 하루 2만원입니다.
해외 출장의 일비는 하루 50달러입니다.

--- 고친 뒤(앞 3줄) ---
제1조(목적) 이 기준은 출장과 업무에 쓴 비용을 정산하는 방법을 정합니다.

제2조(일비) 국내 출장의 일비는 하루 2만원입니다. 해외 출장의 일비는 하루 50달러입니다.

2단계. 개인정보 가리고 빈칸 정리하기

Python
PHONE = re.compile(r"01[016789]-\d{3,4}-\d{4}")            # 휴대폰 번호 꼴
EMAIL = re.compile(r"[\w.-]+@[\w-]+\.[\w.]+")               # 메일 주소 꼴

masked = 0
for d in docs:
    text, n1 = PHONE.subn("[전화번호]", d["글"])             # subn: 바꾼 글과 바꾼 횟수를 함께 준다
    text, n2 = EMAIL.subn("[메일]", text)
    text = re.sub(r"[ \t]+", " ", text)                      # 겹친 빈칸을 하나로
    text = re.sub(r"\n{3,}", "\n\n", text).strip()           # 빈 줄이 셋 이상이면 둘로
    d["글"], masked = text, masked + n1 + n2
    if n1 + n2:
        print(d["경로"], "에서", n1 + n2, "개를 가렸습니다")
security = next(d for d in docs if d["경로"] == "it/보안수칙.md")
print(security["글"].split("## 담당자")[1].strip())
출력
it/보안수칙.md 에서 3 개를 가렸습니다
보안 문의는 보안팀 김하늘 책임([전화번호], [메일])에게 합니다.
사고는 내선 1199 로 바로 신고합니다. 야간에는 당직자([전화번호])에게 연락합니다.

3단계. 똑같은 파일과 옛 판 거르기

Python
# 이름이 짧은 것부터 본다. 사본 표시(" (1)")가 없는 원래 이름이 남게 된다
seen, unique = {}, []
for d in sorted(docs, key=lambda d: len(d["경로"])):
    if d["해시"] in seen:
        print("똑같은 파일:", d["경로"], "=", seen[d["해시"]])
        continue
    seen[d["해시"]] = d["경로"]
    unique.append(d)

# 제목이 같은 문서는 버전이 가장 높은 것만 남긴다
latest = {}
for d in unique:
    if d["제목"] not in latest or d["버전"] > latest[d["제목"]]["버전"]:
        latest[d["제목"]] = d
for d in unique:
    if latest[d["제목"]] is not d:
        new = latest[d["제목"]]
        print("옛 판:", d["경로"], f"(판 {d['버전']}) → 남길 판:", new["경로"], f"(판 {new['버전']})")
        # 두 판에서 "반차" 절의 글을 견준다
        for x in (d, new):
            print("   ", x["작성일"], x["글"].split("## 반차")[1].split("##")[0].split())
clean = sorted((d for d in unique if latest[d["제목"]] is d), key=lambda d: d["경로"])
print(len(docs), "개 →", len(clean), "개")
출력
똑같은 파일: notice/공지_사무실이전 (1).html = notice/공지_사무실이전.html
옛 판: hr/휴가규정_2025.html (판 1) → 남길 판: hr/휴가규정_2026.html (판 2)
    2025-03-02 ['반차는', '4시간', '단위로', '씁니다.', '반차는', '쓰기', '하루', '전까지', '팀장의', '결재를', '받아야', '합니다.']
    2026-02-02 ['반차는', '4시간', '단위로', '씁니다.', '2026년부터', '반차는', '쓰는', '날', '오전', '9시까지만', '신청하면', '됩니다.']
13 개 → 11 개

4단계. 라벨 붙은 조각으로 나누고 저장하기

Python
def split_doc(d):
    """'## 제목', '제N조', 'N. 제목', 표의 행을 경계로 조각을 나누고, 조각마다 라벨을 붙인다"""
    pieces, head, body = [], "", []
    for line in d["글"].split("\n"):
        if line.startswith("# ") or line.startswith("["):       # 문서 제목 줄은 라벨에 있으므로 뺀다
            continue
        is_head = line.startswith("## ") or re.match(r"\d+\. ", line)            # 절 제목 줄
        is_item = re.match(r"제\d+조", line) or (d["경로"].endswith(".csv") and line.strip())   # 조항, 표의 행
        if is_head or is_item:
            if "".join(body).strip():
                pieces.append((head, body))
            head, body = (line.lstrip("# "), []) if is_head else ("", [line])
        else:
            body.append(line)
    if "".join(body).strip():
        pieces.append((head, body))
    chunks = []
    for n, (h, b) in enumerate(pieces):
        text = "\n".join(l for l in b if l.strip())             # 조각 안의 빈 줄은 뺀다
        chunks.append({"id": f"{d['경로']}#{n}", "글": f"[{d['제목']}] {h}".strip() + "\n" + text,
                       **{k: d[k] for k in ("경로", "제목", "부서", "등급", "버전", "작성일")}})
    return chunks


chunks = [c for d in clean for c in split_doc(d)]
with open("out_docs_clean.jsonl", "w", encoding="utf-8") as fp:
    fp.writelines(json.dumps(d, ensure_ascii=False) + "\n" for d in clean)
with open("out_chunks.jsonl", "w", encoding="utf-8") as fp:
    fp.writelines(json.dumps(c, ensure_ascii=False) + "\n" for c in chunks)

print("조각", len(chunks), "개")
import pandas as pd
# 결과: 문서마다 조각 수와 등급
print(pd.DataFrame(chunks).groupby(["제목", "등급"]).size().rename("조각 수").reset_index().to_string(index=False))
for c in chunks[:2] + [c for c in chunks if "불량률" in c["제목"]][:1]:
    print(repr(c["글"]))
출력
조각 51 개
         제목  등급  조각 수
PC 보안 점검 안내   1     2
  VPN 접속 안내   2     4
   경비 정산 기준   1     6
 라인별 불량률 현황   2     9
    법인카드 규정   2     4
    복리후생 안내   1     5
  사무실 이전 안내   1     2
설비 일상 점검 절차   2     4
    인사평가 기준   3     5
    정보보안 수칙   1     5
      휴가 규정   1     5
'[경비 정산 기준]\n제1조(목적) 이 기준은 출장과 업무에 쓴 비용을 정산하는 방법을 정합니다.'
'[경비 정산 기준]\n제2조(일비) 국내 출장의 일비는 하루 2만원입니다. 해외 출장의 일비는 하루 50달러입니다.'
'[라인별 불량률 현황]\n라인 A, 월 2026-07, 생산수량 12000, 불량수량 132, 불량률 1.1%'

이 실습이 뜻하는 것

이 실습은 상자에 모은 서류를 정리해 색인 카드로 만드는 일과 같습니다. 쪽마다 끊겨 나온 글을 이어 붙이고(1단계), 전화번호와 메일 주소를 검은 펜으로 가리고 빈칸을 정리하고(2단계), 똑같은 복사본과 새 판이 나온 옛 서류를 빼고(3단계), 남은 서류를 조항이나 절마다 잘라 "어느 서류의 몇 번째"라는 꼬리표를 붙입니다(4단계). 여기서 이어 붙이기가 fix_pdf_text(), 검은 펜이 정규식의 subn(), 복사본 찾기가 해시, 꼬리표 붙은 카드가 out_chunks.jsonl 의 조각입니다.

단계출력뜻
1. 고치기 전"…비용을 정산" 다음 줄 "하는 방법을 정합니다."PDF 에서 뽑은 글이라 한 문장이 쪽 폭에서 끊겨 두 줄로 나뉘어 있습니다
1. 고친 뒤"…비용을 정산하는 방법을 정합니다."머리말과 쪽 번호가 빠지고, 끊긴 줄이 한 문장으로 이어졌습니다
2. 가리기it/보안수칙.md 에서 3 개를 가렸습니다휴대폰 번호 2개와 메일 1개가 [전화번호], [메일] 로 바뀌었습니다. 이름 "김하늘"(지어낸 이름)은 그대로 남았습니다
3. 사본똑같은 파일: notice/공지_사무실이전 (1).html = …내용이 똑같은 사본 하나를 해시로 찾아 뺐습니다
3. 옛 판2025 판 "하루 전까지 … 결재", 2026 판 "오전 9시까지만 신청"반차 규정이 판마다 다릅니다. 옛 판을 남기면 같은 질문에 서로 다른 답이 나올 수 있어 새 판만 남겼습니다
3. 개수13 개 → 11 개사본 1개와 옛 판 1개를 빼서 11개가 남았습니다
4. 조각조각 51 개와 문서별 조각 수, 등급문서 11개가 51조각이 되었습니다. 불량률 표는 행마다 한 조각씩 9조각이고, 인사평가 기준은 비밀(등급 3)입니다
4. 조각 글'[라인별 불량률 현황]\n라인 A, …'조각마다 문서 제목이 앞에 붙어, 조각만 떼어 봐도 어느 문서인지 압니다

한 줄로 줄이면, 날것의 문서 13개를 검색에 바로 쓸 수 있는 꼬리표 붙은 조각 51개로 바꾼 실습입니다. 조각마다 등급이 붙어 있어 실습 4, 6에서 사용자 등급으로 거를 수 있습니다. 다만 가리기는 정해 둔 휴대폰 번호와 메일 꼴만 잡으므로, 이름이나 다른 꼴의 개인정보까지 가려졌다는 뜻은 아닙니다.

코드 설명

  • 1단계에서 쪽 번호가 있는 경비 정산 기준만 고쳐졌습니다. 머리말과 "- 1 -" 이 빠지고, "정산" 과 "하는 방법을" 처럼 끊긴 줄이 한 문장으로 이어졌습니다. 교안을 만들며 처음에는 줄 잇기를 모든 문서에 걸었는데, VPN 안내의 "1. 접속 프로그램" 이 다음 줄과 붙고 워드 표의 행들까지 한 줄로 이어졌습니다. 그래서 쪽 번호가 있는 글에만 걸도록 했습니다.
  • 2단계에서 보안 수칙의 휴대폰 번호 2개와 메일 1개, 모두 3개를 가렸습니다. subn() 은 바꾼 글과 함께 바꾼 횟수를 돌려주므로 몇 개를 가렸는지 셀 수 있습니다. VPN 안내의 vpn.example.com 은 @ 가 없어 메일 꼴에 걸리지 않았습니다. 이름(김하늘, 지어낸 이름)은 그대로 남았으니, 이름까지 가릴지는 회사의 기준으로 정합니다.
  • 3단계에서 사무실 이전 공지의 사본(" (1)")이 해시로 걸러지고, 2025년 휴가 규정이 옛 판으로 걸러져 13개가 11개가 되었습니다. 두 판의 반차 규정은 "하루 전까지 결재" 와 "오전 9시까지 신청" 으로 다릅니다. 옛 판을 남겨 두면 같은 질문에 서로 다른 두 답이 검색될 수 있습니다.
  • 4단계에서 문서 11개가 51조각이 되었습니다. 불량률 표는 행마다 한 조각(9개), 경비 정산 기준은 조항마다 한 조각(6개)입니다. 조각 글이 "[라인별 불량률 현황]" 처럼 문서 제목으로 시작하므로, 조각만 떼어 봐도 어느 문서인지 압니다.
← 이전 절11장 · 실습 2 — 사내 문서 모으기다음 절 →11장 · 실습 4 — 벡터DB 세 가지 견주기