설계기준 PDF 색인과 수식 기호 검색 실험

한글(HWP)로 만든 설계기준 PDF는 수식 글자를 유니코드 개인용 영역(PUA) 코드로 내보낸다. PUA 대응표를 만들어 95.6%를 복원하고, 표를 캡션과 가로 규선으로 추출하고, 기호 토큰과 질의 유형별 라우팅을 더했다. 자동 생성한 질의 752개로 가설 H1~H9를 하나씩 측정한 결과와 원시 데이터를 싣는다.

Seobway · · 20분

1. 배경

1.1 문제 상황

구조 계산서에 설계기준의 식과 표를 인용하려면 원문 PDF를 열어 해당 쪽을 찾아야 한다. 이 작업을 줄이려고 설계기준 PDF를 RAG 검색에 색인하고 있다. 도로교 설계기준(한계상태설계법) 2016 일반교량편 PDF(811쪽)를 색인해 두고 「LL 판정하는 식이 뭐에요?」라고 물었다. 검색은 3.6.1 차량활하중 조항을 찾았지만 LLM은 이렇게 답했다.

조항 본문에서 식의 기호와 수식이 텍스트로 잡히지 않아, 식 자체는 옮겨 적을 수 없습니다.
원문 페이지(p51, 3.6.1.1) 확인이 필요합니다.

같은 조항에 딸린 표 3.6.2 「표준차로하중」을 색인에서 꺼내면 이렇게 나왔다.

지간 표준차로하중 (색인에 저장된 글자)
□ ≤ 60 m □□□□□□ (kN/m)
□ > 60 m □□□ (kN/m) · □□□□□□×□□
표 3.6.2 표준차로하중 원문 영역
그림 1. 같은 표의 원문 영역. 사람 눈에는 L ≤ 60 m, ω = 12.7 (kN/m), ω = 12.7 × (60/L)^0.10 이 보인다. 출처: 도로교 설계기준(한계상태설계법) 2016 일반교량편, 표 3.6.2

원문에는 L ≤ 60 m, ω = 12.7 (kN/m), ω = 12.7 × (60/L)^0.10이 적혀 있다. □는 추출 결과를 표시할 때 넣은 기호이고, 실제로 색인에 들어간 값은 U+E000~U+F8FF 범위의 코드였다. 유니코드는 이 범위를 개인용 영역(PUA, Private Use Area)으로 비워 두고 어떤 글자도 배정하지 않는다[1]. 코드만으로는 원래 글자를 알 수 없다.

항목 값
수식 글자가 섞인 조항 481개 중 240개 (49.9%)
읽지 못한 칸이 있는 표 168개 중 106개 (63%)
수식 폰트가 사용한 서로 다른 PUA 코드 135종

검색 쪽 결과도 같았다. 조항에 정의된 기호(fyt, ADTTSL, φs 등) 150개로 BM25 검색을 하면 Hit@3이 0.013이었다. 이 글은 PDF 추출, 표 추출, PUA 복원, 토큰화, 라우팅을 차례로 고치면서 각 단계의 효과를 같은 평가셋으로 잰 기록이다. 검색 파이프라인(dense, BM25, RRF, 리랭커) 자체는 설계기준 RAG의 하이브리드 검색 구현에서 다뤘다.

1.2 PDF에서 글자가 추출되는 과정

PDF 페이지의 콘텐츠 스트림에는 문자가 아니라 「폰트 F의 글리프 번호 N을 좌표 (x, y)에 그린다」는 명령이 들어 있다. 텍스트 추출기가 글자를 돌려주려면 글리프 번호(CID)를 유니코드로 바꾸는 대응표가 필요하다. PDF 표준에서는 폰트 사전의 ToUnicode CMap이 이 역할을 한다[2]. ToUnicode가 없으면 추출기는 폰트 파일 내부의 cmap 테이블이나 글리프 이름(uni03C9, omega)으로 추정한다.

%% desc: 글리프 번호가 유니코드로 바뀌는 경로. 대응이 없거나 PUA로 지정되면 뜻 없는 코드가 나온다
flowchart LR
    A["콘텐츠 스트림<br/>글리프 번호 (CID)"] -->|ToUnicode CMap| B[유니코드 문자]
    A -->|폰트 내부 cmap| B
    A -->|글리프 이름| B
    A -.->|대응 없음 또는 PUA 지정| C["U+E0B4<br/>의미 없는 코드"]

문제의 PDF에는 폰트가 네 개 있었다. 본문용 세 개(Haansoft Batang, YDIYGO320, YDIYMjO320)는 정상이었고, 한글 수식 편집기가 쓰는 HyhwpEQ만 PUA를 냈다. 이 폰트는 문서에 쓰인 글리프만 담은 서브셋으로 들어가 있다.

