Field Log · Entry

LLM Wiki 구현 2: 검증·승인·증분 갱신으로 운영하기 (6/6)

LLM Wiki 후보가 staging 검증과 사람 승인을 거쳐 canonical로 승격되고 source 변경으로 다시 갱신되는 운영 루프

이번 글의 결론

  • LLM Wiki 운영의 핵심은 생성 성공이 아니라 검증된 candidate만 canonical로 승격하는 state machine입니다.
  • LLM은 proposal을 만들고, renderer·validator·promotion은 가능한 한 deterministic하게 구현합니다.
  • Evidence·schema·link·graph·secret·coverage gate는 서로 다른 실패를 잡으므로 하나의 “quality score”로 뭉치지 않습니다.
  • Source ledger는 review가 끝났을 때가 아니라 promotion이 성공했을 때만 processed로 기록합니다.
  • 운영 지표는 page 수보다 human acceptance, unmapped source, stale age, unverifiable evidence, rollback 가능성에 둡니다.

LLM Wiki 구현은 page가 생성된 순간이 아니라 그 page가 틀렸을 때 안전하게 거절·수정·재생성할 수 있을 때 완성됩니다. 이번 글은 앞 편의 evidence-bearing proposal을 staging에 쓰고, deterministic gate와 human review를 통과한 결과만 canonical wiki로 승격하는 운영 구조를 만듭니다.

LLM Wiki 정의부터 인기, 종류, RAG 비교, 구현과 운영으로 이어지는 6편 학습 경로

운영 state machine부터 고정한다

“위키 생성”이라는 button 하나 안에 여러 상태가 숨어 있습니다. 상태를 명시하지 않으면 실패한 run의 partial output이 정상 page처럼 보이거나, review가 끝나기 전에 source ledger가 소비될 수 있습니다.

CREATED
  ↓ source inventory fixed
PLANNED
  ↓ page plan approved or auto policy passed
PROPOSED
  ↓ evidence-bearing proposals verified
STAGED
  ↓ candidate files rendered outside canonical
VALIDATED
  ↓ deterministic reports produced
HUMAN_REVIEW_REQUIRED
  ↓ approved subset selected
PROMOTED

어느 단계에서든 deterministic error가 나면 FAILED, 외부 LLM call이 끝나지 않으면 CANCELLED 또는 resumable state로 이동합니다. State와 artifact path를 함께 저장합니다.

{
  "run_id": "wiki-20260729-001",
  "state": "HUMAN_REVIEW_REQUIRED",
  "artifacts": {
    "PROPOSED": ["runs/wiki-20260729-001/proposals.json"],
    "VALIDATED": [
      "runs/wiki-20260729-001/evidence-report.json",
      "runs/wiki-20260729-001/link-report.json",
      "runs/wiki-20260729-001/coverage-report.json"
    ]
  }
}

State만 저장하고 artifact를 덮어쓰면 resume와 audit가 불가능합니다. Run별 immutable artifact와 최신 state pointer를 분리합니다.

Proposal이 staging 검증과 human review를 거쳐 canonical로 승격되고 source change로 되돌아오는 운영 루프

그림 1. Review는 pipeline 끝의 장식이 아니라 candidate와 canonical 사이의 명시적인 상태다.

1단계. Proposal을 deterministic하게 렌더링한다

LLM이 최종 Markdown 전체를 쓰게 하면 같은 proposal에서도 heading, frontmatter, link format이 흔들립니다. LLM은 semantic field를 만들고 renderer가 page bytes를 결정합니다.

from __future__ import annotations

from pathlib import Path

import yaml

from .models import PageProposal


