위키 레이싱 문서 그래프

한국어 위키백과 2026년 11월 1일 덤프를 전처리한 결과예요.

770,114문서 (그래프의 점)
54,085,452링크 (그래프의 화살표)
70.2문서당 평균 나가는 링크
24문서당 나가는 링크 중앙값

문서별 연결 보기

전체 데이터는 이 페이지에 담기엔 너무 커서 문서 2,003개만 넣었어요. 들어오는 링크가 많은 문서 1,500개와 무작위 문서 500개, 예시 문서로 골랐어요. 파란 칸을 누르면 그 문서로 넘어가고, 회색 칸은 여기 없는 문서예요.

데이터를 불러오는 중이에요. 이 문구가 계속 보이면 viewer-data.js가 페이지와 같은 폴더에 있는지 확인해 주세요.

전처리 데이터 받기

이 파일만 있으면 각자 원하는 자료구조로 그래프를 구현할 수 있어요. zip 안의 README에 형식 설명과 읽는 코드가 있어요.

형식내용생김새zip 크기압축 해제받기
TXT 구현 추천전체 문서 770,114개를 번호로 저장179965 101 146 176 ...111.9 MB383.5 MBTXT 받기
JSON전체 문서를 제목 키와 값으로 저장"이순신":["대한민국", ...]269.2 MB1,182.4 MBJSON 받기
샘플 테스트용문서 1만 개, 링크 567만 개 (TXT, JSON 모두 포함)위 두 형식과 같음13.7 MB143.9 MB샘플 받기

샘플로 먼저 구현을 테스트하고 전체 데이터로 넘어가면 편해요. 샘플의 최단 경로는 전체와 다를 수 있으니 측정과 시연에는 전체 데이터를 써 주세요. 전체 파일은 드라이브 폴더에서도 받을 수 있어요.

전처리 결과 파일

문서마다 0부터 770,113까지 번호를 붙이고, 제목과 링크를 파일 두 개로 나눴어요. 어떤 자료구조로 구현하든 이 파일을 읽으면 돼요.

파일크기줄 수줄 형식예시
titles.tsv26.7 MB770,114번호, page_id, 제목 (탭으로 구분)179965 710049 리그_오브_레전드
links.txt356.9 MB770,114출발 번호, 대상 번호들 (공백으로 구분)179965 101 146 176 864 ...

links.txt의 n번째 줄(0부터 세요)이 n번 문서예요. 대상 번호는 오름차순이고 겹치지 않아요. 나가는 링크가 없는 문서 2,967개는 번호만 적힌 줄로 남겨 뒀어요. 두 파일을 다시 읽어서 문서 수, 링크 수, 번호 범위와 순서를 확인했는데 문제없었어요.

전처리 코드

덤프 4개를 읽어서 위 파일 두 개를 만들어요. 표준 라이브러리만 쓰고, 메모리 24GB PC에서 약 3분 걸렸어요.

python -I scripts/preprocess.py data/raw data/processed
scripts/preprocess.py
"""덤프 4개를 문서 번호 기반 그래프 파일로 바꾼다.

사용법: python -I scripts/preprocess.py <덤프 폴더> <출력 폴더>

출력
  titles.tsv : 줄마다 "번호<TAB>page_id<TAB>제목". 번호는 0부터 빈틈없이 매긴다.
  links.txt  : 줄마다 "출발번호 대상번호 대상번호 ...". 출발번호 순서이고, 대상번호는 오름차순이며 겹치지 않는다.
               나가는 링크가 없는 문서도 번호만 있는 줄로 남긴다.
"""
import gzip
import re
import sys
import time
from array import array
from pathlib import Path

STR = rb"'((?:[^'\\]|\\.)*)'"
OPT = rb"(NULL|'(?:[^'\\]|\\.)*')"
RX_PAGE = re.compile(rb"\((\d+),(-?\d+)," + STR + rb",(\d),", re.S)
RX_REDIRECT = re.compile(rb"\((\d+),(-?\d+)," + STR + rb"," + OPT + rb",", re.S)
RX_LINKTARGET = re.compile(rb"\((\d+),(-?\d+)," + STR + rb"\)", re.S)
RX_PAGELINKS = re.compile(rb"\((\d+),(-?\d+),(\d+)\)")


def unescape(raw):
    return re.sub(rb"\\(.)", lambda m: m[1], raw, flags=re.S)


def rows(path, rx):
    """INSERT 아래의 행(한 줄에 한 행)을 읽는다. 해석 못 하는 줄이 있으면 바로 멈춘다."""
    in_insert = False
    with gzip.open(path, "rb") as f:
        for line in f:
            if in_insert and line[:1] == b"(":
                m = rx.match(line)
                if m is None:
                    raise ValueError(f"해석 실패 {path}: {line[:200]!r}")
                yield m
                continue
            in_insert = line.startswith(b"INSERT INTO")


