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

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

실습 2 — 사내 문서 모으기

intranet 폴더에 있는 누리솔정밀(가상 회사)의 사내 문서 13개를 형식마다 읽어 한 꼴의 기록으로 모읍니다. 문서관리시스템에서 뽑았다고 가정한 intranet/_문서목록.csv 와 견주어 라벨을 붙이고 out_docs_raw.jsonl 로 저장합니다. 문서는 연습용으로 지어낸 것이고, 정제 연습을 위해 일부러 흠을 넣었습니다. 표준 라이브러리만 씁니다.

1단계. 폴더를 훑고 문서 목록과 견주기

Python
import csv, hashlib, io, json, zipfile
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from pathlib import Path
import pandas as pd

ROOT = Path("intranet")
listing = pd.read_csv(ROOT / "_문서목록.csv")          # 문서관리시스템에서 뽑은 목록(가상)
# 폴더 안의 모든 파일. "_" 로 시작하는 목록 파일은 뺀다
files = sorted(p.relative_to(ROOT).as_posix() for p in ROOT.rglob("*")
               if p.is_file() and not p.name.startswith("_"))
print("폴더의 파일:", len(files), "개 · 목록의 문서:", len(listing), "개")
print("목록에 없는 파일:", sorted(set(files) - set(listing["경로"])))
print("파일이 없는 목록:", sorted(set(listing["경로"]) - set(files)))
# 결과: 확장자마다 파일 수
print(pd.Series([Path(f).suffix for f in files]).value_counts().to_dict())
출력
폴더의 파일: 13 개 · 목록의 문서: 13 개
목록에 없는 파일: []
파일이 없는 목록: []
{'.html': 6, '.md': 3, '.txt': 2, '.docx': 1, '.csv': 1}

2단계. 형식마다 읽는 함수

Python
def decode(raw):
    """utf-8 로 읽어 보고, 안 되면 옛 한글 윈도 방식(cp949)으로 읽는다. 돌려주는 것: (글, 방식)"""
    for enc in ("utf-8-sig", "cp949"):
        try:
            return raw.decode(enc), enc
        except UnicodeDecodeError:
            pass
    raise ValueError("읽을 수 없는 인코딩")


class HtmlText(HTMLParser):
    """html 에서 본문 글만 뽑는다. script, style, title 과 메뉴(gnb), 꼬리말(footer), 게시 정보(info) 는 건너뛴다."""
    SKIP_TAGS, SKIP_CLASSES = {"script", "style", "title"}, {"gnb", "footer", "info"}
    VOID = {"meta", "br", "img", "link", "input", "hr"}          # 닫는 태그가 없는 태그

    def __init__(self):
        super().__init__()
        self.parts, self.stack = [], []        # stack: 열린 태그마다 "건너뛰는 중인가"

    def handle_starttag(self, tag, attrs):
        if tag in self.VOID:
            return
        skip = tag in self.SKIP_TAGS or dict(attrs).get("class") in self.SKIP_CLASSES or bool(self.stack and self.stack[-1])
        self.stack.append(skip)
        if not skip and tag in ("h1", "h2"):
            self.parts.append("\n# " if tag == "h1" else "\n## ")     # 제목은 마크다운 꼴로 남긴다

    def handle_endtag(self, tag):
        if tag in self.VOID:
            return
        if self.stack:
            self.stack.pop()
        if tag in ("p", "h1", "h2"):
            self.parts.append("\n")

    def handle_data(self, data):
        if not (self.stack and self.stack[-1]):
            self.parts.append(data)


W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"     # 워드 XML 태그 이름의 앞머리


def read_docx(raw):
    """docx(zip) 안의 word/document.xml 에서 문단과 표를 차례로 읽는다. 표는 행마다 '머리글 값' 문장으로 바꾼다."""
    root = ET.fromstring(zipfile.ZipFile(io.BytesIO(raw)).read("word/document.xml"))
    text_of = lambda node: "".join(t.text or "" for t in node.iter(W + "t"))
    lines = []
    for el in root.find(W + "body"):
        if el.tag == W + "p":                                      # 문단. 제목 꼴(Heading1, 2)이면 # 을 붙인다
            style = el.find(f"{W}pPr/{W}pStyle")
            name = style.get(W + "val") if style is not None else ""
            lines.append({"Heading1": "# ", "Heading2": "## "}.get(name, "") + text_of(el))
        elif el.tag == W + "tbl":                                  # 표
            rows = [[text_of(c) for c in r.iter(W + "tc")] for r in el.iter(W + "tr")]
            for r in rows[1:]:
                lines.append(", ".join([r[0]] + [f"{h} {v}" for h, v in zip(rows[0][1:], r[1:])]))
    return "\n".join(lines)