def render_page(proposal: PageProposal) -> str:
    frontmatter = {
        "id": proposal.page_id,
        "kind": proposal.kind,
        "title": proposal.title,
        "status": "candidate",
        "sources": sorted(
            {evidence.source_id for evidence in proposal.evidence}
        ),
    }
    lines = [
        "---",
        yaml.safe_dump(
            frontmatter,
            allow_unicode=True,
            sort_keys=False,
        ).rstrip(),
        "---",
        "",
        f"# {proposal.title}",
        "",
        "## Summary",
        "",
        proposal.summary.strip(),
        "",
        "## Claims",
        "",
    ]

    evidence_by_id = {
        evidence.evidence_id: evidence
        for evidence in proposal.evidence
    }
    for claim in proposal.claims:
        refs = ", ".join(
            f"`{evidence_by_id[eid].source_id}`"
            for eid in claim.evidence_ids
        )
        lines.append(f"- {claim.text}{refs}")

    lines.extend(["", "## Related", ""])
    lines.extend(
        f"- [[{page_id}]]"
        for page_id in sorted(set(proposal.related_page_ids))
    )
    return "\n".join(lines).rstrip() + "\n"

Renderer가 책임질 항목:

  • Safe path와 file extension
  • Frontmatter field와 order
  • Required heading
  • Evidence 표시 형식
  • Link syntax
  • Managed block marker
  • 최종 newline과 encoding

LLM prompt를 바꾸지 않고도 wiki format을 migration할 수 있다는 장점이 있습니다.

2단계. Canonical 밖에 staging vault를 만든다

Run별 staging은 현재 canonical wiki의 snapshot에서 시작합니다.

runs/wiki-20260729-001/
├── run.json
├── artifacts/
├── logs/
└── vault-staging/
    ├── nodes/
    ├── decisions/
    └── graph/

wiki/                  # real canonical, 아직 수정하지 않음

Candidate를 기존 wiki와 함께 검사해야 broken link, duplicate ID, canonical conflict를 찾을 수 있습니다.

from __future__ import annotations

import shutil
from pathlib import Path


def prepare_staging(canonical: Path, staging: Path) -> None:
    if staging.exists():
        raise FileExistsError(staging)
    shutil.copytree(canonical, staging)


def safe_target(root: Path, relative: str) -> Path:
    root = root.resolve(strict=True)
    target = (root / relative).resolve(strict=False)
    if not target.is_relative_to(root):
        raise ValueError(f"path escaped staging root: {relative}")
    if target.suffix != ".md":
        raise ValueError(f"non-markdown page: {relative}")
    return target


def write_candidate(
    staging: Path,
    relative: str,
    body: str,
) -> None:
    target = safe_target(staging, relative)
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(body, encoding="utf-8")

Production에서는 staging root 자체와 parent symlink 교체도 고려해야 합니다. Native filesystem에서 descriptor-relative safe write를 제공할 수 없다면 지원 환경을 제한하는 것이 검사를 끄는 것보다 낫습니다.

3단계. Validator를 독립 report로 만든다

하나의 quality: 82 점수로는 무엇을 고쳐야 하는지 알 수 없습니다. 각 invariant를 별도 report로 만듭니다.

Evidence validation

앞 편의 verifier를 다시 실행합니다.

  • Source path exists
  • Path stays inside allowed raw/source root
  • Quote policy satisfied
  • Claim의 evidence ID가 실제 evidence와 연결

Semantic relevance는 deterministic substring match로 보장할 수 없습니다. “Source가 존재한다”와 “이 source가 claim을 실제로 지지한다”를 분리하고 후자는 human review 또는 별도 entailment eval로 둡니다.

Schema·frontmatter validation

  • ID, kind, status, sources required
  • Kind별 required section
  • Page file path와 slug 일치
  • Enum·date·list type
  • Duplicate page ID 없음
  • Lifecycle transition 허용
def validate_contract(page: dict, contract: dict) -> list[str]:
    errors: list[str] = []
    rule = contract["kinds"].get(page.get("kind"))
    if rule is None:
        return [f"unknown kind: {page.get('kind')!r}"]

    for field in rule["required_fields"]:
        if page.get(field) in (None, "", []):
            errors.append(f"missing required field: {field}")

    return errors

Contract는 prompt 설명이 아니라 code가 읽는 data model이어야 합니다.

  • [[target]]이 존재하는가
  • Heading·block suffix를 제거한 target resolution이 맞는가
  • Review에서 제외된 node를 approved page가 가리키는가
  • External source link가 허용 scheme인가

