ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 같은 소스에서 매번 같은 위키가 나와야 한다 — 위키 생성 파이프라인의 흔들림 제어
    IT 2026. 5. 31. 21:00
    같은 소스에서 매번 같은 위키가 나와야 한다 — 위키 생성 파이프라인의 흔들림 제어

    위키 페이지 한 장이 어제와 오늘 다르다. diff를 떠 보면 paragraph 두 개의 순서가 바뀌었다. 단어 한 두 개는 동의어로 치환됐다. 본질적인 내용은 똑같아 보인다. 그런데 — 원본 소스 코드는 한 글자도 안 바뀌었다. 어제와 오늘 LLM에 들어간 입력이 byte-identical이다. 그런데 위키 출력이 다르다.

    위키는 본질적으로 안정적인 참조여야 한다. 누군가 어제 본 페이지를 오늘 다시 열었을 때, 출처 코드가 그대로면 위키도 그대로여야 한다. 그래야 어제의 인용·링크·기억이 오늘 깨지지 않는다. 우리는 매일 위키를 새로 만들지 않는다 — 원본 소스가 실제로 바뀔 때만 해당 페이지를 다시 생성한다. 그 전제가 의미를 가지려면 "입력이 그대로면 출력도 그대로"가 먼저 보장돼야 한다.

    1. 같은 입력에 다른 출력이 왜 위험한가

    소스가 안 바뀌었는데 위키가 흔들리면 두 가지가 한꺼번에 망가진다.

    • 참조의 신뢰가 사라진다 — 어제 읽은 설명과 오늘 읽은 설명이 다르면 사용자는 "그럼 어느 게 맞아?"를 묻게 된다. 위키의 본분은 권위 있는 단일 참조점인데, 매번 흔들리는 참조는 권위를 잃는다. 어제 위키를 인용한 메모, 어제 위키 한 줄을 옮겨 적은 발표 자료가 오늘 다시 열어 보면 어긋난다.
    • 진짜 변경을 못 본다 — 원본 코드가 실제로 바뀌어 위키를 재생성할 때, 우리는 "이번 업데이트로 뭐가 새로 추가됐지?"를 diff로 보고 싶다. 그런데 LLM이 단어를 자기 마음대로 골라 매번 다르게 쓰면 진짜 변경과 잡음을 구분할 수 없다. 1줄짜리 실제 변경이 paragraph 순서 변동과 동의어 치환 100줄 속에 묻힌다. 변경 추적이 무력해진다.

    위키가 종이책이면 같은 인쇄본은 매번 똑같은 내용이 나오는 게 당연하다. LLM이 끼어 있으면 그 "당연함"이 사라진다. 흔들림을 의식적으로 제어하지 않으면 매번 새 인쇄판이 나오는 셈이다.

    2. 어디서 흔들리는가

    LLM이 끼어 있는 파이프라인의 본능적 약점이다. 같은 prompt를 두 번 보내도 출력이 한 token씩 다르게 나올 수 있다. 흔들리는 자리는 의외로 흔하다.

    • LLM temperaturetemperature가 0보다 크면 sampling 단계에서 매번 다른 token이 선택된다. 창의적인 글쓰기에는 좋지만 안정 참조가 필요한 위키에는 독약. temperature=0은 "가장 확률 높은 token만 골라라"라는 명령이다.
    • GPU 부동소수점 잔존 흔들림temperature=0이어도 GPU 연산의 부동소수점 누적 순서가 매번 미세하게 달라지면, 가장 확률 높은 token이 두 후보 사이를 오갈 수 있다. 이건 사용자 코드 레벨로 완전히 잡기 어려운, 남는 잡음이다.
    • seed 미고정 — 일부 모델은 temperature=0이어도 seed가 명시되지 않으면 호출마다 다른 random state로 시작한다.
    • prompt 안의 변동값 — prompt에 now() timestamp나 random ID를 끼우면 입력 자체가 매번 다르다. 의도된 비결정성이지만 위키엔 치명적.
    • set 순회 순서 — Python 3.7+에서 dict는 insert order가 보존되지만 set은 여전히 비결정적이다. 위키 페이지의 link 목록이 set에서 나오면 매번 순서가 바뀐다.
    • 외부 API silent update — embedding service나 외부 검색 API의 모델 버전이 조용히 바뀌면 같은 입력에 다른 결과가 나온다.

    3. 흔들림을 제어하는 방법

    흔들림을 줄이는 건 단계별 작업이다. 위에서부터 효과가 크다.

    • temperature=0 설정 — 우선순위 1번. sampling 자체를 끄는 가장 직접적 방법. 매번 가장 확률 높은 token만 선택. 위키에서는 창의성이 단점이고 일관성이 미덕이다.
    • seed 고정 — vLLM, OpenAI 일부 모델은 seed 파라미터를 받는다. 같은 seed면 같은 random state로 시작한다.
    • prompt 정규화 — timestamp, 호출 시각, random ID를 prompt에서 제거. 입력 자체가 byte-identical이어야 한다.
    • 자료구조 정렬set 대신 sorted(list(...)). dict를 직렬화할 때는 json.dumps(..., sort_keys=True). iteration 순서를 결정적으로 만든다.
    • 외부 API 버전 고정 — embedding model name에 버전 suffix(text-embedding-3-large-2024-01-25 같은)를 명시. 외부 검색은 가능하면 결과를 캐시.

    이 다섯 가지를 모두 적용하면 출력은 거의 같아진다. 그래도 GPU 부동소수점 잔존 흔들림은 남는다 — 1만 token 중 수십 token이 동의어로 흔들리는 정도. 사용자 코드 레벨에서 완전히 잡을 수는 없는 흔들림이다.

    그래서 검증의 형태는 두 갈래가 된다. 통제 가능한 흔들림은 모두 제거하고, 통제 불가능한 잔존 흔들림은 "허용 범위 안에 있는지"를 측정한다.

    4. 그래도 남는 흔들림을 어떻게 보는가

    diagram

    흐름은 단순하다. 같은 입력으로 위키를 두 번 생성한다. 먼저 두 결과 파일의 byte hash를 비교 — 한 비트도 안 다르면 통과. 가장 엄격하고 가장 싸다. 다르면 두 번째 잣대인 Jaccard similarity를 잰다.

    Jaccard similarity가 뭔가? 두 글의 단어를 각각 집합(set)으로 만든 다음, "겹치는 단어 수 ÷ 둘을 합한 전체 단어 수"를 계산한 값이다. 두 글에 같은 단어들이 얼마나 많이 나오는지를 0과 1 사이 숫자로 표현한다. 1.0이면 단어 집합이 완전히 같다는 뜻이고, 0이면 한 단어도 안 겹친다. 0.95라면 단어 집합의 95%가 겹친다 — 두 글의 의미는 거의 같다고 본다. paragraph 순서가 바뀌어도, 공백이 다르게 들어가도, 동의어 한두 개만 바뀌어도 Jaccard는 0.95 이상으로 나온다.

    5. 코드 — hash 한 번, Jaccard 한 번

    import hashlib
    from pathlib import Path
    
    def file_hash(p: Path) -> str:
        return hashlib.sha256(p.read_bytes()).hexdigest()
    
    def jaccard(a: str, b: str) -> float:
        """두 글의 단어 집합 겹침 비율. 1.0 = 완전 일치, 0 = 무관."""
        sa, sb = set(a.split()), set(b.split())
        return len(sa & sb) / max(len(sa | sb), 1)
    
    def check_determinism(out_a: Path, out_b: Path) -> dict:
        # 1단계: byte hash — 가장 엄격, 가장 싸다
        if file_hash(out_a) == file_hash(out_b):
            return {"status": "byte-identical"}
    
        # 2단계: byte는 다른데 의미는 같을 수 있다 — 단어 집합으로 fallback
        sim = jaccard(out_a.read_text(), out_b.read_text())
        if sim >= 0.95:
            return {"status": "jaccard-pass", "similarity": sim}
    
        return {"status": "fail", "similarity": sim}
    

    두 단계 구조의 의미가 여기에 있다. byte hash는 "한 비트도 안 바뀌었으면 무조건 통과"라는 가장 엄격한 잣대. 통과 못 하면 Jaccard로 내려간다 — "단어 집합이 거의 같으면 의미는 같은 위키로 친다"는 운영적 타협. 두 잣대가 "최대한 엄격하게" + "운영 가능하게"의 절충점이다.

    6. 왜 0.95인가

    임계값 0.95는 두 가지 관찰에서 나왔다.

    첫째, 통제 가능한 흔들림(temperature, seed, set 정렬, prompt 변동값)을 모두 잡으면 남는 건 GPU 부동소수점 잔존 흔들림 정도다. 이 잡음은 보통 1만 단어 중 수십 단어를 동의어로 흔드는 수준 — 단어 집합 변화는 5% 미만이다. 0.95는 그 "건강한 잔존 흔들림"의 상한선이다.

    둘째, 그 이하로 떨어지면 통제 안 된 흔들림이 새고 있다는 신호다. 한두 문장이 통째로 빠지거나, 핵심 단어가 다른 것으로 치환되거나, paragraph 하나가 새로 끼어든 경우 — Jaccard는 0.95 아래로 떨어진다. 이때는 어디 한 곳에서 결정성이 깨졌다는 뜻이므로 FAIL로 잡아 진단해야 한다.

    임계값은 도메인에 맞춰 조정한다. 한국어 위키는 단어 토크나이즈 변동성이 크므로 더 관대하게(0.90), 코드 중심 위키는 더 엄격하게(0.98) 잡을 수 있다.

    7. 무엇을 잡고 무엇을 못 잡나

    이 검증이 잡는 것:

    • 통제되지 않은 LLM 흔들림 (temperature 누출, seed 미고정)
    • 자료구조 비결정성 (set 순회, dict 직렬화 순서)
    • prompt 안에 들어간 변동값 (timestamp, random ID)
    • 외부 API silent update

    이 검증이 못 잡는 것 — 그날 LLM 호출 자체가 의도와 다른 출력을 만든 경우. 두 번 다 똑같이 잘못된 출력을 내면 byte-identical로 통과한다. 결정성 검증은 "안정적으로 같은 답을 만드는가"까지 검증하고, "그 답이 맞는가"는 별도의 평가 영역이다.

    8. 정리

    위키는 안정적인 참조여야 한다. 같은 소스에서 같은 위키가 나오지 않으면 사용자 신뢰가 깨지고, 진짜 변경과 LLM 잡음을 구분할 수 없게 된다. temperature=0, seed 고정, 자료구조 정렬, prompt 정규화, 외부 API 버전 고정으로 통제 가능한 흔들림을 모두 제거하고, 남는 부동소수점 잔존 흔들림은 byte hash → Jaccard 0.95 fallback의 두 단계로 허용 범위 안에 가둔다. 위키 생성 파이프라인이 "입력이 같으면 출력도 같다"는 약속을 지키는지 확인하는 유일한 자리다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.