11장. 폐쇄망 문서 파이프라인과 폐쇄형 RAG
프로젝트 1 — 문서가 바뀌어도 맞게 답하는 사내 문서 RAG
누리솔정밀(가상 회사)의 사내 문서로 폐쇄형 RAG 를 만들고, 문서가 바뀌었을 때 바뀐 것만 색인에 반영하는 갱신 기능을 붙여 보세요. 경비 정산 기준이 개정되고, 통근버스 공지가 새로 올라오고, 옛 휴가 규정이 지워졌습니다. 갱신 전과 뒤에 같은 질문을 물어 답이 새 문서를 따르는지 확인합니다. 모든 작업은 바깥 연결을 막은 채 합니다. 회사와 문서, 숫자는 모두 지어낸 것입니다.
| 파일 | 생김새 |
|---|---|
intranet/ | 사내 문서 13개(html, docx, md, txt, csv)와 _문서목록.csv(경로, 제목, 부서, 등급, 버전, 작성일) |
intranet_update/ | 바뀐 문서 둘(finance/경비정산기준.txt, notice/공지_통근버스.html)과 _변경목록.csv(경로, 변경, 제목, 부서, 등급, 버전, 작성일). 변경은 추가, 수정, 삭제 |
intranet_update_qa.jsonl | 갱신 뒤에 물을 질문 4개. 질문, 열쇠(갱신 뒤의 정답), 출처, 등급 |
요구사항은 다섯 가지입니다.
- 실습 1의
out_offline.guard()로 바깥 연결을 막고 시작합니다. 끝날 때 막은 주소가 0개인지 보입니다. - 폴더 하나를 받아 수집, 정제, 중복과 옛 판 거르기, 조각 나누기를 하는 함수를 만듭니다.
- 운영 폴더(
out_live/)를intranet의 복사본으로 만들고, Chroma 색인과 문서별 해시 기록(out_index_state.json)을 만듭니다. intranet_update의 변경을 운영 폴더와 문서 목록에 반영한 뒤, 해시를 견주어 추가, 수정, 삭제된 문서의 조각만 색인에 반영하는sync()를 만듭니다.- 갱신 전과 뒤에 질문 4개를 정직원(등급 2)으로 묻고 견줍니다.
단계별 힌트입니다.
- 실습 2의
read_file(), 실습 3의fix_pdf_text(),split_doc()를 함수로 다시 씁니다. 문서 목록은 폴더 안의_문서목록.csv에서 읽게 하면 갱신 뒤의 폴더에도 같은 함수를 쓸 수 있습니다. - 운영 폴더는
shutil.copytree(..., dirs_exist_ok=True)로 만들고, 변경 목록의 "삭제" 행은 파일을 지우고 문서 목록에서도 뺍니다. - 해시 기록은 "색인에 들어 있는 문서의 경로 → 해시" 사전입니다. 옛 판과 사본을 거른 뒤의 문서로 만들어야, 지워진 옛 판이 다시 들어오지 않습니다.
- 수정된 문서는
col.delete(where={"경로": 경로})로 옛 조각을 모두 지우고 새 조각을 넣습니다. - 실습 6의
answer()를 그대로 쓰되, 갱신 뒤에는 색인이 바뀌므로 같은 컬렉션을 다시 찾습니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
사내 문서는 밖으로 가져올 수 없으므로, 공개된 규정과 안내문으로 같은 파이프라인을 연습합니다. 내려받기 전에 이용 약관을 확인하고, 개인정보가 든 문서는 쓰지 않습니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 공공기관의 내부 규정 | 국가법령정보센터(law.go.kr)의 행정규칙, 공공기관 경영정보 공개시스템(alio.go.kr) | 여비, 복무, 보안 규정. html 과 PDF |
| 공문서와 보도자료 | 공공데이터포털(data.go.kr), 각 기관 누리집 | hwp, PDF, html 이 섞인 실제 문서 묶음 |
| 바뀐 판 | 같은 규정의 개정 이력(국가법령정보센터의 연혁) | 옛 판과 새 판을 견주는 갱신 연습 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 guard() 로 바깥을 막고 load_docs() 같은 파이프라인 함수를 만들며, 2단계(마젠타)에서 out_live/ 운영 폴더와 Chroma 색인, 해시 기록을 만듭니다. 3단계(다홍)는 answer() 로 질문 4개를 갱신 전에 묻고, 4단계(빨강)는 _변경목록.csv 를 반영한 뒤 sync() 로 바뀐 것만 색인에 고쳐 넣으며, 5단계(짙은 청록)는 다시 물어 전후를 견주고 막은 주소를 보입니다.
결과 보기 (예시 풀이)
1단계. 바깥을 막고 파이프라인 함수 만들기
import csv, hashlib, io, json, re, shutil, time, urllib.request, zipfile
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from pathlib import Path
import pandas as pd
import sys
sys.path.insert(0, ".") # 작업 폴더(data)에 만든 out_offline.py 를 찾게 한다
import out_offline
out_offline.guard() # 모든 작업을 바깥 연결을 막은 채 한다
import chromadb
from chromadb.config import Settings
from openai import OpenAI
from sentence_transformers import SentenceTransformer
# Ollama 모델을 내려 깨끗하게 다시 올린다(실습 6과 같은 까닭)
def unload():
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()
unload()
# ----- 읽기(실습 2와 같은 방법) -----
class HtmlText(HTMLParser):
SKIP_TAGS, SKIP_CLASSES, VOID = {"script", "style", "title"}, {"gnb", "footer", "info"}, {"meta", "br", "img", "link", "input", "hr"}
def __init__(self):
super().__init__(); self.parts, self.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}"
def read_file(path):
raw = path.read_bytes()
if path.suffix == ".docx": # 워드: 문단과 표
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":
st = el.find(f"{W}pPr/{W}pStyle")
lines.append({"Heading1": "# ", "Heading2": "## "}.get(st.get(W + "val") if st is not None else "", "") + 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")]
lines += [", ".join([r[0]] + [f"{h} {v}" for h, v in zip(rows[0][1:], r[1:])]) for r in rows[1:]]
return "\n".join(lines)
for enc in ("utf-8-sig", "cp949"): # 글: utf-8 먼저, 안 되면 cp949
try:
text = raw.decode(enc); break
except UnicodeDecodeError:
pass
if path.suffix == ".html":
p = HtmlText(); p.feed(text); return "".join(p.parts)
if path.suffix == ".csv":
return "\n".join(", ".join(f"{k} {v}" for k, v in r.items()) for r in csv.DictReader(text.splitlines()))
return text
# ----- 정제(실습 3과 같은 방법) -----
def clean_text(text):
lines = text.split("\n")
if any(re.fullmatch(r"- \d+ -", l.strip()) for l in lines): # PDF 에서 뽑은 글
header, out = lines[0], []
for l in lines:
if l == header or re.fullmatch(r"- \d+ -", l.strip()): continue
if out and out[-1] and l and not out[-1].endswith("."): out[-1] += l
else: out.append(l)
lines = out
text = "\n".join(lines)
text = re.sub(r"01[016789]-\d{3,4}-\d{4}", "[전화번호]", text)
text = re.sub(r"[\w.-]+@[\w-]+\.[\w.]+", "[메일]", text)
return re.sub(r"\n{3,}", "\n\n", re.sub(r"[ \t]+", " ", text)).strip()
def split_doc(d):
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))
return [{"id": f"{d['경로']}#{n}", "글": f"[{d['제목']}] {h}".strip() + "\n" + "\n".join(l for l in b if l.strip()),
"경로": d["경로"], "제목": d["제목"], "등급": d["등급"], "버전": d["버전"]} for n, (h, b) in enumerate(pieces)]
def load_docs(root):
"""폴더의 문서를 읽어 정제하고, 사본과 옛 판을 거른 문서 목록을 돌려준다"""
root = Path(root)
meta = pd.read_csv(root / "_문서목록.csv").set_index("경로")
docs = []
for p in sorted(root.rglob("*"), key=lambda p: len(str(p))): # 이름이 짧은(원래 이름) 것부터
rel = p.relative_to(root).as_posix()
if p.is_file() and not p.name.startswith("_") and rel in meta.index:
m = meta.loc[rel]
docs.append({"경로": rel, "제목": m["제목"], "등급": int(m["등급"]), "버전": int(m["버전"]),
"해시": hashlib.sha256(p.read_bytes()).hexdigest(), "글": clean_text(read_file(p))})
seen, unique = set(), []
for d in docs: # 똑같은 파일은 하나만
if d["해시"] not in seen:
seen.add(d["해시"]); unique.append(d)
latest = {}
for d in unique: # 같은 제목은 최신 판만
if d["제목"] not in latest or d["버전"] > latest[d["제목"]]["버전"]:
latest[d["제목"]] = d
return sorted(latest.values(), key=lambda d: d["경로"])
print([d["경로"] for d in load_docs("intranet")])['finance/경비정산기준.txt', 'finance/법인카드_규정.html', 'hr/복리후생_안내.docx', 'hr/인사평가_기준.md', 'hr/휴가규정_2026.html', 'it/VPN_접속안내.txt', 'it/보안수칙.md', 'notice/공지_보안점검.html', 'notice/공지_사무실이전.html', 'production/불량률_현황.csv', 'production/설비점검_절차.md']2단계. 운영 폴더와 첫 색인 만들기
LIVE, STATE = Path("out_live"), Path("out_index_state.json")
shutil.rmtree(LIVE, ignore_errors=True)
shutil.copytree("intranet", LIVE) # 운영 폴더 = 지금의 사내 문서
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
client = chromadb.PersistentClient(path="out_chroma_live", settings=Settings(anonymized_telemetry=False))
if "live" in [c.name for c in client.list_collections()]:
client.delete_collection("live")
col = client.create_collection("live", metadata={"hnsw:space": "cosine"})
STATE.unlink(missing_ok=True) # 색인을 새로 만들면 해시 기록도 함께 지운다. 기록만 남으면 "이미 넣었다"고 착각한다
def add_doc(d):
"""문서 하나의 조각을 임베딩해 색인에 넣는다. 돌려주는 것: 조각 수"""
chunks = split_doc(d)
vecs = emb.encode(["passage: " + c["글"] for c in chunks], normalize_embeddings=True)
col.add(ids=[c["id"] for c in chunks], embeddings=vecs.tolist(), documents=[c["글"] for c in chunks],
metadatas=[{k: c[k] for k in ("경로", "제목", "등급", "버전")} for c in chunks])
return len(chunks)
def sync():
"""운영 폴더와 해시 기록을 견주어 바뀐 문서만 색인에 반영한다"""
before = json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
docs = {d["경로"]: d for d in load_docs(LIVE)}
after = {p: d["해시"] for p, d in docs.items()}
added = sorted(after.keys() - before.keys())
deleted = sorted(before.keys() - after.keys())
changed = sorted(p for p in after.keys() & before.keys() if after[p] != before[p])
for p in deleted + changed: # 옛 조각을 문서 단위로 지운다
col.delete(where={"경로": p})
n = sum(add_doc(docs[p]) for p in added + changed) # 새 조각을 넣는다
STATE.write_text(json.dumps(after, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"추가 {added} · 수정 {changed} · 삭제 {deleted} · 넣은 조각 {n}개 · 색인 {col.count()}개")
t = time.time()
sync() # 기록이 없으니 모든 문서가 "추가"다
print(f"{time.time() - t:.1f}초")추가 ['finance/경비정산기준.txt', 'finance/법인카드_규정.html', 'hr/복리후생_안내.docx', 'hr/인사평가_기준.md', 'hr/휴가규정_2026.html', 'it/VPN_접속안내.txt', 'it/보안수칙.md', 'notice/공지_보안점검.html', 'notice/공지_사무실이전.html', 'production/불량률_현황.csv', 'production/설비점검_절차.md'] · 수정 [] · 삭제 [] · 넣은 조각 51개 · 색인 51개
1.4초3단계. 갱신 전에 묻기
llm = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
SYSTEM = ("당신은 누리솔정밀(가상 회사)의 사내 문서 도우미입니다. [자료]에 있는 내용만으로 한두 문장으로 답하고, "
"끝에 근거 문서의 제목을 (출처: 제목) 꼴로 적으세요. [자료]에 답이 없으면 '찾을 수 없습니다'라고만 답하세요.")
def answer(question, level):
"""등급으로 거른 조각 3개로 답한다. 오류나 깨진 글자면 모델을 내렸다 두 번까지 다시 묻는다"""
q = emb.encode("query: " + question, normalize_embeddings=True)
r = col.query(query_embeddings=[q.tolist()], n_results=3, where={"등급": {"$lte": level}})
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": "[자료]\n" + "\n\n".join(r["documents"][0]) + f"\n\n[질문]\n{question}"}]
for _ in range(3):
try:
text = llm.chat.completions.create(model="qwen2.5:3b", messages=messages, temperature=0, seed=42).choices[0].message.content.strip()
if "@" not in text: # 깨진 글자(@)가 섞이면 다시 묻는다. 메일 주소는 정제에서 가렸다
return text
except Exception as e:
text = f"(오류: {e})"
unload()
return text
tests = [json.loads(line) for line in open("intranet_update_qa.jsonl", encoding="utf-8")]
norm = lambda s: s.replace(" ", "").replace(",", "")
before_answers = {}
for t in tests:
before_answers[t["질문"]] = answer(t["질문"], 2) # 정직원(등급 2)으로 묻는다
print("Q:", t["질문"], "\n ", before_answers[t["질문"]])Q: 서울 출장 숙박비는 한 밤에 얼마까지인가요?
서울 출장 숙박비는 한 밤에 10만원까지입니다. (출처: [경비 정산 기준]제3조)
Q: 출장 정산은 돌아온 뒤 며칠 안에 신청하나요?
출장 정산은 돌아온 뒤 7일 안에 신청합니다. (출처: [경비 정산 기준]제5조)
Q: 통근버스 3호차는 어디서 출발하나요?
통근버스 3호차는 시청역에서 출발합니다. (출처: 복리후생 안내)
Q: 반차는 언제까지 신청해야 하나요?
2026년부터는 오전 9시까지 신청하면 됩니다. (출처: [휴가 규정] 반차)4단계. 변경을 반영하고 바뀐 것만 색인 고치기
changes = pd.read_csv("intranet_update/_변경목록.csv")
listing = pd.read_csv(LIVE / "_문서목록.csv")
for _, ch in changes.iterrows():
target = LIVE / ch["경로"]
if ch["변경"] == "삭제":
target.unlink() # 파일을 지우고
listing = listing[listing["경로"] != ch["경로"]] # 목록에서도 뺀다
else: # 추가와 수정: 새 파일을 복사하고 목록 행을 바꾼다
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path("intranet_update") / ch["경로"], target)
listing = listing[listing["경로"] != ch["경로"]]
listing = pd.concat([listing, ch.drop("변경").to_frame().T], ignore_index=True)
listing.to_csv(LIVE / "_문서목록.csv", index=False, encoding="utf-8-sig")
print(changes[["경로", "변경", "버전"]].to_string(index=False))
t = time.time()
sync() # 해시가 다른 문서만 고친다
print(f"{time.time() - t:.1f}초") 경로 변경 버전
finance/경비정산기준.txt 수정 2
notice/공지_통근버스.html 추가 1
hr/휴가규정_2025.html 삭제 1
추가 ['notice/공지_통근버스.html'] · 수정 ['finance/경비정산기준.txt'] · 삭제 [] · 넣은 조각 8개 · 색인 53개
0.2초5단계. 갱신 뒤에 다시 묻고 견주기
right_before = right_after = 0
for t in tests:
after_answer = answer(t["질문"], 2)
ok_b, ok_a = norm(t["열쇠"]) in norm(before_answers[t["질문"]]), norm(t["열쇠"]) in norm(after_answer)
right_before, right_after = right_before + ok_b, right_after + ok_a
print("Q:", t["질문"], f"(열쇠: {t['열쇠']})")
print(" 전:", before_answers[t["질문"]], "→", "맞음" if ok_b else "틀림")
print(" 뒤:", after_answer, "→", "맞음" if ok_a else "틀림")
print(f"새 문서 기준 정답: 갱신 전 {right_before}/{len(tests)} → 갱신 뒤 {right_after}/{len(tests)}")
print("막은 주소:", out_offline.BLOCKED)Q: 서울 출장 숙박비는 한 밤에 얼마까지인가요? (열쇠: 12만원)
전: 서울 출장 숙박비는 한 밤에 10만원까지입니다. (출처: [경비 정산 기준]제3조) → 틀림
뒤: 서울 출장 숙박비는 한 밤에 12만원까지입니다. (출처: [경비 정산 기준] 제3조) → 맞음
Q: 출장 정산은 돌아온 뒤 며칠 안에 신청하나요? (열쇠: 10일)
전: 출장 정산은 돌아온 뒤 7일 안에 신청합니다. (출처: [경비 정산 기준]제5조) → 틀림
뒤: 출장 정산은 돌아온 뒤 10일 안에 신청합니다. (출처: [경비 정산 기준]제5조) → 맞음
Q: 통근버스 3호차는 어디서 출발하나요? (열쇠: 환승센터)
전: 통근버스 3호차는 시청역에서 출발합니다. (출처: 복리후생 안내) → 틀림
뒤: 통근버스 3호차는 신도시 환승센터에서 출발합니다. (출처: 통근버스 노선 추가) → 맞음
Q: 반차는 언제까지 신청해야 하나요? (열쇠: 9시)
전: 2026년부터는 오전 9시까지 신청하면 됩니다. (출처: [휴가 규정] 반차) → 맞음
뒤: 2026년부터는 오전 9시까지 신청하면 됩니다. (출처: [휴가 규정] 반차) → 맞음
새 문서 기준 정답: 갱신 전 1/4 → 갱신 뒤 4/4
막은 주소: ['huggingface.co']첫 색인은 문서 11개, 51조각으로 1.4초 걸렸습니다. 사본과 2025년 휴가 규정은 처음부터 걸러져 들어가지 않았습니다. 갱신 전에는 질문 4개 가운데 반차 하나만 새 기준에 맞았습니다. 숙박비(10만원)와 정산 기한(7일)은 그때의 문서로는 맞는 답이고, 통근버스 3호차 질문에는 문서가 없는데도 복리후생 안내의 1호차 출발지를 끌어다 "시청역"이라고 지어냈습니다. 변경을 반영하자 sync() 는 추가 1개(통근버스 공지)와 수정 1개(경비 정산 기준)만 골라 8조각을 넣었고 0.2초 걸렸습니다. 삭제가 비어 있는 것은 지운 2025년 휴가 규정이 옛 판이라 처음부터 색인에 없었기 때문입니다. 색인은 51 - 6 + 6 + 2 = 53조각이 되었고, 갱신 뒤에는 4개를 모두 맞혔습니다. 교안을 만들며 다시 돌렸을 때는 색인은 새로 만들었는데 지난 실행의 해시 기록이 남아 있어서, sync() 가 거의 모든 문서를 "이미 넣었다"고 보고 6조각만 넣은 일이 있었습니다. 2단계에서 색인을 새로 만들 때 기록도 함께 지우는 줄(STATE.unlink)을 넣은 까닭입니다. 색인과 해시 기록은 늘 한 몸으로 다룹니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 회사 자료실의 서류가 바뀔 때마다 색인 카드를 고쳐 끼우는 일과 같습니다. 바깥 연결을 막고, 서류를 모아 정리하는 순서를 한 묶음의 절차로 만들고(1단계), 지금의 서류를 복사해 운영 자료실을 차린 뒤 카드 상자와 "서류마다 지문" 장부를 만듭니다(2단계). 바뀌기 전에 질문 4개를 물어 두고(3단계), 개정된 서류와 새 공지, 지울 옛 서류를 반영한 뒤 지문이 달라진 서류의 카드만 바꿔 끼우고(4단계), 같은 질문을 다시 물어 견줍니다(5단계). 여기서 정리 절차가 load_docs(), 카드 상자가 Chroma 색인, 지문 장부가 해시 기록 out_index_state.json, 바꿔 끼우기가 sync() 입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 파이프라인 | 문서 경로 11개 | 13개 가운데 사본과 2025년 휴가 규정이 걸러져 11개가 남았습니다. 실습 3과 같은 결과입니다 |
| 2. 첫 색인 | 넣은 조각 51개 · 색인 51개, 1.4초 | 장부가 비어 있으니 11개 문서가 모두 "추가"로 들어갔습니다 |
| 3. 갱신 전 | 숙박비 10만원, 정산 7일, 3호차 시청역, 반차 9시 | 숙박비와 정산 기한은 그때 문서로는 맞는 답입니다. 3호차는 문서가 없는데도 1호차 출발지를 끌어다 지어냈습니다 |
| 4. 변경 목록 | 수정 1, 추가 1, 삭제 1 | 경비 정산 기준이 2판으로 바뀌고, 통근버스 공지가 새로 생기고, 2025년 휴가 규정이 지워졌습니다 |
| 4. sync | 추가 ['notice/공지_통근버스.html'] · 수정 ['finance/경비정산기준.txt'] · 삭제 [] · 넣은 조각 8개 · 색인 53개, 0.2초 | 바뀐 두 문서의 조각만 넣었습니다. 지운 옛 판은 처음부터 색인에 없어서 삭제가 비었습니다 |
| 5. 견주기 | 갱신 전 1/4 → 갱신 뒤 4/4 | 갱신 뒤에는 12만원, 10일, 신도시 환승센터, 9시로 네 질문 모두 새 문서를 따랐습니다 |
| 5. 막은 주소 | ['huggingface.co'] | 실습 4, 6의 출력과 같은 목록으로, 이 프로젝트에서 새로 막힌 바깥 연결은 없습니다 |
한 줄로 줄이면, 문서가 바뀔 때 해시로 바뀐 것만 골라 색인을 고치면 1초도 안 걸려 답이 새 문서를 따르게 된다는 것을 보인 프로젝트입니다. 첫 색인 1.4초와 갱신 0.2초는 문서 11개 규모에서 이 PC 로 한 번 잰 값입니다. 질문 4개를 정답 낱말이 들어 있는지로 본 채점이라 숫자보다 흐름으로 읽어야 하고, 갱신 전의 "시청역"처럼 문서가 없을 때 지어내는 버릇은 색인 갱신으로 고쳐지지 않습니다. 결론 문단에 적은 것처럼 색인과 해시 기록은 늘 함께 만들고 함께 지웁니다.