Broken link를 모두 block할지는 domain에 따라 다릅니다. 아직 만들지 않은 future page link를 허용한다면 warning과 block을 구분합니다.

Graph integrity

  • Edge endpoint가 실제 node인가
  • Edge type이 contract에 있는가
  • From/to kind 조합이 허용되는가
  • Required evidence attribute가 있는가
  • 고립 node 비율이 비정상적으로 늘지 않았는가

GraphRAG에서 추출한 entity graph를 그대로 canonical로 쓰려면 특히 entity resolution과 edge evidence 검수가 필요합니다. Microsoft GraphRAG 문서도 standard indexing에서 LLM이 entity·relationship·summary를 생성하고 prompt tuning을 권장합니다. Retrieval용 noisy graph와 domain record용 canonical graph의 허용 오차는 다릅니다.

Secret·PII scan

검사는 staging file을 쓴 뒤에만 하면 늦을 수 있습니다. Write plan의 body를 먼저 scan하고, staging output을 다시 scan합니다.

pre-write:
  proposal fields
  write operation body

post-write:
  authored files
  generated diff
  logs and artifacts

API key pattern만 찾는 것으로 충분하지 않습니다. Private key block, token variable, client secret, connection string, email·phone 같은 domain-specific PII rule과 allowlist가 필요합니다.

Coverage validation

Source inventory와 proposal evidence를 대조합니다.

def build_coverage(
    source_ids: list[str],
    proposals: list[PageProposal],
) -> dict:
    cited_by: dict[str, list[str]] = {
        source_id: [] for source_id in source_ids
    }
    for proposal in proposals:
        for evidence in proposal.evidence:
            if evidence.source_id in cited_by:
                cited_by[evidence.source_id].append(proposal.page_id)

    unmapped = [
        source_id
        for source_id, pages in cited_by.items()
        if not pages
    ]
    return {
        "source_total": len(source_ids),
        "covered": len(source_ids) - len(unmapped),
        "unmapped": unmapped,
        "cited_by": cited_by,
    }

Coverage 100%가 항상 목표는 아닙니다. License, generated lockfile, irrelevant log는 intentionally skipped일 수 있습니다. unmapped, skipped_with_reason, quarantined, failed를 구분합니다.

4단계. Gate와 report를 분리한다

Validator가 ok: false를 계산해도 아무도 읽지 않으면 gate가 아닙니다. Run completion과 promotion permission도 나눕니다.

validation failed
→ run은 HUMAN_REVIEW_REQUIRED까지 도달
→ 사람은 report와 diff를 볼 수 있음
→ promotion은 기본 거절
→ 명시적 override만 audit log와 함께 허용

이 방식은 실패를 숨기지 않으면서도 조사할 artifact를 보존합니다.

from dataclasses import dataclass


@dataclass(frozen=True)
class ValidationBundle:
    evidence_ok: bool
    schema_ok: bool
    links_ok: bool
    graph_ok: bool
    secrets_ok: bool


def promotion_allowed(
    reports: ValidationBundle,
    *,
    allow_invalid: bool = False,
    allow_secrets: bool = False,
) -> tuple[bool, list[str]]:
    reasons: list[str] = []

    if not reports.evidence_ok:
        reasons.append("evidence validation failed")
    if not reports.schema_ok:
        reasons.append("schema validation failed")
    if not reports.links_ok:
        reasons.append("link validation failed")
    if not reports.graph_ok:
        reasons.append("graph validation failed")
    if not reports.secrets_ok:
        reasons.append("secret scan failed")

    blocking = [
        reason for reason in reasons
        if not (
            allow_invalid
            and reason in {
                "schema validation failed",
                "link validation failed",
                "graph validation failed",
            }
        )
        and not (
            allow_secrets
            and reason == "secret scan failed"
        )
    ]
    return not blocking, blocking

