LangChain text splitter, 마크다운 코드가 깨질 때

3줄 요약
- LangChain
MarkdownHeaderTextSplitter는 코드블록 안의 들여쓰기까지 지웁니다. 제 원고 45편으로 재현했더니 파이썬 코드 84개 중 67개가 문법 오류 상태로 인덱스에 들어갔습니다. - 검색 자체는 멀쩡합니다. 질의 12개로 확인해 보니 어떤 분할기를 써도 정답 문서를 1등으로 찾아냈습니다. 망가지는 것은 찾은 뒤 모델에게 건네는 그 조각입니다.
- 코드가 든 문서를 인덱싱하고 있다면 오늘
ast.parse한 줄짜리 검사부터 돌려 보시면 됩니다. 아래 스크립트를 그대로 쓰시면 됩니다.
45편, 51만 5,741자. 표 242개, 코드블록 213개. 이것을 청킹 방식만 바꿔 가며 다섯 번 돌린 표를 보다가 한 칸에서 눈이 멈췄습니다. 파이썬 코드블록 84개 중 파싱에 성공한 것이 17개. 20.2%였습니다.
같은 표의 바로 윗줄, RecursiveCharacterTextSplitter는 84개 전부 통과였습니다. 아래 줄의 실험용 분할기도 84개 전부였습니다. 문서 구조를 가장 잘 이해한다고 알려진 분할기 하나만 20.2%였습니다.
1. 재현은 네 줄이면 끝납니다
먼저 규모를 줄여 보겠습니다. 마크다운 문서에 파이썬 함수 하나만 넣고 분할기를 통과시킵니다.
# langchain-text-splitters 1.1.2 · 2026-09-08 확인
from langchain_text_splitters import MarkdownHeaderTextSplitter
sample = "## 예시\n\n```python\ndef f():\n return 1\n```\n"
h = MarkdownHeaderTextSplitter([("##", "h2")], strip_headers=False)
print(repr(h.split_text(sample)[0].page_content))
원본 코드는 def f(): 다음 줄에 공백 네 칸이 있습니다. 통과 후에는 이렇게 됩니다.
원본 : 'def f():\n return 1\n'
분할 후 : 'def f():\nreturn 1\n'
return 1 앞의 네 칸이 사라졌습니다. 파이썬에서 이것은 주석 하나가 빠진 정도의 문제가 아니라 IndentationError입니다. 실행할 수도 없고, 모델에게 "이 코드를 참고해서 답하라"고 건넸을 때 모델이 읽는 것도 이 망가진 쪽입니다.
원인은 소스 세 줄에 있습니다
설치본을 열어 보면 바로 보입니다. split_text는 줄 단위로 순회하면서 맨 처음에 stripped_line = line.strip()을 만들고, 그다음부터는 원본 line을 쓰지 않습니다. 코드블록 안이라는 것을 인지하는 분기(if in_code_block:)까지 있는데, 그 분기가 하는 일이 current_content.append(stripped_line)입니다. 코드블록임을 알면서도 앞뒤 공백을 턴 줄을 담습니다.
# langchain_text_splitters/markdown.py · split_text 내부 (설치본에서 발췌)
stripped_line = line.strip() # 30행 부근
...
if in_code_block:
current_content.append(stripped_line) # 47행 부근 — 코드블록 안에서도 strip된 줄
코드펜스 인식 자체는 정확합니다. ```으로 열고 닫는 것도, ~~~도, 코드블록 안의 #을 헤더로 오인하지 않는 것도 확인했습니다. 잘라야 할 곳을 잘못 잡는 문제가 아니라, 잘라 담는 과정에서 공백을 버리는 문제입니다.
이미 2024년에 올라온 이슈입니다
langchain 이슈 #20823이 2024년 4월 24일에 같은 증상을 보고했고, 제보자가 수정 코드까지 적어 두었습니다. 라벨은 bug가 붙었지만 상태는 Closed as not planned입니다. 고치지 않기로 하고 닫은 것입니다.
그래서 2026년 4월 16일에 올라온 최신 버전 1.1.2에서도 그대로입니다. 공식 문서는 대신 안내 문구를 넣었습니다. 마크다운 분할기 페이지에 "기본 MarkdownHeaderTextSplitter는 공백과 개행을 지웁니다. 원본 서식을 보존하려면 ExperimentalMarkdownSyntaxTextSplitter를 보십시오"라는 주석이 붙어 있습니다. 버그가 아니라 사양이라는 쪽으로 정리된 셈입니다.
문제는 이 주석이 문서 중간에 있고, 청킹 예제 코드에는 아무 표시가 없다는 점입니다. 예제를 복사해 온 사람은 자기 인덱스에서 코드가 깨졌다는 사실을 알 방법이 없습니다.
2. 45편으로 세어 본 실제 피해
최소 예제로 재현했으니 규모를 키웠습니다. 코퍼스는 제 블로그 원고 45편입니다. 2026년 8월 25일부터 9월 7일까지 쓴 것이고, 한국어 산문에 표와 코드블록이 섞인 전형적인 기술 문서 형태입니다. QA 팀이 쌓아 둔 테스트 가이드나 트러블슈팅 문서와 구조가 비슷합니다.
판정 방법은 단순합니다. 원본에서 ```python 블록을 뽑아 ast.parse로 검증하고, 청킹을 거친 결과에서 같은 블록을 찾아 다시 ast.parse를 겁니다. 원본 84개는 모두 통과합니다. 실행되는 코드만 올리기 때문입니다.
| 분할 전략 | 청크 수 | 파이썬 파싱 성공 | 표 온전 보존 | 헤더 문맥 |
|---|---|---|---|---|
RecursiveCharacterTextSplitter |
611 | 84/84 (100%) | 240/242 (99.2%) | 없음 |
MarkdownHeaderTextSplitter → Recursive |
959 | 17/84 (20.2%) | 234/242 (96.7%) | 있음 |
ExperimentalMarkdownSyntaxTextSplitter → Recursive |
1,430 | 84/84 (100%) | 240/242 (99.2%) | 있음 |
chunk_size=1000, chunk_overlap=100 조건입니다. 파이썬 블록 84개 중 들여쓰기가 있는 것이 69개였으니, 들여쓰기가 아예 없는 15개와 우연히 살아남은 2개를 빼면 거의 전멸입니다.
검색은 멀쩡합니다 — 그래서 늦게 발견됩니다
여기서 한 가지를 더 확인해야 했습니다. 이 손상이 검색 단계에서 드러나는지입니다. 드러난다면 평가 지표가 떨어질 테니 누구든 알아챕니다.
BM25로 인덱스를 만들고 질의 12개를 던졌습니다. "옵시디언 아이패드 동기화 방법", "자동차세 연납 할인율", "MCP 서버 connection closed 원인" 같은, 실제로 검색창에 칠 법한 문장입니다. 한국어 형태소 분석기 없이 어절과 음절 2-그램만 썼습니다.
| 분할 전략 | hit@1 | hit@3 | MRR |
|---|---|---|---|
| Character 고정 | 12/12 | 12/12 | 1.000 |
| Recursive 기본 | 12/12 | 12/12 | 1.000 |
| MarkdownHeader → Recursive | 12/12 | 12/12 | 1.000 |
| Experimental → Recursive | 12/12 | 12/12 | 1.000 |
전부 만점입니다. 문서 단위로 정답을 찾는 일에서는 청킹 방식이 아무 차이를 만들지 않았습니다. 질의에 고유명사가 들어가 있으면 조각이 어떻게 잘렸든 그 조각 안에 그 단어가 남아 있기 때문입니다.
그러니 검색 평가만 보고 있으면 이 문제는 영원히 보이지 않습니다. 망가지는 곳은 검색 다음 단계, 찾아낸 청크를 모델의 컨텍스트에 붙여 넣는 지점입니다. 모델은 들여쓰기가 무너진 코드를 받고, 그것을 근거로 답을 만듭니다. MCP 서버가 죽었을 때 진짜 원인이 stderr에만 남던 것과 성격이 같습니다. 파이프라인은 성공을 보고하고, 손상은 아무도 보지 않는 곳에 쌓입니다.
제목도 같이 사라집니다
같은 분할기에 함정이 하나 더 있습니다. strip_headers의 기본값이 True라서, 분할 기준으로 삼은 헤더 줄이 청크 본문에서 제거됩니다.
| 설정 | 청크 총 문자 | 원본 대비 | 제목으로 시작하는 청크 |
|---|---|---|---|
strip_headers=True (기본) |
487,963자 | 94.6% | 0개 |
strip_headers=False |
511,390자 | 99.2% | 769개 |
원본의 5.4%가 사라집니다. 그 5.4%가 하필 문서에서 가장 정보 밀도가 높은 부분, 즉 제목입니다. 헤더는 metadata에 남지만 임베딩 대상은 page_content이므로 벡터에는 들어가지 않습니다. "Selenium Grid 병렬 세션 수" 아래 문단에서 제목이 빠지면, 남는 것은 주어 없이 숫자만 늘어놓은 문단입니다.
메타데이터에 남아 있으니 검색 뒤에 되붙이면 되지 않느냐고 할 수 있습니다. 실제로 그렇게 쓰는 곳도 많습니다. 다만 그것은 모델에게 건네는 문맥을 복원할 뿐이고, 벡터를 만드는 단계에서 이미 제목 없이 임베딩된 것은 되돌릴 수 없습니다. 질의가 제목의 단어로 들어올 때 그 청크가 후보에 오르지 못합니다. strip_headers=False 한 줄로 끝나는 일이니 되붙이는 후처리를 짜기 전에 이것부터 확인하는 편이 낫습니다.
표도 8개가 잘렸습니다. 242개 중 234개만 온전히 남았고, 잘린 8개는 헤더 행과 값 행이 다른 청크로 갈라진 경우입니다. 표는 첫 줄에 항목 이름이 있고 아래에 값이 오는 구조라, 갈라지면 아래쪽 조각은 숫자만 남은 의미 없는 덩어리가 됩니다. 비율로는 3.3%라 코드만큼 심각하지 않지만, 판단 근거를 표로 정리해 둔 문서라면 하필 그 표가 잘릴 수 있습니다.
3. 대안 세 가지와 각각의 값
여기까지가 계산입니다. 이제 무엇을 고를지의 문제인데, 답은 문서에 코드가 있느냐로 갈립니다.
도구별 비교
| 항목 | Recursive 단독 | MarkdownHeader | Experimental |
|---|---|---|---|
| 도입 난이도 | 낮음. 인자 두 개 | 낮음 | 낮음. 클래스명만 교체 |
| 유지보수 비용 | 낮음 | 낮음 | Experimental 접두. API 변경 가능성을 감수 |
| 팀 학습곡선 | 없음 | 없음 | 낮음. 메타데이터 키가 하나 늘어남 |
| 헤더 문맥 | 잃음 | 유지 | 유지 |
| 코드 보존 | 온전 | 파손 | 온전 |
| 청크 수 (45편) | 611 | 959 | 1,430 |
ExperimentalMarkdownSyntaxTextSplitter는 코드블록을 별도 청크로 분리하고 metadata에 Code: python 같은 표시를 붙입니다. 원본 서식을 그대로 유지하는 것이 설계 목표이므로 들여쓰기가 남습니다. 대신 청크가 잘게 쪼개집니다. 45편에서 1,376개가 나왔고 그중 258개(18.8%)가 100자 미만이었습니다. 코드 청크 중앙값이 397자, 산문 청크 중앙값이 281자입니다.
청크가 잘아지면 top-k를 그만큼 늘려야 같은 양의 문맥이 모입니다. 임베딩 호출 수도 2.3배가 됩니다. 총 문자량은 오히려 조금 줄지만(99.4%), 벡터DB 요금은 대개 벡터 개수로 매겨지므로 여기가 실제 비용입니다.
크기와 겹침을 정할 때 보는 두 숫자
chunk_size와 chunk_overlap도 같이 재 두었습니다. Recursive 기준입니다.
| chunk_size | 청크 수 | 중앙값 | 코드펜스 깨짐 | 문장 중간에서 끝난 청크 |
|---|---|---|---|---|
| 400 | 1,781 | 313자 | 258 | 520 |
| 600 | 1,089 | 514자 | 180 | 320 |
| 800 | 790 | 703자 | 144 | 226 |
| 1,000 | 611 | 912자 | 102 | 177 |
| 1,500 | 406 | 1,393자 | 72 | 132 |
| 2,000 | 304 | 1,890자 | 65 | 86 |
여기서 "코드펜스 깨짐"은 앞의 파싱 실패와 다른 지표입니다. 한 청크 안에서 ```이 홀수 개로 끝난, 즉 코드블록 중간에서 경계가 그어진 경우입니다. 크기를 키우면 줄어들지만 청크당 절대량이 늘어 비율은 오히려 올라갑니다. 코드블록이 긴 문서라면 크기 조정으로는 해결되지 않고 분할기를 바꿔야 합니다.
겹침은 저장 비용에 직결됩니다. chunk_size=1000 기준입니다.
| chunk_overlap | 청크 수 | 총 문자 | 원본 대비 |
|---|---|---|---|
| 0 | 602 | 514,574 | 99.8% |
| 100 | 611 | 526,538 | 102.1% |
| 200 | 668 | 574,167 | 111.3% |
| 300 | 736 | 638,805 | 123.9% |
300으로 올리면 같은 문서를 24% 더 저장하고 24% 더 임베딩합니다. 검색이 안 될 때 겹침부터 올리는 손이 나가기 쉬운데, 그 전에 무엇이 안 되는지부터 봐야 합니다. 문장이 잘려서 못 찾는 것인지, 애초에 청크 안에 답이 없는 것인지는 다른 문제입니다.
4. QA 문서에 적용한다면
우리 팀 상황에 대입해 보겠습니다. QA 4~6명 규모에서 사내 문서를 검색 가능하게 만들려는 초기 단계라면, 인덱싱 대상은 대개 테스트 가이드, 트러블슈팅 노트, API 명세, CI 설정 설명입니다. 전부 코드블록과 표가 절반 이상을 차지하는 문서들입니다.
무엇을 먼저 할 것인가
인덱스를 새로 만들기 전에, 이미 만들어 둔 인덱스가 있다면 그것부터 검사하십시오. 아래 스크립트는 문서 폴더를 받아 청킹 전후의 파이썬 코드 파싱 성공률을 세어 줍니다. 코드 언어가 다르면 ast.parse 자리를 그 언어의 파서로 바꾸면 됩니다. YAML이면 yaml.safe_load, JSON이면 json.loads입니다.
# 청킹 전후로 파이썬 코드블록이 여전히 파싱되는지 세는 검사입니다
# 사용법: python3 check_chunking.py <문서폴더>
import sys, glob, re, ast
from langchain_text_splitters import (MarkdownHeaderTextSplitter,
RecursiveCharacterTextSplitter)
FILES = glob.glob(f"{sys.argv[1]}/**/*.md", recursive=True)
R = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
H = MarkdownHeaderTextSplitter([("#", "h1"), ("##", "h2"), ("###", "h3")],
strip_headers=False)
def blocks(t): # 파이썬 코드블록만 뽑습니다
return re.findall(r"```(?:python|py)\n(.*?)```", t, re.S)
ok = tot = 0
for f in FILES:
src = open(f, encoding="utf-8").read()
after = "\n".join(x.page_content for x in H.split_text(src))
for b in blocks(src):
try: ast.parse(b) # 원본이 애초에 깨져 있으면 세지 않습니다
except SyntaxError: continue
tot += 1
for b2 in blocks(after): # 첫 줄이 같은 블록을 짝지어 대조합니다
if b2.split("\n")[0].strip() == b.split("\n")[0].strip():
try: ast.parse(b2); ok += 1
except SyntaxError: pass
break
print(f"청킹 후 파싱 성공 {ok}/{tot}")
성공률이 90%를 밑돌면 분할기를 바꿀 이유가 생긴 것입니다. 100%가 나오면 그 문서에는 들여쓰기가 있는 코드가 거의 없다는 뜻이니 지금 방식을 유지하시면 됩니다.
바꾼다면 어느 쪽으로
문서에 코드가 없다면 MarkdownHeaderTextSplitter를 그대로 쓰되 strip_headers=False만 넣으십시오. 제목이 청크에 남는 것만으로 검색 품질이 달라집니다. 그리고 공식 문서가 권하는 대로 split_text가 아니라 split_documents를 쓰십시오. 섹션별 메타데이터가 청크에 따라붙고, 겹침이 섹션 안에서만 적용됩니다.
코드가 있다면 ExperimentalMarkdownSyntaxTextSplitter로 가거나, 헤더 문맥을 포기하고 Recursive 단독으로 가는 두 갈래입니다. 저는 전자를 택했습니다. Experimental이라는 이름이 걸리긴 하지만, 지금 손에 있는 것은 이름이 안정적인 대신 코드를 80% 망가뜨리는 도구와 이름이 불안한 대신 망가뜨리지 않는 도구입니다. 이름은 나중에 고칠 수 있고 인덱스는 다시 만들어야 합니다.
세 번째 길도 있습니다. 헤더 위치를 원문 오프셋으로 직접 계산해 원문 그대로 잘라 내는 것인데, 30줄이면 짜입니다. 다만 제가 짜서 돌려 보니 파이썬 파싱이 67/84(79.8%)에 그쳤습니다. 헤더 접두를 붙이느라 청크가 길어져 긴 코드블록이 크기 제한에 걸린 탓입니다. 직접 짜는 쪽이 더 안전할 것 같지만, 재 보기 전에는 알 수 없습니다.
지금 확인할 것
□ 인덱싱 대상 문서에 들여쓰기가 있는 코드블록이 있는가
□ 위 스크립트로 청킹 후 파싱 성공률을 쟀는가
□ strip_headers 값을 명시적으로 지정했는가 (기본값은 True입니다)
□ split_text 대신 split_documents를 쓰고 있는가
□ chunk_overlap을 올리기 전에 무엇이 안 되는지 확인했는가
□ 분할기를 바꾼다면 인덱스를 다시 만들 시간을 잡아 두었는가
마지막 줄이 실무에서 제일 걸립니다. 분할기 교체는 코드 한 줄이지만 인덱스 재생성은 임베딩 비용과 시간이 듭니다. 그래서 문서를 많이 넣기 전에, 가급적 첫 인덱스를 만들기 전에 재 보는 편이 쌉니다.
5. 이 실측의 한계
코퍼스가 제 블로그 원고 45편 하나뿐입니다. 한국어 산문에 표와 코드가 섞인 특정한 형태이고, 파이썬 블록 84개 중 들여쓰기가 있는 것이 69개(82%)로 비율이 높은 편입니다. 코드가 적은 문서에서는 피해가 훨씬 작을 것입니다.
BM25 검색 실험은 형태소 분석기 없이 어절과 음절 2-그램으로만 돌렸고, 질의 12개는 제가 만든 것이라 표본이 작습니다. 임베딩 기반 검색에서도 같은 결론이 나오는지는 확인하지 못했습니다. 다만 이 실험의 결론은 "청킹이 검색을 개선한다"가 아니라 "검색 지표로는 이 손상이 안 잡힌다"이므로, 방향이 뒤집힐 여지는 크지 않다고 봅니다.
파싱 판정은 코드블록의 첫 줄이 같은 것을 짝지어 대조하는 방식입니다. 첫 줄이 동일한 블록이 한 문서에 여러 개 있으면 오판할 수 있습니다. 45편에서는 그런 경우가 없었습니다.
버전은 langchain-text-splitters 1.1.2 하나로만 확인했습니다. PyPI 업로드 시각은 2026년 4월 16일 14시 20분(UTC), 한국 시간 같은 날 23시 20분입니다. 이 글을 쓴 2026년 9월 8일 기준 최신입니다. 이슈가 not planned로 닫혔으니 다음 버전에서 갑자기 고쳐질 가능성은 낮지만, 확인된 근거는 없습니다.
같은 코퍼스를 다른 각도에서 센 기록이 하나 더 있습니다. 프롬프트에 예시로 적은 문구가 원고에 그대로 굳는지 세어 본 글인데, 그때는 문단 첫머리를 셌고 이번에는 코드블록을 셌습니다. 자기 산출물을 코퍼스로 두면 이런 검사를 언제든 다시 돌릴 수 있다는 점이 편합니다.
댓글
댓글 쓰기