def main(raw_dir, out_dir):
    t0 = time.time()
    log = lambda msg: print(f"[{time.time() - t0:7.1f}s] {msg}", flush=True)
    raw, out = Path(raw_dir), Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)

    # 1. page: 일반 문서(공간 0)의 제목과 넘겨주기 여부
    id_of, title_of, redirect_pages = {}, {}, set()
    for m in rows(raw / "kowiki-latest-page.sql.gz", RX_PAGE):
        if m[2] == b"0":
            pid, title = int(m[1]), unescape(m[3])
            id_of[title] = pid
            title_of[pid] = title
            if m[4] == b"1":
                redirect_pages.add(pid)
    log(f"page: 일반 문서 {len(title_of):,}개, 넘겨주기 {len(redirect_pages):,}개")

    # 2. redirect: 넘겨주기 문서 -> 진짜 문서
    rd = {}
    for m in rows(raw / "kowiki-latest-redirect.sql.gz", RX_REDIRECT):
        frm = int(m[1])
        if frm in title_of:
            rd[frm] = (int(m[2]), unescape(m[3]), m[4] not in (b"NULL", b"''"))

    def resolve(pid):
        seen = {pid}
        while True:
            t = rd.get(pid)
            if t is None or t[2] or t[0] != 0:
                return None
            nxt = id_of.get(t[1])
            if nxt is None:
                return None
            if nxt not in redirect_pages:
                return nxt
            if nxt in seen:
                return None
            seen.add(nxt)
            pid = nxt

    final = {p: resolve(p) for p in redirect_pages}
    log(f"redirect: 풀린 넘겨주기 {sum(v is not None for v in final.values()):,}개")

    # 3. 넘겨주기가 아닌 문서에 0부터 새 번호를 매긴다 (page_id 오름차순)
    articles = sorted(p for p in title_of if p not in redirect_pages)
    new_id = {p: i for i, p in enumerate(articles)}
    n = len(articles)
    with open(out / "titles.tsv", "w", encoding="utf-8", newline="\n") as f:
        for i, p in enumerate(articles):
            f.write(f"{i}\t{p}\t{title_of[p].decode('utf-8', 'replace')}\n")
    log(f"titles.tsv: {n:,}줄")

    # 4. linktarget: lt_id -> 새 번호 (문서로 이어지는 대상만)
    lt = {}
    for m in rows(raw / "kowiki-latest-linktarget.sql.gz", RX_LINKTARGET):
        if m[2] != b"0":
            continue
        pid = id_of.get(unescape(m[3]))
        if pid is None:
            continue
        dest = final.get(pid) if pid in redirect_pages else pid
        if dest is not None:
            lt[int(m[1])] = new_id[dest]
    log(f"linktarget: 문서로 이어지는 대상 {len(lt):,}개")
    del id_of, title_of, redirect_pages, rd, final

    # 5. pagelinks: (출발, 대상) 쌍으로 바꾼다. pl_from 순서가 아니므로 모아서 정렬한다.
    packed = array("Q")
    for m in rows(raw / "kowiki-latest-pagelinks.sql.gz", RX_PAGELINKS):
        if m[2] != b"0":
            continue
        src = new_id.get(int(m[1]))
        if src is None:
            continue
        dst = lt.get(int(m[3]))
        if dst is None or dst == src:
            continue
        packed.append(src << 32 | dst)
    log(f"pagelinks: 후보 {len(packed):,}개, 정렬 시작")
    del lt, new_id
    ordered = sorted(packed)
    del packed
    log("정렬 끝, links.txt 쓰는 중")

    # 6. 겹치는 쌍을 지우며 한 줄에 한 문서씩 쓴다
    edges = 0
    with open(out / "links.txt", "w", encoding="ascii", newline="\n", buffering=1 << 20) as f:
        cur, targets, last = 0, [], None
        for v in ordered:
            if v == last:
                continue
            last = v
            src = v >> 32
            while cur < src:
                f.write(" ".join([str(cur), *targets]) + "\n")
                cur, targets = cur + 1, []
            targets.append(str(v & 0xFFFFFFFF))
            edges += 1
        while cur < n:
            f.write(" ".join([str(cur), *targets]) + "\n")
            cur, targets = cur + 1, []
    log(f"links.txt: {n:,}줄, 간선 {edges:,}개")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
노잼 얘기들 (덤프 구조, 연결 방법, 전처리 순서)

파일 4개

압축을 풀면 다 합쳐서 약 2.4GB예요. 가장 큰 파일은 pagelinks이고, 제목은 page, linktarget, redirect에 나뉘어 있어요.