High-stakes system에서는 evidence failure와 secret finding에 override 자체를 금지할 수 있습니다.

5단계. Human review는 page 전체가 아니라 claim과 diff를 본다

Reviewer 화면에 최종 Markdown만 보여 주면 source와 비교하는 비용이 큽니다. 최소한 다음을 한 화면에 둡니다.

[candidate claim]
Retries stop after three attempts.

[evidence]
docs/runtime.md @ revision 7a13...
“After the third failure, the worker moves the job to dead-letter.”

[change]
new page: runbooks/retry-worker.md
new edge: component:worker --uses--> policy:retry

[decision]
approve / edit / reject / needs-source

Review 단위:

  • Proposal 전체
  • Claim별 verdict
  • Relation별 verdict
  • Canonical overwrite conflict

모든 reviewer edit를 LLM prompt 개선으로 돌리기 전에 error taxonomy로 남깁니다.

wrong_scope
missing_source
unsupported_claim
duplicate_page
bad_title
wrong_relation
stale_source
security_redaction

Acceptance rate 하나보다 어떤 오류가 반복되는지 알아야 합니다.

6단계. Approved subset만 promote한다

Review에서 10개 중 7개만 승인했다면 7개만 canonical로 옮깁니다. 승인되지 않은 node로 가는 link는 warning으로 report합니다.

Canonical overwrite에는 optimistic concurrency를 둡니다.

from __future__ import annotations

import hashlib
import os
from pathlib import Path


def file_hash(path: Path) -> str | None:
    if not path.exists():
        return None
    return hashlib.sha256(path.read_bytes()).hexdigest()


def promote_one(
    staged: Path,
    canonical: Path,
    *,
    last_read_hash: str | None,
) -> str:
    current_hash = file_hash(canonical)
    if current_hash != last_read_hash:
        return "conflict"

    canonical.parent.mkdir(parents=True, exist_ok=True)
    temp = canonical.with_suffix(canonical.suffix + ".tmp")
    temp.write_bytes(staged.read_bytes())
    os.replace(temp, canonical)
    return "promoted"

Reviewer가 page를 읽은 뒤 다른 사람이 Obsidian이나 Git에서 canonical을 고쳤다면 hash가 달라집니다. 이때 덮어쓰지 않고 conflict document를 만들거나 새 review를 요구합니다.

전통 wiki가 revision history와 diff를 핵심 기능으로 갖는 이유도 같습니다. MediaWiki의 history 기능은 revision 비교, 이전 version, permanent link를 제공합니다. LLM Wiki도 최소한 Git commit이나 revision table로 이 성질을 보존해야 합니다.

7단계. Source ledger는 promotion 뒤에 갱신한다

Incremental build는 source hash를 비교합니다.

new       이전 ledger에 path 없음
changed   path 같고 hash 다름
unchanged path와 hash 같음
orphaned  ledger에는 있으나 source에서 사라짐

가장 중요한 timing:

PROPOSED                  ledger mark 금지
HUMAN_REVIEW_REQUIRED     ledger mark 금지
PROMOTED                  approved source만 processed mark

Review에 도착했다고 source를 consumed 처리하면 사용자가 승인하지 않고 run을 버렸을 때 다음 build가 그 source를 skip합니다. Wiki가 조용히 줄어드는 위험한 bug입니다.

def commit_ledger(
    ledger: dict[str, str],
    approved: list[PageProposal],
    source_hashes: dict[str, str],
) -> None:
    used = {
        evidence.source_id
        for proposal in approved
        for evidence in proposal.evidence
    }
    for source_id in used:
        ledger[source_id] = source_hashes[source_id]

orphaned source는 page를 자동 삭제하지 않습니다. 영향을 받는 page를 stale candidate로 표시하고 사람이 archive·replace·keep을 결정합니다.

8단계. Freshness를 page 속성으로 만든다

Page의 source hash set을 저장합니다.

id: runbook:retry-worker
status: approved
source_revisions:
  source:docs/runtime.md: 38173b...
  source:src/worker.py: 0fd2a1...