확인 항목 결과
폰트 형식 Type0, 인코딩 Identity-H, 서브셋 임베드 (THDNKW+HyhwpEQ)
ToUnicode 글리프를 PUA 코드로 매핑한다 (ω → U+E0B4)
폰트 파일 내부 대응표 임베드된 폰트를 꺼내 fontTools로 열면 cmap 테이블이 없다[3]
같은 PDF의 ≤ ≥ × ⋅ 정상 유니코드. 수식 폰트 안에서도 일부 글리프만 PUA다

ToUnicode가 PUA를 가리키고 폰트 내부 cmap도 없으므로, PDF 파일 안에는 원래 글자를 알아낼 정보가 없다. 같은 기관의 2000년, 2008년 판 PDF는 수식 폰트를 쓰지 않아 PUA가 0개였다. KDS 웹 원문은 식을 base64 이미지로 싣고 <img data-script="…"> 속성에 한글 수식 스크립트를 같이 넣어 두어, 스크립트를 LaTeX로 변환해 식 541개를 렌더링할 수 있었다. 같은 설계기준이라도 만든 도구에 따라 추출 결과가 다르다.


2. 방법

2.1 표 추출

PyMuPDF의 Page.find_tables()를 먼저 썼다[4]. 선 기준 전략(lines)은 표를 0개 찾았고, 글자 기준 전략(text)은 쪽 전체를 43행 × 5열 표 하나로 잡았다. 설계기준 표는 세로선 없이 가로 규선만 긋기 때문에 선 기준 전략이 셀 경계를 만들지 못한다.

대신 설계기준 표의 조판 관행을 규칙으로 썼다. 표 위에는 항상 「표 N.N.N 제목」 캡션이 있고, 캡션 바로 아래부터 가로 규선이 이어진다.

  1. 쪽의 글줄 중 정규식 ^\s*표\s*(\d+(?:[.\-]\d+)+)\s*(.*)$에 맞는 줄을 캡션으로 찾는다.
  2. page.get_drawings()에서 가로선과 높이 2pt 미만의 얇은 사각형을 가로 규선으로 모은다.
  3. 캡션 아래 40pt 안에서 시작하는 규선 묶음을 표 영역으로 본다. 규선 간격이 80pt를 넘으면 표가 끝난 것으로 보되, 첫 규선과 폭이 같은 선은 400pt까지 같은 표의 끝선으로 인정한다. 그 사이에 다른 캡션이나 조항 제목이 있으면 끊는다.
  4. 행은 규선 사이의 띠로 나눈다. 규선이 머리행에만 있어 띠가 2개 이하면 글줄 단위로 나눈다.
  5. 열은 표 안 단어들의 x 구간을 겹쳐 본 빈틈으로 나눈다. 빈틈이 글자 높이 중앙값 × 1.2(최소 6pt) 이하면 띄어쓰기로 보고 같은 칸에 둔다.