파일담긴 것그래프에서 맡는 일행 수압축압축 해제
page문서 번호, 제목, 넘겨주기 여부점3,632,898119.1 MB426.4 MB
pagelinks어느 문서가 어느 대상 번호로 링크하는지화살표85,418,621303.3 MB1,680.9 MB
linktarget대상 번호와 제목의 대응표번호를 제목으로 바꾸기6,615,70379.9 MB262.2 MB
redirect넘겨주기 문서가 가리키는 제목별명 문서를 진짜 문서로 합치기945,10510.2 MB39.8 MB

연결 방법

링크 행에는 대상 문서의 번호가 없어요. pl_target_id는 linktarget의 번호라서, 먼저 제목으로 바꾼 다음 page에서 문서를 찾아야 해요.

pagelinks pl_from pl_from_namespace pl_target_id linktarget lt_id lt_namespace lt_title page page_id page_namespace page_title page_is_redirect redirect rd_from rd_namespace rd_title rd_interwiki ① 출발 문서 찾기 ② 대상 번호를 제목으로 ③ 공간 + 제목으로 문서 찾기 ④ 어떤 문서가 넘겨주기인지 ⑤ 공간 + 제목으로 진짜 문서 찾기
파란 실선은 링크 한 줄을 문서로 바꾸는 길이고, 회색 점선은 넘겨주기를 푸는 길이에요.
순서이 값으로여기서 찾아요얻는 것
①pagelinks.pl_frompage.page_id링크를 건 문서
②pagelinks.pl_target_idlinktarget.lt_id대상의 공간 번호와 제목
③lt_namespace, lt_titlepage_namespace, page_title대상 문서. 없으면 아직 안 쓰인 문서
④redirect.rd_frompage.page_id넘겨주기 문서
⑤rd_namespace, rd_titlepage_namespace, page_title넘겨주기가 가리키는 진짜 문서

테이블 구조

파란색 열만 쓰면 돼요. 예시 행은 덤프에 있는 실제 값이고, 일반 문서만 골랐어요.

page

문서 하나당 한 행
열형식뜻
page_idint문서 번호. 중간에 빈 번호가 많아요
page_namespaceint공간 번호. 일반 문서는 0
page_titlevarbinary제목. 띄어쓰기는 밑줄
page_is_redirecttinyint넘겨주기 문서면 1
page_idnamespacepage_titleis_redirect
50지미_카터0
90수학0
3890스타크래프트0
13688660The_New_York_Times1

나머지 8개 열은 쓰지 않아요: page_is_new, page_random, page_touched, page_links_updated, page_latest, page_len, page_content_model, page_lang

pagelinks

링크 하나당 한 행
열형식뜻
pl_fromint링크를 건 문서의 page_id
pl_from_namespaceint링크를 건 문서의 공간 번호
pl_target_idbigint대상의 lt_id. 문서 번호가 아니에요
pl_fromfrom_namespacepl_target_id
71004908031
7100490297729
7100490421263
71004904757119

linktarget

링크 대상 하나당 한 행
열형식뜻
lt_idbigint대상 번호
lt_namespaceint대상의 공간 번호
lt_titlevarbinary대상 제목. 없는 문서의 제목도 들어 있어요
lt_idnamespacelt_title
80310스타크래프트
2977290The_New_York_Times
4212630미드_시즌_인비테이셔널_2024
475711910리그_오브_레전드

redirect

넘겨주기 문서 하나당 한 행
열형식뜻
rd_fromint넘겨주기 문서의 page_id
rd_namespaceint가리키는 문서의 공간 번호
rd_titlevarbinary가리키는 문서의 제목
rd_interwikivarbinary값이 있으면 다른 위키로 가는 넘겨주기
rd_fragmentvarbinary문서 안의 위치. 쓰지 않아요
rd_fromnamespacerd_title
600민주당_(2000년_대한민국)
720알베르트_아인슈타인
1020음악가
13688660뉴욕_타임스

원본 파일의 생김새

SQL 파일이지만 INSERT 줄 아래에 행이 한 줄씩 있어서, 줄 단위로 읽고 정규식으로 나눌 수 있어요. 제목에 쉼표와 괄호가 들어가니까 쉼표로만 자르면 안 돼요.

INSERT INTO `page` VALUES
(3,4,'대문',0,0,0.02613266955,'20261101202310','20261101202453',42458456,14960,'wikitext',NULL),
(5,0,'지미_카터',0,0,0.932405166265001,'20261031012503','20261030071633',42412419,64090,'wikitext',NULL),

링크 하나 따라가기

리그_오브_레전드 문서(page_id 710049)에서 나가는 실제 행이에요. 같은 모양의 행도 대상에 따라 다섯 가지로 갈려요.