reviewed_at: 2026-07-29

새 inventory와 비교:

def is_stale(
    recorded: dict[str, str],
    current: dict[str, str],
) -> bool:
    return any(
        current.get(source_id) != old_hash
        for source_id, old_hash in recorded.items()
    )

Freshness는 boolean 하나보다 이유가 중요합니다.

  • source_changed
  • source_deleted
  • contract_changed
  • generator_changed
  • review_expired

Generator prompt나 page contract가 바뀌었을 때도 rebuild candidate가 될 수 있습니다.

9단계. Retrieval index는 promotion 뒤 재생성한다

Index를 canonical보다 먼저 갱신하면 승인되지 않은 candidate가 Q&A에 노출됩니다.

approved Markdown / graph
→ keyword index
→ embedding index
→ optional community summary
→ MCP / search / Q&A

Index record에는 canonical revision을 넣습니다.

{
  "chunk_id": "runbook:retry-worker#rollback:0",
  "page_id": "runbook:retry-worker",
  "canonical_revision": "git:9a03c4e",
  "text": "..."
}

Delete와 permission change도 index에 전파합니다. Index는 언제든 canonical에서 다시 만들 수 있는 derived state여야 합니다.

운영 지표

Build 품질

  • inventory_total
  • prompted / dropped_by_budget
  • covered / unmapped / skipped / quarantined
  • proposal_created / pruned / verified
  • unverifiable_evidence
  • duplicate_page_id

Review 품질

  • Human acceptance rate
  • Claim edit distance
  • Rejection reason distribution
  • Review latency
  • Reviewer agreement

Canonical 건강도

  • Stale page count와 p95 age
  • Broken link·orphan node
  • Source-less page
  • Owner-less high-risk page
  • Rollback·conflict count

Q&A 품질

  • Retrieval Recall@k·nDCG
  • Answer correctness
  • Citation entailment
  • Page citation 대 raw citation 비율
  • No-answer calibration
  • Latency·cost

Page 수와 graph node 수는 workload 지표이지 성공 지표가 아닙니다.

실패를 관측할 run log

LLM step마다 다음을 보존합니다.

logs/04-NODE_PROPOSALS-extractor/
├── prompt.txt
├── stdout.log
├── stderr.log
└── meta.json

meta.json 예:

{
  "engine": "provider-a",
  "model": "model-x",
  "prompt_version": "extractor@7",
  "started_at": "2026-07-29T09:12:01+09:00",
  "duration_ms": 184220,
  "exit_code": 0,
  "timed_out": false,
  "input_chars": 172804,
  "output_chars": 38120
}

Prompt와 output에는 source secret이 포함될 수 있으므로 log access, retention, redaction을 별도 policy로 둡니다. “디버깅을 위해 전부 저장”과 “민감정보를 최소화”의 trade-off를 명시해야 합니다.

제가 운영 단계에서 고친 세 가지 오해

오해 1. Validator가 report를 만들면 안전하다

초기 구현에서는 graph·Markdown·link validator가 ok: false를 계산했지만 그 값을 promotion service가 읽지 않는 구간이 있었습니다. Report가 존재하는 것과 gate가 작동하는 것은 달랐습니다. 이후 promotion이 validation report를 읽고 기본 거절하도록 바꿨습니다.

오해 2. Human review가 있으니 evidence code는 약해도 된다

처음 시스템이 안전했던 실제 이유는 staging과 human promotion이었습니다. 그러나 source path가 진짜인지 매번 사람이 확인하게 하면 review가 확장되지 않습니다. Source existence와 path containment는 코드로 옮기고, semantic support 판단에 사람 시간을 써야 했습니다.

오해 3. Build를 만들면 관리도 거의 끝난다

2026년 7월 초 개인 dashboard를 진단했을 때 source materialize→LLM extraction→evidence verification→graph→review→promote 흐름은 강했지만, promotion 이후 in-app edit, version rollback, stale node detection은 아직 부족했습니다. “위키 구축”과 “위키 관리”를 별도 product track으로 봐야 했습니다.