def read_file(path):
    """확장자에 맞게 읽는다. 돌려주는 것: (글, 읽은 방식)"""
    raw = path.read_bytes()
    if path.suffix == ".docx":
        return read_docx(raw), "docx"
    text, enc = decode(raw)
    if path.suffix == ".html":
        parser = HtmlText()
        parser.feed(text)
        text = "".join(parser.parts)
    elif path.suffix == ".csv":                                    # 표의 행마다 "열이름 값" 문장으로
        text = "\n".join(", ".join(f"{k} {v}" for k, v in r.items()) for r in csv.DictReader(text.splitlines()))
    return text, enc


# 결과: 워드 문서를 읽은 글. 표가 문장으로 바뀌었는지 본다
print(read_file(ROOT / "hr/복리후생_안내.docx")[0])
출력
# 복리후생 안내
## 건강검진
모든 직원은 해마다 건강검진을 받습니다. 검진 비용은 회사가 냅니다. 배우자는 2년마다 지원합니다.
## 경조금과 경조 휴가
경조금과 휴가는 아래 표와 같습니다.
본인 결혼, 경조금 100만원, 휴가 5일
자녀 출산, 경조금 50만원, 휴가 10일
부모 회갑, 경조금 30만원, 휴가 1일
부모 상, 경조금 100만원, 휴가 5일
## 사내 식당
점심 한 끼의 본인 부담은 2,500원입니다. 나머지는 회사가 냅니다.
## 통근버스
통근버스는 두 노선입니다. 1호차는 시청역에서, 2호차는 버스터미널에서 오전 7시 20분에 출발합니다.
## 자기계발비
외국어 학원비와 자격증 시험비를 한 해 80만원까지 지원합니다.

3단계. 모든 파일을 한 꼴로 읽기

Python
meta = listing.set_index("경로")
docs = []
for f in files:
    raw = (ROOT / f).read_bytes()
    text, how = read_file(ROOT / f)
    m = meta.loc[f]
    # 한 꼴의 기록: 경로와 라벨, 원본 파일의 해시, 뽑은 글
    docs.append({"경로": f, "제목": m["제목"], "부서": m["부서"], "등급": int(m["등급"]), "버전": int(m["버전"]),
                 "작성일": m["작성일"], "해시": hashlib.sha256(raw).hexdigest(), "글": text})
    print(f"{f} · {how} · {len(raw):,}바이트 → {len(text)}자")
출력
finance/경비정산기준.txt · utf-8-sig · 863바이트 → 399자
finance/법인카드_규정.html · utf-8-sig · 1,165바이트 → 227자
hr/복리후생_안내.docx · docx · 1,483바이트 → 362자
hr/인사평가_기준.md · utf-8-sig · 526바이트 → 262자
hr/휴가규정_2025.html · utf-8-sig · 1,191바이트 → 241자
hr/휴가규정_2026.html · utf-8-sig · 1,368바이트 → 322자
it/VPN_접속안내.txt · cp949 · 417바이트 → 268자
it/보안수칙.md · utf-8-sig · 760바이트 → 374자
notice/공지_보안점검.html · utf-8-sig · 919바이트 → 131자
notice/공지_사무실이전 (1).html · utf-8-sig · 927바이트 → 129자
notice/공지_사무실이전.html · utf-8-sig · 927바이트 → 129자
production/불량률_현황.csv · utf-8-sig · 266바이트 → 422자
production/설비점검_절차.md · utf-8-sig · 527바이트 → 247자

4단계. 저장하고 html 이 어떻게 줄었나 보기

Python
with open("out_docs_raw.jsonl", "w", encoding="utf-8") as fp:
    for d in docs:
        fp.write(json.dumps(d, ensure_ascii=False) + "\n")

# html 하나를 원본과 뽑은 글로 견준다
card = next(d for d in docs if d["경로"] == "finance/법인카드_규정.html")
print("원본", len((ROOT / card["경로"]).read_text(encoding="utf-8")), "자 → 본문", len(card["글"]), "자")
print(card["글"].strip())
출력
원본 767 자 → 본문 227 자
# 법인카드 규정




## 사용 한도

법인카드는 한 번에 50만원까지 씁니다. 그보다 많으면 미리 재무팀의 승인을 받습니다.


## 식대

회식과 업무 식사는 한 사람에 3만원까지입니다.


## 주말과 휴일

주말과 휴일에는 법인카드를 쓸 수 없습니다. 꼭 써야 하면 미리 승인을 받습니다.


## 분실

카드를 잃어버리면 바로 카드사와 재무팀(내선 1310)에 알립니다.