pagelinks 행linktarget에서 찾은 값page에서 찾은 결과처리
710049, 0, 80310, 스타크래프트일반 문서 389번화살표로 저장
710049, 0, 2977290, The_New_York_Times넘겨주기 문서 1368866번, 진짜 문서는 뉴욕_타임스 47206번47206으로 바꿔 저장
710049, 0, 3431160, 뉴욕_타임스일반 문서 47206번. 바로 위 행과 같은 화살표겹치므로 하나만
710049, 0, 4212630, 미드_시즌_인비테이셔널_2024page에 없음 (빨간 링크)버림
710049, 0, 475711910, 리그_오브_레전드공간 10번(틀), 일반 문서가 아님버림

남는 행 수

pagelinks 85,418,621행 중 63.3%가 화살표로 남아요. 막대 길이는 전체 행에서 차지하는 비율이에요.

분류처리행 수비율비율 막대
출발지가 일반 문서가 아님 (토론, 사용자, 틀 등)버림14,405,61216.86%
대상이 없는 문서 (빨간 링크)버림11,917,36913.95%
대상이 일반 문서가 아님버림3,277,4013.84%
출발지가 넘겨주기 문서버림911,2501.07%
넘겨주기를 풀었더니 겹침하나만792,5160.93%
자기 자신을 가리킴버림28,9460.03%
끊긴 넘겨주기로 향함버림750.00%
최종 화살표저장54,085,45263.32%

후보 54,877,968행 중 3,728,111행은 넘겨주기 문서를 가리켜서 진짜 문서로 바꿨어요. linktarget에 번호가 없는 행과 page에 출발 문서가 없는 행은 하나도 없었어요.

page: 점으로 남는 문서

전체 행3,632,898
공간 0번 (일반 문서)1,680,906
그중 넘겨주기910,792
점으로 남는 문서770,114

redirect: 넘겨주기 910,792개를 풀면

한 번에 도착910,735
두 번 거쳐 도착2
다른 공간으로 향함53
대상 없음1
서로 가리키며 순환1

전처리 순서

앞 단계에서 만든 표를 다음 단계에서 쓰기 때문에 이 순서대로 해야 해요.

  1. page를 읽어 공간 0번만 남기고, 제목 → page_id 표와 넘겨주기 목록을 만들어요.
  2. redirect를 읽어 넘겨주기 문서 → 진짜 문서 표를 만들어요. 두 번 거치는 경우까지 따라가고, 순환하거나 대상이 없으면 끊긴 넘겨주기로 표시해요.
  3. 넘겨주기가 아닌 문서 770,114개에 0부터 770,113까지 새 번호를 붙여요. page_id는 최댓값이 4,296,017이라 그대로 배열 위치로 쓰면 빈칸이 많아져요.
  4. linktarget을 읽어 lt_id → 최종 문서 표를 만들어요. 공간 0번 대상 4,501,007개 중 문서로 이어지는 것은 1,119,370개(바로 757,483개, 넘겨주기 361,887개)이고, 3,381,616개는 없는 문서, 21개는 끊긴 넘겨주기예요.
  5. pagelinks를 읽어 출발지와 대상을 새 번호로 바꿔요. 자기 자신을 가리키는 행은 버려요.
  6. (출발, 대상) 쌍을 정렬해서 겹치는 행을 지우면 그래프 파일이 돼요. 제목 목록도 번호 순서대로 따로 저장해요.

주의할 점

확인한 사실어떻게 대응할지
pagelinks는 pl_from 순서가 아니에요. 순서가 뒤집힌 곳이 3,130,616곳이고, 대체로 pl_target_id 순서예요(뒤집힌 곳 25곳).문서별로 묶여 있다고 가정하면 안 돼요. 전체를 정렬한 뒤에 인접 리스트를 만들어요.
나가는 링크가 없는 문서가 2,967개, 들어오는 링크가 없는 문서가 60,601개예요.시연에서 경로 없음이 나올 수 있으니 그 경우의 출력도 만들어 둬요.
인접 행렬은 칸마다 1비트만 써도 770,114² 칸, 약 74.1GB가 필요해요. 그중 채워지는 칸은 0.0091%뿐이에요.개발 PC의 메모리가 약 24GB라 전체 그래프를 올릴 수 없어요. 계산으로만 보여줘요.
pagelinks에는 틀에서 온 링크가 섞여 있고, 본문 링크와 구분되지 않아요.실제로 누를 수 있는 링크라 그대로 써요. 발표 때 "틀 링크 포함"이라고 밝혀요.
파일별 덤프 완료 시각은 11월 1일 23시 18분부터 21분 사이예요.한 번 받은 파일을 묶음으로 유지하고, 측정 중에는 새로 받지 않아요.

들어오는 링크가 많은 문서

대한민국140,194
영어91,366
위키미디어_공용84,962
일본83,449
미국78,096

나가는 링크가 많은 문서

정치적_이데올로기_목록4,218
경기도수원교육지원청2,914
중국의_연호_목록2,866
국제공항_목록2,173
2026년2,018