이 세 가지가 마지막 결론으로 이어집니다.

LLM Wiki의 어려운 부분은 첫 page를 만드는 일이 아니라, 천 번째 갱신에서도 원천·검수·정본의 경계를 보존하는 일입니다.

출시 순서

Phase 1. 한 종류의 page

  • Markdown source만
  • decision page 하나
  • Source path + quote
  • Git staging + human review
  • Full rebuild

Phase 2. Incremental

  • Source hash ledger
  • Changed source queue
  • Stale page report
  • Idempotent rerun
  • BM25
  • Page embedding
  • Raw source fallback
  • Cited Q&A

Phase 4. Typed graph

  • 3~5 relation type
  • Endpoint·evidence contract
  • Graph integrity
  • 필요한 query만 visualization

Phase 5. Agent integration

  • MCP read tools
  • Context package budget
  • Task→wiki→source trace
  • 좁은 write action + approval

이 순서는 technology roadmap이 아니라 risk 순서입니다. Canonical과 review가 안정되기 전에 autonomous write를 열지 않습니다.

자주 묻는 질문

Human review를 언제 줄일 수 있나?

Page kind별 acceptance rate, error category, source freshness, rollback record가 충분히 쌓인 뒤 좁은 조건부터 줄입니다. 예를 들어 기존 approved page의 managed summary block만, source가 한 개이고 quote가 exact match인 경우에만 자동 갱신할 수 있습니다.

모든 validation failure를 run-fatal로 만들까?

아닙니다. Path escape, raw write, secret처럼 즉시 중단할 항목과 broken future link처럼 review에 보여 줄 항목을 나눕니다. 다만 promotion 기본값은 fail-closed가 안전합니다.

Source가 삭제되면 연결된 page도 자동 삭제할까?

보통은 아닙니다. Historical decision과 incident는 원천이 archive돼도 보존 가치가 있습니다. orphanedstale을 표시하고 owner가 archive·replace·delete를 결정합니다.

Wiki와 raw source 중 무엇을 Q&A에서 우선할까?

Overview·route는 reviewed wiki를 우선하고, 구체 fact·수치·code는 최신 raw source를 함께 retrieve합니다. Answer에는 가능하면 raw citation과 wiki revision을 모두 남깁니다.

LLM evaluator로 human review를 대체할 수 있나?

Coverage나 style screening을 보조할 수 있지만, 같은 model family의 shared blind spot과 source permission 판단을 대신하지 못합니다. LLM judge score도 deterministic gate와 sample human audit 아래에 둡니다.

마무리

운영 가능한 LLM Wiki의 전체 loop를 정리하면 다음과 같습니다.

authoritative source
→ inventory + full hash
→ bounded LLM proposal
→ evidence pruning
→ deterministic render
→ staging
→ evidence/schema/link/graph/secret/coverage reports
→ human review
→ conflict-safe promotion
→ canonical-derived search index
→ stale detection and incremental rebuild

가장 중요한 설계 선택은 model이 아닙니다.

  • LLM은 proposal을 만들고 canonical을 직접 쓰지 않습니다.
  • Validator 결과를 promotion gate가 실제로 읽습니다.
  • Ledger는 promotion 뒤에만 source를 consumed 처리합니다.
  • Generated page와 raw source를 모두 추적합니다.
  • Page 수 대신 acceptance, coverage, freshness, rollback을 봅니다.

이 여섯 편의 출발 질문은 “LLM Wiki가 무엇인가?”였습니다. 마지막 답은 제품 정의보다 운영 원칙에 가깝습니다.

LLM Wiki는 LLM이 써 주는 위키가 아니라, LLM이 제안한 지식을 근거·계약·검수로 지속 가능하게 만드는 시스템입니다.

처음부터 다시 읽으려면 1편: LLM Wiki란 무엇인가로 돌아가거나, 구현 전에 4편: LLM Wiki와 RAG의 차이에서 query와 lifecycle 경계를 다시 확인하세요.

참고자료