11장. 폐쇄망 문서 파이프라인과 폐쇄형 RAG
실습 3 — 정제하고 조각 나누기
실습 2의 out_docs_raw.jsonl 을 읽어 PDF 에서 뽑은 글을 고치고, 개인정보를 가리고, 똑같은 파일과 옛 판을 거른 뒤, 라벨 붙은 조각으로 나눕니다. 결과는 out_docs_clean.jsonl(문서)과 out_chunks.jsonl(조각)입니다. 실습 2를 먼저 실행해야 합니다.
1단계. PDF 에서 뽑은 글 고치기
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단계. 개인정보 가리고 빈칸 정리하기
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단계. 똑같은 파일과 옛 판 거르기
# 이름이 짧은 것부터 본다. 사본 표시(" (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단계. 라벨 붙은 조각으로 나누고 저장하기
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개)입니다. 조각 글이 "[라인별 불량률 현황]" 처럼 문서 제목으로 시작하므로, 조각만 떼어 봐도 어느 문서인지 압니다.