-
위키가 거짓말하지 않게 — 모든 코드 인용에 file:line을 강제하는 doctrineIT 2026. 5. 30. 22:30
코드 위키를 한 달 운영해 보면 같은 장면이 두세 번 반복된다. 누군가가 위키를 열어 "이 함수의 동작이 이렇다"라는 문장을 읽고, 안내된 위치로 가 보면 그 함수가 거기에 없다. 같은 이름의 다른 함수가 있거나, 함수가 다른 파일로 옮겨졌거나, 아예 삭제되었거나. 위키 본문은 자신만만하게 적혀 있고, 코드는 그 자신감을 배신한다.
이 장면이 사람 독자에게는 짜증나는 정도지만, AI 에이전트에게는 다르다. 에이전트는 RAG(Retrieval-Augmented Generation) — 질문이 들어오면 외부 지식 베이스에서 관련 문서를 먼저 검색해 가져온 뒤 그걸 prompt에 끼워 답을 만드는 패턴 — 으로 동작한다. 위키가 가져다 준 문장을 ground truth(검증된 사실)로 받아 그 위에서 추론한다. 위키 한 줄의 거짓말이 RAG를 거쳐 모든 후속 세션을 오염시킨다. 환각의 시작점이 코드가 아니라 위키 본문이 되는 것이다.
이 글은
deep-wiki가 그 오염을 막기 위해 채택한 단 한 줄의 doctrine — "코드를 가리키는 모든 인용은 반드시file/path.py:LINE형식이어야 한다" — 과, 그것을 매일 자동으로 강제하는 작은 validator에 대한 기록이다. 패턴 자체는 단순하지만, 그 단순함이 어디까지 미치는지가 핵심이다.1. 모호한 인용은 왜 위험한가
코드를 가리키는 방식은 보통 세 단계로 나뉜다.
- L0 (이름만 인용) — "
handle_chat함수가 이 일을 한다". 어느 파일, 어느 줄인지 모름. - L1 (파일까지 인용) — "
proxy.py의handle_chat함수". 파일은 알지만 줄은 모름. - L2 (파일·줄 모두 인용) — "
proxy.py:1247의handle_chat". 완전한 좌표.
L0과 L1까지는 사람 독자에게는 충분하다. 본인이 IDE를 열고 grep을 돌리면 되니까. 그러나 RAG로 동작하는 AI 에이전트는 grep을 다시 돌릴 여유가 없다. 검색기가 위키 청크를 가져왔으면, 그 청크 안의 문자열을 그대로 prompt에 박는다.
handle_chat이라는 이름이 코드 베이스에 3개 있다면, 에이전트는 어느 것을 가리키는지 모른 채 답을 만든다. 답이 그럴듯해 보여도 실제로는 다른 함수의 동작을 설명하고 있을 수 있다.이 모호함은 시간이 흐르면 더 나빠진다. 함수가 같은 파일 안에서 200줄 위로 이동했을 때, L0/L1 인용은 여전히 "위키는 맞다"고 답한다 — 이름은 그대로니까. L2만이 "그 줄은 더이상 함수가 아니라 공백이다"를 즉시 신호한다. 위키의 정확성이 시간 함수가 되어 줄어드는데, 그 줄어듦을 감지하려면 좌표가 있어야 한다.
2. 한 줄의 doctrine
이 인식이 잡힌 뒤
deep-wiki에 박은 규칙은 한 줄이다.코드 본문에 등장하는 모든 심볼은
path/file.ext:LINE형식의 backtick 인용을 포함해야 한다.구체적 형태:
- 좋은 인용 —
`extractor/stage_b.py:148`,`scripts/validate_grounding.py:108-117`,`proxy.py:1247:verify_token`(optional 심볼 이름 포함) - 나쁜 인용 —
`handle_chat`(파일 없음),`proxy.py의 그 함수`(line 없음), 그냥handle_chat(backtick도 없음)
이 규칙이 자연스러운 한국어 글쓰기 흐름을 깨뜨릴 것 같지만, 실제로는 그렇지 않다. "
extract_calls_for_repo는 함수 이름의 line을 인덱스로 받아 jediScript.goto를 호출하고…" 같은 산문 안에`extractor/stage_b.py:148`한 줄만 끼우면 산문 흐름은 보존된다. 읽기에는 이름이, 검증에는 좌표가 — 둘이 분리되어 같은 문장에 공존한다.이 형식이 마음에 들지 않는 사람도 있을 것이다. 줄 번호는 자주 바뀐다는 게 가장 큰 이유다. 함수 위에 한 줄만 추가해도 그 함수의 줄 번호는 바뀐다. 하지만 줄 번호가 바뀌는 그 순간을 잡아내는 것이야말로 doctrine의 진짜 가치다. 줄 번호가 바뀌었음을 위키가 모르고 있다는 사실이, validator가 알려 주는 시그널이다.
3. 매일 walking — L1 grounding validator
doctrine만 박아두면 작성자가 잊는다. 강제 수단이 필요하다.
deep-wiki는scripts/validate_grounding.py라는 작은 파이썬 도구로 매일 위키 전체를 걸어 다니며 모든 anchor를 검증한다.흐름은 단순하다 — 위키 마크다운을 정규식으로 훑어 anchor를 추출하고, anchor가 가리키는 source 파일을 AST(Abstract Syntax Tree, 코드를 트리 구조로 파싱한 형태)로 다시 파싱해 해당 라인에 실제 심볼이 있는지 확인한다.
왼쪽에서 시작한다 — 위키 안에 박힌 anchor 문자열을 정규식으로 골라낸다. 그 anchor의
path를 실제 source 디렉터리에 합쳐 파일을 열고, 파이썬 표준 라이브러리ast로 트리 파싱한다. anchor가 가리키는 line에 실제로 함수 정의, 변수 할당, import 같은 의미 있는 노드가 있는지 본다. 없으면 그 anchor는 drift — wiki는 늙어 있고 코드는 변했다는 신호 — 다. 모든 anchor를 검사한 뒤 drift 비율이 5%를 넘으면 nightly 파이프라인이 알림으로 신호한다. (%는 상황에 맞춰 조절해야 함)핵심 코드는 짧다.
# scripts/validate_grounding.py 의 핵심 매칭 정규식 # `path/to/file.ext:LINE` 또는 `:LINE-LINE`을 backtick 안에서 잡는다. # ADR-0027: optional하게 `:NAME` suffix를 붙여 실제 심볼과 cross-check를 수행한다. ANCHOR_RE = re.compile( r"`(?P<path>(?!https?:|/)[A-Za-z0-9_./\-]+?\.[A-Za-z0-9]+)" r":(?P<start>\d+)(?:-\d+)?" r"(?::(?P<name>[A-Za-z_][\w.]*))?`" ) def resolve_anchor(anchor, source_root): src = source_root / anchor.rel_path if not src.is_file(): return ResolveResult(ok=False, reason="file-not-found") try: tree = ast.parse(src.read_text(encoding="utf-8")) except SyntaxError: return ResolveResult(ok=False, reason="parse-error") total = len(src.read_text().splitlines()) if anchor.line > total: return ResolveResult(ok=False, reason="line-out-of-range") sym = _enclosing_symbol(tree, anchor.line) if sym is None: return ResolveResult(ok=False, reason="no-symbol-on-line") # ADR-0027: expected_name이 있고, 그 라인의 후보 심볼에 없으면 mismatch 처리 if anchor.expected_name: candidates = _qualified_symbols_at(tree, anchor.line) if anchor.expected_name not in candidates: return ResolveResult(ok=False, reason="symbol-name-mismatch") return ResolveResult(ok=True, symbol=sym[0], kind=sym[1])실수의 종류를 의도적으로 분리했다 —
file-not-found(파일이 이동·삭제),line-out-of-range(파일은 있지만 짧아짐),no-symbol-on-line(그 줄이 공백·주석으로 변함),parse-error(소스가 깨졌음),symbol-name-mismatch(동일 좌표에 엉뚱한 심볼이 정의됨). 알림 한 줄로 어떤 종류의 drift인지 즉시 분류된다. 사람이 보고 30초 안에 "함수가 옮겨졌네"인지 "파일이 사라졌네"인지 알 수 있다.4. doctrine은 자기 자신을 검증할 수 없다
doctrine이 자리 잡고 며칠 동안 nightly가 깨끗하게 통과했다. 그러다 한 번 9.24% drift로 FAIL했다. 분석해 보니 drift 93건 중 70건이 doctrine을 설명하는 문서에서 나왔다.
line-anchor doctrineADR 자체의 본문에 들어 있던 예시 anchor`proxy.py:3296`이 실제 파일과 안 맞아서 drift로 카운트된 것이다. 외부 OSS 비교 보고서도 마찬가지였다 —`api/api.py:634`같은 인용은 그 외부 repo의 좌표이지 우리 코드의 좌표가 아니었다.여기서 작은 paradox가 드러난다. doctrine을 가르치는 문서에 doctrine을 강제하면, 그 문서는 doctrine을 가르칠 수 없다. 예시는 illustrative하지 실제 좌표가 아니다. 외부 인용은 우리 코드 좌표가 아니다. 이 클래스의 문서들은 validator의 적용 범위 밖에 있어야 한다.
해결은 작다. 마크다운 머리 부분에 한 줄을 박는다.
<!-- grounding: skip — doctrine examples are illustrative, not real anchors -->validator는 파일 첫 30줄 안에 이 marker가 있으면 그 파일을 skip한다. 30줄인 이유는, wiki 변환기가 자체 frontmatter(~8줄)를 prepend하고 원본도 frontmatter(~9줄)를 가질 수 있어 marker가 17~18째 줄로 밀리기 때문이다. 30줄이면 이중 frontmatter + 빈 줄 + 제목까지 커버하면서, 본문 깊이의 의도치 않은 skip은 막는다.
이 사건이 흥미로운 이유는 doctrine의 boundary를 명시적으로 박는 것의 가치를 보여 주기 때문이다. 처음 doctrine을 만들었을 때는 "모든 문서에 적용"이라고 생각했다. 실제로 운영해 보니 doctrine을 설명하는, doctrine을 비교하는 메타 문서는 별도의 클래스였다. validator가 마침내 그 사실을 강제로 가르쳐 준 셈이다.
5. drift 5% threshold — 신호와 노이즈 사이
drift 0%를 목표로 잡지 않은 이유는 운영 경험에서 나왔다. 코드가 활발히 바뀌면 wiki는 항상 살짝 뒤처진다. nightly가 변경된 repo의 wiki를 재생성하고 LLM 에이전트가 ADR과 가이드 문서를 자동으로 업데이트한다 해도, 코드 커밋과 문서 갱신 파이프라인 사이에 필연적인 시차가 발생한다. 0%는 매 커밋마다 즉각적인 문서 갱신을 강제하는 셈이 되어 실현 가능한 정책이 아니다.
5%는 임의로 박은 게 아니다 — 한 달간 nightly 결과를 보고 정상 운영 시 drift가 1~3%를 오갔다. 5%로 잡으면 "drift가 평소보다 2배 이상으로 튀었을 때"만 알림이 온다. 노이즈와 신호의 분기선이다.
이 thresholding이 또 한 가지 의미를 갖는다 — 완벽한 위키를 목표하지 않는다. 위키는 코드보다 항상 살짝 늦은 그림자다. 그 그림자가 너무 멀어졌을 때만 손을 댄다. drift 1.5%인 상태로 6개월 운영해도, 사람 독자도 AI 에이전트도 충분히 신뢰할 수 있다.
6. 어디까지 보편적인가
이 doctrine이
deep-wiki외에 어디 적용될 수 있는가가 본격적인 질문이다. 답은 코드와 산문이 같이 사는 모든 곳이다.- API 문서 — 각 endpoint 설명이 핸들러의
file:line을 박으면, 핸들러 함수가 옮겨졌을 때 문서가 즉시 알림한다. - RAG knowledge base — 코드 청크를 indexing할 때 각 청크의
file:start-end메타를 박으면, RAG 결과가 직접 IDE를 열게 할 수 있다. 동시에 청크의 anchor가 stale되면 indexing 단계에서 잡힌다. - 아키텍처 다이어그램 캡션 — "이 노드는
api/users.py:34의 라우터" 같은 인용을 매번 박으면 다이어그램의 정확성을 시간에 따라 추적할 수 있다. - incident postmortem — "이 함수에서 race condition" → "
worker.py:182에서 race condition"이 되면, 6개월 후 같은 함수가 다른 파일로 옮겨졌을 때 postmortem이 stale임을 검증기로 알 수 있다.
doctrine을 강제하는 데 필요한 도구는 의외로 작다. 정규식 한 줄 + 표준 라이브러리
ast+ nightly cron 한 줄. 이 셋이면 어떤 wiki도 매일 자기 자신을 검증할 수 있다. Python 외 언어는 그 언어의 표준 파서나tree-sitter로 같은 패턴을 짤 수 있다."agent가 받아 보는 ground truth가 진짜 ground truth임을 시간에 따라 보장하는 것"은 무척이나 중요하다. 위키가 그럴듯한 산문을 잘 쓰는 것보다, 위키가 가리키는 좌표가 매일 검증되는 게 우선이다. 우리는 산문의 화려함을 위해 doctrine을 깨뜨리지 않고, doctrine 위에 산문을 얹는다.
file.py:LINE이라는 한 줄짜리 규약이, 매일 5%라는 한 숫자로 검증되는 시스템 — 그것이 환각을 막는 가장 단순한 방어선이다.7. 정리
doctrine 한 줄, validator 300줄, threshold 한 숫자, skip marker 한 줄. 그게 전부다. 이 작은 시스템이 22개 repo × 5종 페이지 규모의 wiki에서 환각이 RAG로 새는 경로를 막는다. 완벽한 wiki가 아니라 "거짓말의 양이 시간에 따라 폭주하지 않는 wiki" — 그것이 AI 에이전트에게 줄 수 있는 가장 정직한 ground truth다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
1인 로컬 환경에도 outbox 패턴 — NetworkX·Qdrant·파일 3-way 정합성 (0) 2026.06.01 위키 하나에 저장소가 셋인 이유 — 그래프 DB·벡터 DB·파일의 분업 (0) 2026.06.01 agent가 wiki로 task를 풀 수 있느냐가 ground truth — with-wiki vs without-wiki로 측정하기 (0) 2026.05.31 같은 소스에서 매번 같은 위키가 나와야 한다 — 위키 생성 파이프라인의 흔들림 제어 (0) 2026.05.31 외부 API 100%, 내부는 trend — 양적 검증을 두 층으로 가르는 이유 (0) 2026.05.30 file.py:LINE anchor가 진짜 그 줄을 가리키는가 — 매일 AST와 대조해서 RAG 거짓말 끊기 (0) 2026.05.30 코드 위키의 빈 박스와 깨진 다이어그램 — mermaid 검증 2중 안전망 (0) 2026.05.30 LLM이 다른 LLM의 답을 채점하는 법 — judge prompt·rubric.json·3가지 안티패턴 (0) 2026.05.29 LLM 검증을 싸게 — haiku 1차 + sonnet 재검증 + Redis SHA 캐시 (1) 2026.05.29 AI 에이전트가 보는 surface를 8개로 좁히다 — deep-wiki MCP gateway (0) 2026.05.28 - L0 (이름만 인용) — "