이 실습이 뜻하는 것

이 실습은 이사 가는 사무실의 서류를 상자 하나에 모으는 일과 같습니다. 먼저 캐비닛의 서류와 서류 대장을 한 장씩 맞춰 보고(1단계), 종이 문서, 워드 파일, 게시판 출력물처럼 생김새가 다른 서류마다 읽는 법을 정하고(2단계), 모든 서류를 같은 양식의 카드에 옮겨 적은 뒤(3단계), 카드를 상자에 넣고 게시판 출력물에서 메뉴와 꼬리말을 걷어 낸 결과를 봅니다(4단계). 여기서 서류 대장이 _문서목록.csv, 읽는 법이 read_file(), 카드가 경로·라벨·해시·글을 담은 기록, 상자가 out_docs_raw.jsonl 입니다.

단계출력뜻
1. 맞춰 보기폴더의 파일: 13 개 · 목록의 문서: 13 개, 빠진 것 [] 둘폴더의 파일과 대장이 한 건도 어긋나지 않았습니다
1. 형식{'.html': 6, '.md': 3, '.txt': 2, '.docx': 1, '.csv': 1}다섯 가지 형식이 섞여 있어 형식마다 읽는 법이 필요합니다
2. 워드 읽기"본인 결혼, 경조금 100만원, 휴가 5일" 같은 줄워드 파일의 제목은 #, ## 로, 표는 행마다 한 문장으로 바뀌었습니다. 한 줄만 봐도 무엇의 금액인지 압니다
3. 모두 읽기13줄, it/VPN_접속안내.txt · cp94913개가 모두 읽혔습니다. VPN(Virtual Private Network, 가상 사설망) 안내 하나만 옛 한글 방식(cp949)이고 나머지는 utf-8 입니다
3. 표의 크기production/불량률_현황.csv · 266바이트 → 422자표는 칸마다 열 이름을 붙여 문장으로 바꾸므로 오히려 글이 늘었습니다
4. html 줄이기원본 767 자 → 본문 227 자법인카드 규정에서 메뉴, 꼬리말, 스크립트를 걷어 내자 본문만 남아 30%쯤이 되었습니다. 빈 줄은 아직 남아 있습니다

한 줄로 줄이면, 생김새가 다른 사내 문서 13개를 하나도 빠뜨리지 않고 같은 꼴의 기록으로 모은 실습입니다. 아직 고치기 전의 날것이라, PDF(Portable Document Format)에서 뽑은 끊긴 줄, 개인정보, 사본과 옛 판, 남은 빈 줄이 그대로 있습니다. 이것을 고치고 걸러 조각으로 나누는 일은 실습 3에서 합니다.

코드 설명

  • 1단계에서 폴더의 파일 13개와 목록의 13줄이 빠짐없이 맞았습니다. 확장자는 html 6개, md 3개, txt 2개, docx 1개, csv 1개입니다. rglob("*") 은 하위 폴더까지 모든 파일을 훑습니다.
  • decode() 는 utf-8 로 먼저 읽고, 실패하면 cp949 로 읽습니다. 3단계의 출력을 보면 VPN 안내 하나만 cp949 로 읽혔고 나머지는 utf-8 입니다. utf-8-sig 는 utf-8 과 같되, 파일 맨 앞에 붙는 BOM 표시가 있으면 떼어 줍니다.
  • HtmlText 는 표준 라이브러리 HTMLParser 를 이어받아, 태그가 열릴 때마다 "건너뛰는 중인가"를 쌓아 둡니다. 메뉴나 꼬리말 안에 든 글은 버리고, h1, h2 는 #, ## 로 남겨 다음 실습에서 절을 나누는 데 씁니다.
  • read_docx() 는 워드 파일을 zip 으로 열어 문단과 표를 읽습니다. 2단계 출력처럼 제목 꼴 문단은 #, ## 로, 경조금 표는 "본인 결혼, 경조금 100만원, 휴가 5일" 같은 행 문장으로 바뀌었습니다. 표를 칸마다 흩뜨리지 않고 행마다 머리글을 붙여야, 조각 하나만 봐도 무엇의 금액인지 압니다.
  • 법인카드 규정의 html 은 767자였지만 메뉴, 꼬리말, 스크립트를 빼자 본문은 227자였습니다. 남은 빈 줄은 실습 3에서 정리합니다. csv 는 266바이트가 422자로 오히려 늘었는데, 행마다 열 이름을 붙여 문장으로 바꿨기 때문입니다.
← 이전 절11장 · 실습 1 — 오프라인으로 돌아가는지 확인하기다음 절 →11장 · 실습 3 — 정제하고 조각 나누기