# pdf_tables.py (열 나누기)
hs_ = sorted(w[3] - w[1] for w in inside)
gap = max(_COLGAP, 1.2 * hs_[len(hs_) // 2])
cols: list[list[float]] = []
for a, b in sorted((w[0], w[2]) for w in inside):
    if cols and a <= cols[-1][1] + gap:
        cols[-1][1] = max(cols[-1][1], b)
    else:
        cols.append([a, b])

811쪽에서 표 168개를 3.3초에 추출했다. 각 표는 {no, title, page, bbox, rows, pua}로 저장하고, 캡션 「표 N」을 본문에 언급한 조항 중 표가 있는 쪽 이전에서 가장 가까이 시작한 조항에 붙인다. 표가 붙은 조항은 70개였다. bbox는 쪽 크기로 나눈 0~1 좌표라, 화면은 복원하지 못한 칸이 있는 표 옆에 원문 영역을 이미지로 렌더링해 같이 보여 준다.

표 구조는 이것으로 잡혔지만 168개 중 106개에 □ 칸이 남았다. 표 추출 문제가 아니라 글자 문제였다.

2.2 PUA 대응표

2.2.1 글리프 모양 비교

처음에는 PUA 글리프 135종을 600dpi로 잘라내고, Times와 Cambria Math로 그린 후보 글자(영문 정체와 이탤릭, 숫자, 그리스 문자, 연산자)와 모양 유사도로 비교했다. ω, =, 2, 0, 괄호는 맞았지만 신뢰도 0.8 미만이 102종이었다. L을 H로, 7과 1을 I로 판정했다. 이탤릭 세리프체의 가는 획은 32 × 32 픽셀로 줄이면 서로 비슷해진다.

2.2.2 코드 순서 규칙

모양 비교가 확실히 맞힌 글자만 코드 순서로 늘어놓자 간격이 일정했다. U+E00B가 L, U+E00F가 P, U+E016이 W다. U+E000을 A로 두고 세면 정확히 맞는다. HyhwpEQ는 글자를 블록 단위로 알파벳 순서대로 PUA에 배치한다.

블록 코드 범위 확인한 글자
대문자 A–Z E000 – E019 L = E00B, P = E00F, W = E016
숫자 1–9, 0 E034 – E03D 12.7 = E034 E035 E053 E03A
그리스 대문자 Α–Ω E085 – E09C Δ = E088, Σ = E096, Φ = E099
그리스 소문자 α–ω E09D – E0B4 ω = E0B4 (표 3.6.2)
소문자 a–z E0E5 – E0FE m = E0F1 (표 3.6.1 머리행)
연산자와 기호 E042 – E05C 외 글리프 이미지를 보고 26개를 직접 지정
HyhwpEQ 개인용 영역 기호 글리프 시트
그림 2. 블록 순서 밖의 PUA 코드를 600dpi 로 잘라 빈도순으로 늘어놓은 시트. 칸 아래는 코드와 출현 횟수(n). 첫 칸 E06D(939회)는 분수 가로줄이라 주변 글자까지 같이 잘렸다. 연산자 26개는 이 시트를 보고 지정했다. 출처: 도로교 설계기준(한계상태설계법) 2016 일반교량편에서 추출
# hweq.py
def _build() -> dict[int, str]:
    m: dict[int, str] = {}
    for i, ch in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
        m[0xE000 + i] = ch
    for i, ch in enumerate("1234567890"):
        m[0xE034 + i] = ch
    for i, ch in enumerate("ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"):
        m[0xE085 + i] = ch
    for i, ch in enumerate("αβγδεζηθικλμνξοπρστυφχψω"):
        m[0xE09D + i] = ch
    for i, ch in enumerate("abcdefghijklmnopqrstuvwxyz"):
        m[0xE0E5 + i] = ch
    m.update({0xE046: "−", 0xE047: "=", 0xE048: "+", 0xE053: ".", 0xE05C: "√", ...})
    return m

def decode(text: str) -> str:
    """PUA 코드를 글자로. 대응표에 없는 코드는 □."""
    return _PUA.sub(lambda mt: HWPEQ.get(ord(mt.group()), UNKNOWN), text)

_PUA는 [-] 범위의 정규식이다.

2.2.3 다른 문서로 검증

PDF PUA 글자 수 복원 비율 복원 예
도로교 설계기준(한계상태설계법) 2016 일반교량편, 811쪽 29,718 95.6% 5 ≤ Nb ≤ 20, L ≤ 60 m
도로교 설계기준(한계상태설계법), 777쪽 (다른 문서) 29,830 95.7% ρtfst = fnt + vtan θ ≤ φsρtfyt
도로교 설계기준 2000, 2008 해설 0 해당 없음 수식 폰트를 쓰지 않음

다른 문서에서도 같은 코드가 같은 글자였다. 대응표는 개별 PDF가 아니라 HyhwpEQ 폰트에 딸린 규칙이므로, 한글에서 만든 다른 설계기준 PDF에도 그대로 쓸 수 있다. 남은 4.4%는 분수 가로줄(U+E06D, 939회)과 큰 괄호 조각이다. 글자가 아니라 배치 정보여서 코드 대응으로는 복원할 수 없다. 이 코드들은 추측한 글자로 채우지 않고 □로 둔다. 설계기준의 수치가 틀리게 복원되면 계산서가 틀리기 때문이다.

복원은 pdf_adapter._clean()과 표 칸 추출에서 색인 전에 한다. 이미 만들어진 색인은 lexical._tokenize()와 검색 결과 출력에서 한 번 더 decode()를 거친다. 사용자가 PDF를 다시 색인하지 않아도 BM25와 화면에 반영된다.

2.3 기호 토큰

복원한 글을 BM25 토크나이저(kiwipiepy 형태소 분석[5] + 「2글자 이상이거나 ASCII」 필터)에 넣어 보니 기호가 다시 사라졌다.

입력 토큰 결과
ω=12.7 = 12.7 ω는 한 글자 비ASCII라 필터에서 삭제
φ_s ρ_t f_yt _ s _ t f _ yt 그리스 문자 삭제, 첨자 분해
f_ck / fck f _ ck / fck 같은 기호가 다른 토큰
A_{v,min} A _ { v , min } 기호가 조각남
표 3.6.1 3.6.1 번호는 유지

형태소 분석기는 수식 기호를 품사로 분류하지 못하고, 조사를 없애려고 넣은 한 글자 필터가 그리스 문자를 지웠다. 그래서 형태소 토큰과 별도로 기호 토큰을 만들어 덧붙인다.

# symbols.py
_SYM = re.compile(rf"[A-Za-z{_GREEK}ℓ](?:[A-Za-z0-9{_GREEK}ℓ′']|_\{{[^}}\s]{{1,8}}\}}|_[A-Za-z0-9{_GREEK}]+)*")

def normalize(sym: str) -> str:
    """f_ck · f_{ck} · fck → fck. A_{v,min} → avmin."""
    s = sym.replace("_", "").replace("{", "").replace("}", "").replace(",", "")
    return s.lower()

def symbol_tokens(text: str) -> list[str]:
    out: list[str] = []
    for m in _SYM.finditer(text or ""):
        tok = normalize(m.group())
        if not tok or _PLAIN_WORD.match(tok):  # 5자 이상 순수 영문 낱말은 기호가 아니다
            continue
        out.append(tok)
        if _HAS_GREEK.search(tok):
            out.append("".join(GREEK_NAME.get(c, c) for c in tok))  # φs → phis
    return out
# lexical.py
def _tokenize(text: str) -> list[str]:
    text = hweq.decode(text)
    return _morphs(text) + symbol_tokens(text)

첨자 표기를 지우고 소문자로 통일하므로 f_ck, f_{ck}, fck가 모두 fck가 된다. 그리스 문자가 있으면 로마자 이름(phis)도 같이 넣어서, 키보드로 φ를 입력하지 못하는 사용자도 찾을 수 있다. 색인과 질의에 같은 함수를 쓴다.

2.4 평가셋 자동 생성

사람이 질의를 만들면 만든 사람이 쓰는 표현에 치우친다. 설계기준 PDF에는 「여기서, X : 설명」 형식의 기호 정의 줄과 캡션이 붙은 표가 있어서, 평가셋의 질의와 정답 조항을 문서에서 직접 뽑을 수 있다(exp_symbol.build_evalset, 난수 시드 7, 유형당 최대 150개).

유형 생성 규칙 개수 정답
A 기호 정의 줄 X : 설명의 X (2글자 이상, 설명에 한글 포함) 150 그 기호를 정의한 조항
A_ 첨자 표기 A의 기호를 f_ctm처럼 첫 글자 뒤에 _를 넣은 형태 133 같음
B 기호 + 설명어 X + 설명에서 가장 긴 한글 낱말 149 같음
C 표 제목 표 캡션의 제목 (4글자 이상) 150 표가 붙은 조항
D 표 값 표 행에서 한글 칸 하나 + 숫자가 있는 칸 하나 150 표가 붙은 조항
E 기존 질의 앞 글의 KDS 질의 20개 20 기존 정답

실제로 생성된 질의 앞부분이다.

ρ′ · fpu · qu · ti · Fyf · VP · γg · Dc

자동 생성이라 어색한 질의도 섞인다. ti 결정하는은 설명에서 가장 긴 낱말이 동사였던 경우다. 에 관한 하중계수는 제목 앞의 수식 기호가 □로 남아 생성 단계에서 빠진 경우다. 걸러내지 않고 그대로 쟀다.

검색 대상은 이 PDF의 조항 481개에 KDS 웹 조항 806개를 방해 문서로 섞은 1,287개다. 사용자 색인은 읽기만 하고, 실험용 색인은 메모리에 따로 만든다.

2.5 지표와 채택 규칙

검색 전에 색인만 보고 재는 지표와, 검색을 돌려 재는 지표를 나눴다.

지표 정의 목적
matched 질의 토큰 중 하나 이상이 정답 조항 토큰에 있는 질의 비율 BM25가 원리상 찾을 수 있는지
empty 토큰화 후 토큰이 하나도 남지 않은 질의 비율 토크나이저가 질의를 지웠는지
pool recall 리랭커에 넘기는 후보 20개 안에 정답이 있는 비율 합산 단계의 손실
Hit@1, 3, 5, MRR 앞 글과 같음 최종 순위

Hit@k는 유형별, 그리고 BM25 단독, dense 단독, RRF, RRF + 리랭커로 따로 잰다. 전체를 합쳐 한 숫자로 보면 기호 질의의 변화가 다른 유형에 묻힌다.

리랭커는 CPU에서 질의당 약 9초가 걸린다. 처음에 752개 전체로 돌렸다가 33분이 지나도 첫 단계도 끝나지 않아 중단했다. 이후 리랭커가 들어가는 측정은 유형마다 30개(E는 20개 전부) 표본을 random.Random(7)로 뽑아 잰다. 30개 표본에서 1문항은 3.3%p다.

가설 바꾸는 것 목표 유형
H1 PUA 복원 (hweq.decode) A, B
H2 기호 토큰 (symbol_tokens) A_, A
H3 코퍼스에서 3회 이상 나온 4글자 이상 한글 덩어리(1,800개)를 통째 토큰으로 추가 C
H4 표 행 선형화 줄 「표 제목 | 머리: 값 · 머리: 값」을 색인 글에 추가 C, D
H5 복원한 글로 재임베딩 dense 전체
H6 리랭커 후보를 RRF 상위 20 대신 BM25 상위 20 ∪ dense 상위 20으로 A, C
H7 기호 질의의 리랭커 처리 변경 (a, b, c 세 방식) A, A_
H8 표 제목 바로가기 C
H9 표 행 바로가기 D

3. 결과

3.1 색인 전 지표

matched A A_ B C D E
V0 (처음) 0.233 0.286 0.940 0.987 0.940 1.000
H1 PUA 복원 0.993 0.556 1.000 0.987 0.940 1.000
H2 기호 토큰 1.000 1.000 1.000 0.987 0.940 1.000

처음 색인에서는 기호 질의 A의 77%가 정답 조항과 토큰이 하나도 겹치지 않았다. 이 상태에서는 BM25 점수 식의 모든 항이 0이라 순위가 나올 수 없다. H1만으로 A는 0.993이 됐고, 첨자 표기 A_는 H2에서 1.000이 됐다. 검색을 돌리기 전에 이미 H1, H2의 효과 범위가 보인다.

3.2 BM25 단독, 가설 누적

질의 752개 전체, BM25 Hit@3이다.

처음채택탈락·보류A 기호0.51.0A 기호 · V0: 0.0130.01V0A 기호 · H1: 0.7470.75H1A 기호 · H2: 0.8330.83H2A 기호 · H3: 0.8330.83H3A 기호 · H4: 0.8470.85H4A_ 첨자 표기0.51.0A_ 첨자 표기 · V0: 0.0080.01V0A_ 첨자 표기 · H1: 0.1500.15H1A_ 첨자 표기 · H2: 0.6240.62H2A_ 첨자 표기 · H3: 0.6170.62H3A_ 첨자 표기 · H4: 0.6320.63H4B 기호+설명어0.51.0B 기호+설명어 · V0: 0.2210.22V0B 기호+설명어 · H1: 0.7250.72H1B 기호+설명어 · H2: 0.8520.85H2B 기호+설명어 · H3: 0.8460.85H3B 기호+설명어 · H4: 0.8660.87H4C 표 제목0.51.0C 표 제목 · V0: 0.7600.76V0C 표 제목 · H1: 0.7070.71H1C 표 제목 · H2: 0.6670.67H2C 표 제목 · H3: 0.6600.66H3C 표 제목 · H4: 0.9470.95H4D 표 값0.51.0D 표 값 · V0: 0.6330.63V0D 표 값 · H1: 0.6670.67H1D 표 값 · H2: 0.6730.67H2D 표 값 · H3: 0.6800.68H3D 표 값 · H4: 0.8270.83H4E 기존 질의0.51.0E 기존 질의 · V0: 0.6000.60V0E 기존 질의 · H1: 0.6000.60H1E 기존 질의 · H2: 0.6000.60H2E 기존 질의 · H3: 0.6500.65H3E 기존 질의 · H4: 0.6500.65H4
그림 3. 질의 유형별 BM25 Hit@3, 가설을 누적해 켤 때. 채운 막대는 채택, 빈 막대는 탈락(H3)·보류(H4). 질의 752개 전체. 데이터: exp_result4.json
단계 A A_ B C D E
V0 0.013 0.008 0.221 0.760 0.633 0.600
H1 PUA 복원 0.747 0.150 0.725 0.707 0.667 0.600
H2 기호 토큰 0.833 0.624 0.852 0.667 0.673 0.600
H3 복합명사 0.833 0.617 0.846 0.660 0.680 0.650
H4 표 선형화 0.847 0.632 0.866 0.947 0.827 0.650

같은 데이터를 A 유형의 정답 순위 분포로 보면 다음과 같다. 「20위 밖」은 BM25 상위 20개에 정답이 없는 질의 수다.

A 기호 (150개) 1위 2~3위 4~20위 20위 밖
V0 1 1 6 142
H1 89 23 27 11
H2 102 23 23 2

H1 하나로 A가 0.013에서 0.747이 됐다. fyt, ADTTSL 같은 영문 기호는 복원만 하면 ASCII라 필터를 통과한다. H2는 복원만으로 부족했던 A_(f_pu 형태로 친 질의)를 0.150에서 0.624로 올렸다. H4는 표 제목 C를 0.667에서 0.947로 올렸다.

3.3 검색 방식별 비교

H2 색인에서 방식만 바꿔 잰 Hit@3이다. 리랭커는 표본 30개(E는 20개)다.

A 기호BM25A 기호 · BM25: 0.8330.833denseA 기호 · dense: 0.0270.027RRFA 기호 · RRF: 0.6270.627+리랭커A 기호 · +리랭커: 0.5330.533C 표 제목BM25C 표 제목 · BM25: 0.6670.667denseC 표 제목 · dense: 0.0530.053RRFC 표 제목 · RRF: 0.3270.327+리랭커C 표 제목 · +리랭커: 0.7000.700E 기존 질의BM25E 기존 질의 · BM25: 0.6000.600denseE 기존 질의 · dense: 0.3000.300RRFE 기존 질의 · RRF: 0.6500.650+리랭커E 기존 질의 · +리랭커: 0.9000.900
그림 4. H2 색인에서 검색 방식별 Hit@3. 기호(A)와 표 제목(C)은 BM25 단독이 가장 높고, 기존 한글 질의(E)는 리랭커가 가장 높다. 리랭커는 유형당 표본 30개(E는 20개). 데이터: exp_result4.json
Hit@3 A A_ B C D E
dense 0.027 0.000 0.034 0.053 0.040 0.300
BM25 0.833 0.624 0.852 0.667 0.673 0.600
RRF 0.627 0.376 0.577 0.327 0.307 0.650
RRF + 리랭커 0.533 0.500 0.867 0.700 0.500 0.900

dense는 PDF 유래 질의(AD)에서 0.053을 넘지 못했다. 임베딩 모델은 fyt나 φs가 무엇을 뜻하는지 학습한 적이 없다. 학습 도메인 밖 문서에서 dense 검색이 BM25보다 약하다는 보고와 같은 방향이다[8]. RRF는 BM25보다 AD 전부에서 낮았다. 앞 글에서 정리한 대로 k=60k = 60, 후보 20개의 RRF[7]는 두 목록에 모두 든 문서를 먼저 세운다. dense가 PDF 질의의 정답을 거의 목록에 넣지 못하므로, BM25에만 있는 정답은 두 목록에 모두 걸린 오답들 뒤로 밀린다. 리랭커는 B, C, E를 올렸지만 A는 더 떨어뜨렸다.

3.4 병목 위치 확인

A의 BM25 0.833과 최종 0.533 사이의 손실을 처음에는 RRF 합산에서 정답이 후보 20개 밖으로 밀린 것으로 봤다. 그래서 리랭커 후보를 「BM25 상위 20 ∪ dense 상위 20」으로 넓히는 H6을 약 50분 동안 측정했는데, 결과는 표본 30개에서 ±1문항으로 변화가 없었다.

그다음에 후보 안에 정답이 있는지(pool recall)를 쟀다. 리랭커 없이 수 초면 나오는 값이다.

H2 색인 A A_ B C D E
정답이 RRF 상위 20 안에 있는 비율 0.967 0.729 0.973 0.853 0.760 0.950
합집합 후보(H6)일 때 0.993 0.857 1.000 0.880 0.780 0.950
리랭커 뒤 Hit@3 0.533 0.500 0.867 0.700 0.500 0.900

A의 정답은 96.7%가 이미 리랭커 후보 안에 있었다. 손실은 합산이 아니라 리랭커에서 생겼다. 리랭커는 한국어 문장 쌍으로 학습된 교차 인코더라서, fyt 한 단어로 된 질의와 조항의 관련도를 판단할 입력 정보가 부족하다. H6은 탈락했다. 단계별 손실을 싼 지표로 먼저 나눠 봤다면 50분 측정 없이 바로 리랭커를 의심할 수 있었다.

3.5 H7 기호 질의의 리랭커 처리

한글이 한 글자도 없는 질의를 기호 질의로 판정한다. 리랭커 점수는 한 번만 계산하고 뒤처리만 바꿔 세 방식을 비교했다.

Hit@3 (표본 30) A A_ B C D E
H2 리랭커 그대로 0.533 0.500 0.867 0.700 0.500 0.900
H7a 기호 질의는 BM25 순위 사용 0.767 0.667 0.867 0.700 0.500 0.900
H7b 기호 질의는 리랭커 순위와 BM25 순위를 RRF 0.700 0.600 0.867 0.700 0.500 0.900
H7c 모든 질의에 리랭커 순위와 BM25 순위를 RRF 0.700 0.600 0.900 0.833 0.533 0.800

H7a를 채택했다. 한글 질의는 이 분기를 타지 않아 B~E가 그대로다. H7c는 C를 올렸지만 E를 10%p(20개 중 2개) 떨어뜨려 탈락했다. 운영 코드는 앞 글의 3.4절에 있다.

3.6 H8 표 제목 바로가기

C는 긴 조항 본문에 묻혀 RRF에서 0.327이었다. 표 제목과 머리행만 모은 작은 BM25 색인을 따로 두고(table_route.TableIndex), 1위 표가 질의 토큰을 충분히 덮으면 그 표가 붙은 조항을 결과 맨 앞에 넣는다.

cov=∣ 질의 토큰∩1위 표 토큰 ∣∣ 질의 토큰 ∣\text{cov} = \frac{|\,\text{질의 토큰} \cap \text{1위 표 토큰}\,|}{|\,\text{질의 토큰}\,|}

BM25 점수 자체는 문서 집합마다 크기가 달라 문턱으로 쓰지 않았다. 덮기(cov) 문턱과, 1위와 2위의 점수비(margin) 조건을 격자로 쟀다. 질의 전체, RRF 순위 기준이다. C_part는 표 제목 속 가장 긴 낱말 하나만 친 질의 150개를 추가로 만든 것이다.

cov margin 켜짐 C 켜짐 B 켜짐 D 켜짐 E 정답 비율 D Hit@3 C Hit@3 C_part
바로가기 없음 0.327 0.151
0.5 1.0 1.000 0.215 0.347 0 0.308 0.993 0.655
0.67 1.0 0.987 0.040 0.087 0 0.615 0.987 0.640
0.67 1.2 0.813 0.034 0.067 0 0.700 0.840 0.446
0.67 1.5 0.687 0.034 0.033 0 1.000 0.760 0.367
0.67 2.0 0.507 0.034 0.027 0 1.000 0.653 0.331
0.8 1.0 0.987 0.020 0.040 0 0.667 0.987 0.640
1.0 1.0 0.887 0.020 0.033 0 0.600 0.920 0.640

「표 3.6.1」처럼 표 번호를 친 질의는 번호로 바로 찾는다. 운영에 옮기면서, 같은 함수의 조항 번호 조회가 「3.6.1」을 조항 번호로 읽어 표 바로가기 결과를 덮어쓰는 것을 발견했다. 표 번호는 조항 번호 조회의 입력에서 빼고 테스트를 추가했다.

# hybrid.py
labels = set(_CLAUSE_NO.findall(table_route._TABLE_NO.sub(" ", query)))

3.7 H9 표 행 바로가기

H4(표 선형화 줄을 색인 글에 추가)는 BM25에서 D를 0.673에서 0.827로 올렸지만 리랭커 뒤에서는 효과가 없었다. 실험의 리랭커 입력이 조항 앞 2,000자라 뒤에 붙인 선형화 줄이 잘렸을 수 있어서, 선형화 줄을 본문 앞에 두는 H4r을 쟀다. D는 0.500에서 0.767로 올랐지만 E가 20개 중 1개, B가 30개 중 1개 떨어져 규칙상 보류했다.

그래서 표 행을 조항 글에 섞지 않고 H8처럼 별도 색인으로 뒀다. H8이 켜지지 않은 질의를 표의 행 한 줄 「표 제목 | 머리: 값 · 머리: 값」과 비교한다.

행 cov 켜짐 D 켜진 D 중 정답 켜짐 A_ 켜짐 E 운영 조건 Hit@3 A_ 운영 조건 Hit@3 D
바로가기 없음 0.667 0.567
0.67 0.840 0.95 0.053 0 0.633 0.967
0.8 0.807 0.97 0 0 0.667 0.967
1.0 0.733 0.96 0 0 0.667 0.967

0.67은 A_를 30개 중 1개 떨어뜨렸다. 0.8과 1.0은 운영 조건 결과가 같고, 질의 전체의 RRF 기준 D Hit@3이 0.893과 0.833이라 0.8로 정했다(ROW_COV_MIN = 0.8).

3.8 최종 결과

운영 모듈(hybrid.search, table_route.TableIndex)로 잰 값이다. 리랭커가 들어가므로 표본 30개(E는 20개)다.

운영 조건 Hit@3 A A_ B C D E
V0 처음 0.033 0.000 0.333 0.767 0.533 0.900
H1 + H2 0.533 0.500 0.867 0.700 0.500 0.900
+ H7a 0.767 0.667 0.867 0.700 0.500 0.900
+ H8 0.833 0.667 0.867 1.000 0.567 0.900
+ H9 (최종) 0.833 0.667 0.867 1.000 0.967 0.900
처음 V0최종0.000.250.500.751.00A 기호A 기호 처음 V0: 0.033A 기호 최종: 0.8330.033 → 0.833A_ 첨자 표기A_ 첨자 표기 처음 V0: 0.000A_ 첨자 표기 최종: 0.6670.000 → 0.667B 기호+설명어B 기호+설명어 처음 V0: 0.333B 기호+설명어 최종: 0.8670.333 → 0.867C 표 제목C 표 제목 처음 V0: 0.767C 표 제목 최종: 1.0000.767 → 1.000D 표 값D 표 값 처음 V0: 0.533D 표 값 최종: 0.9670.533 → 0.967E 기존 질의E 기존 질의 최종: 0.9000.900 (변화 없음)
그림 5. 운영 조건 Hit@3, 처음(V0)에서 최종(H1+H2+H7a+H8+H9)까지. 리랭커가 들어가므로 유형당 표본 30개(E는 20개).

처음보다 떨어진 유형은 없다. E 0.900은 앞 글의 하이브리드 검색 측정값과 같다. 실험 장치가 운영 파이프라인과 같은 결과를 낸다는 확인이기도 하다.

hybrid.search()는 질의 하나를 세 단계로 처리한다.

%% desc: 기본 검색 결과를 만든 뒤 표 바로가기와 조항 번호 조회가 차례로 결과 맨 앞에 조항을 끼워 넣는다
flowchart LR
    Q[질의] --> S[RRF]
    S --> K{한글 포함}
    K -- 예 --> RR[리랭커]
    K -- 아니오 --> BM[BM25 순위]
    RR --> T[표 바로가기]
    BM --> T
    T --> C[조항 번호 조회]
    C --> OUT[상위 8개]
  1. 기본 검색: dense와 BM25를 RRF로 합친 뒤, 한글이 있으면 리랭커, 없으면 BM25 순위로 상위 8개를 만든다.
  2. 표 바로가기: 「표 N.N.N」 번호가 일치하거나, 표 제목 cov ≥ 2/3이거나, 표 행 cov ≥ 0.8이면 그 표가 붙은 조항을 맨 앞에 넣는다. 세 조건을 이 순서로 검사한다.
  3. 조항 번호 조회: 질의에 4.1.1 형태의 번호가 있으면(표 번호는 제외) 번호가 일치하는 조항을 맨 앞에 넣는다.

바로가기와 조항 번호 조회는 기본 결과를 대체하지 않고 맨 앞에 조항을 끼워 넣는다. 바로가기가 틀려도 기본 결과는 한 칸씩 밀릴 뿐 사라지지 않는다. 3.6절에서 A 질의의 9%에서 표 바로가기가 켜지고 그중 29%만 정답이었는데도 A의 Hit@3이 떨어지지 않은 이유다.


4. 논의

4.1 판정 요약

가설 판정 근거
H1 PUA 복원 채택 (규칙 위반 기록) BM25 A 0.013 → 0.747. C −9.3%p
H2 기호 토큰 채택 (규칙 위반 기록) A_ 0.150 → 0.624
H3 복합명사 탈락 목표 C 0.667 → 0.660
H4 표 선형화 보류 → H9로 대체 BM25 C 0.947이지만 리랭커 뒤 효과 없음
H5 재임베딩 탈락 dense PDF 질의 0.05 이하 그대로, RRF E 0.65 → 0.60
H6 후보 합집합 탈락 정답이 이미 후보 안 96.7%
H7a 기호 질의 BM25 순위 채택 A 0.533 → 0.767, 다른 유형 변화 없음
H8 표 제목 바로가기 채택 RRF C 0.327 → 0.987, E 켜짐 0%
H9 표 행 바로가기 채택 운영 D 0.567 → 0.967, E 켜짐 0%

4.2 실험 과정의 오류

4.3 한계


5. 결론


부록 A. 원시 데이터

본문의 모든 수치는 아래 파일에서 나왔다. 원본 PDF는 저작물이라 포함하지 않았고, 임베딩 캐시(약 10MB)는 다시 계산할 수 있어 제외했다. 질의 문자열은 PDF의 기호 정의 줄, 표 제목, 표 행에서 뽑은 짧은 조각이다.

exp_result*.json의 구조는 다음과 같다.

variants[변형][방식][유형] = {n, Hit@1, Hit@3, Hit@5, MRR}
    방식: bm25 · dense · hybrid · hybrid@sub · hybrid+rerank · H7a/H7b/H7c (실행 4)
per_query[변형][방식][유형] = [질의마다 정답 첫 순위, 0 = 상위 20 밖]
    리랭커 방식은 표본 질의만, 질의 번호 순
pre[변형][유형] = {empty, matched, pool_rrf20, pool_union}
queries = [{type, query}] × 752

측정 조건은 코퍼스 1,287개(PDF 481 + KDS 웹 806), 임베딩 paraphrase-multilingual-mpnet-base-v2, 리랭커 jina-reranker-v2-base-multilingual, 후보 20개, RRF k=60k = 60, 난수 시드 7이다.

참고

  1. [1] Private-Use Characters, Noncharacters & Sentinels FAQ — Unicode
  2. [2] ISO 32000-1:2008 Document management, Portable document format, 9.10 Extraction of Text Content — Adobe
  3. [3] fontTools Documentation
  4. [4] Page.find_tables — PyMuPDF Documentation
  5. [5] kiwipiepy — GitHub
  6. [6] Robertson, Zaragoza — The Probabilistic Relevance Framework: BM25 and Beyond, Foundations and Trends in IR 2009
  7. [7] Cormack, Clarke, Büttcher — Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009
  8. [8] Thakur et al. — BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models, NeurIPS 2021

관련 글