코드베이스 지식 베이스 시리즈 (04): 세 가지 청킹 전략 — AST의 정밀함이 손해를 보는가?
요약
코드베이스 RAG를 위한 세 가지 청킹 전략(고정 줄 수, 파일 수준, AST 기반)의 성능을 비교 분석합니다. 실험 결과, 직관과 달리 AST 기반 함수 단위 청킹보다 고정 줄 수 분할 방식이 더 높은 검색 성능을 기록했습니다.
핵심 포인트
- AST 기반 함수 단위 청킹이 반드시 최선은 아님을 실험으로 증명
- 고정 줄 수 분할 방식이 특정 실험 데이터셋에서 가장 높은 R@5 점수 기록
- 청킹 전략 선택 시 코드의 의미론적 단위와 임베딩 모델의 특성을 고려해야 함
- 함수 파편화가 검색 성능에 미치는 영향에 대한 새로운 시각 제시
거의 모든 사람이 저지르는 실수
벡터 모델(vector model)에 입력하기 위해서는 코드를 청크(chunk)로 나누어야 합니다. 가장 "전문적인" 방법은 무엇일까요?
대부분의 엔지니어는 즉각적으로 대답합니다. AST (abstract syntax tree) 기반 청킹, 즉 청크 하나당 함수 하나를 할당하는 방식이라고 말이죠. 그 논리는 매우 탄탄합니다. 함수는 가장 자연스러운 의미론적 단위이며, AST는 의미론적 경계와 정확히 일치하고, 함수를 중간에 자르는 일도 절대 없기 때문입니다. 이에 비하면 고정된 줄 수(예: 매 20줄마다)로 나누는 방식은 조잡하고 멍청해 보입니다. 함수를 파편화하기 때문입니다.
반박할 수 없을 것처럼 들립니다. 하지만 실험 데이터는 그러한 직관을 정면으로 반박합니다.
동일한 266줄의 Python 코드에 대해 세 가지 청킹 전략을 실행했습니다. 결과는 다음과 같습니다: AST 함수 수준 청킹이 가장 낮은 점수를 기록했습니다. 반면 "조잡하고 멍청한" 고정 줄 수 분할 방식은 완벽한 1.000 점을 기록했습니다.
이것은 무작위 노이즈가 아닙니다. 그 이면에는 파고들 가치가 있는 메커니즘이 존재합니다.
세 가지 전략
데이터셋: auth, database, cache, payment, notification 등 5개 모듈을 포함하는 266줄의 Python 코드 — 총 27개의 함수. 임베딩 모델(Embedding model)은 전체 과정에서 bge-large-zh-v1.5를 사용하였으며, 평가는 12개의 자연어 질의(natural-language queries)를 사용했습니다.
세 가지 분할 방식:
전략 1: 고정 줄 수 (각 20줄, overlap=3)
코드 구조를 무시하고 매 20줄마다 자르되, 경계 정보의 손실을 방지하기 위해 인접한 청크 간에 3줄의 중첩(overlap)을 둡니다.
Chunk 1: lines 1-20
Chunk 2: lines 18-37 ← 이전 청크와 3줄 중첩
Chunk 3: lines 35-54
...
전략 2: 파일 수준 (파일 전체를 하나의 청크로)
무차별 대입 방식입니다. 파일 전체가 하나의 청크가 됩니다.
Chunk 1: lines 1-266 ← 모든 내용이 한데 뭉쳐짐
전략 3: AST 함수 수준 (함수당 하나의 청크)
Python의 ast 모듈을 사용하여 구문 트리(syntax tree)를 파싱하고, 함수 정의를 기준으로 정확하게 분할합니다.
Chunk 1: def validate_jwt_token(...) lines 9-18
Chunk 2: def hash_password(...) lines 20-28
Chunk 3: def create_payment_intent(...) ...
...
먼저, 고정 라인 청킹 (fixed-line chunking)이 정확히 얼마나 "조잡한지" 살펴보겠습니다. validate_jwt_token (9-18행, 총 10행)을 예로 들어보겠습니다:
Function 'validate_jwt_token' (lines 9-18, 10 lines)
Splits across 2 chunks:
Chunk lines 1-20: contains lines 9-18 (10/10 lines of function)
...
보이시나요? 함수의 마지막 줄(18행)이 다음 청크로 분리되었습니다. 이것이 바로 고정 라인 청킹이 비판받는 이유입니다. 함수의 경계가 어디인지 알지 못하며, 원하는 곳에서 마음대로 잘라버립니다.
당연히 이러한 파편화는 검색 (retrieval) 성능을 저하시켜야 합니다. AST는 모든 함수를 온전하게 유지하므로, 논쟁의 여지 없이 승리해야 마땅합니다.
결과 (The Results)
Strategy Chunks AvgLen R@3 R@5 vs AST R@5
──────────────────────────── ─────── ─────── ─────── ─────── ──────────
1_fixed_lines_20 16 755 0.917 1.000 +0.042
...
역전 현상이 발생했습니다:
- Fixed-line: Recall@5 = 1.000 (완벽)
- File-level: Recall@5 = 1.000 (완벽)
- AST function-level: Recall@5 = 0.958 (가장 낮음)
함수를 조각내는 "조잡한 방법"이 승리했습니다. 의미적 경계 (semantic boundaries)에 정밀하게 맞춘 "전문적인 방법"이 패배했습니다.
12개의 쿼리 중 11개는 세 가지 전략 모두에서 완벽한 1.000 점수를 기록했습니다. 유일한 차이는 8번 쿼리였습니다:
Query Fixed File AST
────────────────────────────────────────────────── ────── ────── ──────
process payment and create Stripe charge 1.00 1.00 0.50 ←
...
단 하나의 쿼리가 결과를 결정지었습니다. 이 쿼리에서 AST는 0.50을 기록한 반면, fixed-line과 file-level은 모두 1.00을 기록했습니다.
이 역전 현상을 이해하려면, 이 단일 쿼리에서 정확히 어떤 일이 일어났는지 이해해야 합니다.
무임승차 효과 (The Free-Rider Effect)
쿼리는 process payment and create Stripe charge입니다.
이 쿼리의 정답 (ground truth)에는 두 개의 관련 함수인 create_payment_intent와 calculate_order_total이 포함되어 있습니다.
create_payment_intent는stripe.PaymentIntent및create()와 같은 토큰을 포함하고 있으며, 이는 "Stripe charge"와 밀접하게 정렬되어 있습니다. 세 가지 전략 모두 이를 쉽게 찾아냅니다.calculate_order_total이 문제입니다. 이 함수의 역할은 "item prices 합산, discount 적용, tax 계산"입니다. 함수의 시그니처(signature)와 본문은sum,discount,tax로 가득 차 있으며, "payment"나 "Stripe"의 흔적은 전혀 없습니다. 벡터 공간(vector space)에서 이 함수는 "create Stripe charge"라는 쿼리로부터 멀리 떨어져 있습니다.
여기에 결정적인 차이가 있습니다:
AST 청킹 (AST chunking): calculate_order_total은 별도의 고립된 청크로 잘려 벡터 공간에 홀로 존재합니다. 쿼리와 멀리 떨어져 있기 때문에 검색되지 않습니다. AST 점수는 0.50 (2개 중 1개 적중)입니다.
고정 라인 / 파일 수준 청킹 (Fixed-line / file-level chunking): calculate_order_total과 create_payment_intent가 우연히 동일한 청크에 포함되었습니다 (소스 코드에서 서로 인접해 있기 때문입니다). 쿼리가 create_payment_intent를 적중하면 청크 전체가 검색되며, calculate_order_total도 함께 딸려 오게 됩니다 — 이것이 바로 **무임승차 효과 (free-rider effect)**입니다.
[IMG:1] (벡터 공간 다이어그램. 왼쪽, AST 청킹: calculate_order_total이 쿼리에서 멀리 떨어진 고립된 점으로 존재하며 검색되지 않음. 오른쪽, 파일/고정 청킹: calculate_order_total과 create_payment_intent가 하나의 박스로 묶여 있음; 쿼리가 박스를 적중하면 두 함수가 모두 추출됨.)
비유를 들자면:
AST 청킹은 모든 품목이 각자의 바코드를 가진 개별 선반에 놓여 있는 슈퍼마켓과 같습니다. "맥주"를 검색하면 맥주만 찾게 됩니다.
큰 청크는 맥주와 기저귀를 하나의 프로모션 팩으로 묶어 놓은 것과 같습니다. "맥주"를 검색하여 해당 팩을 적중하면, 기저귀를 검색하지 않았음에도 기저귀가 장바구니에 함께 담기게 됩니다.
이 266라인의 작은 데이터셋에서는 "묶음(bundles)"이 의미론적으로 인접한 함수들을 우연히 올바르게 쌍으로 묶어주었기 때문에, 원래라면 찾을 수 없었을 calculate_order_total이 무임승차할 수 있었습니다. AST는 너무 깔끔하게 잘라버림으로써 이러한 "우연한 승리"를 놓친 것입니다.
이 역전 현상의 진실은 이렇습니다: AST 청킹이 더 나쁜 것이 아니라, 큰 청크들이 무임승차 효과를 통해 특정 쿼리에서 운이 좋았던 것입니다.
Fixed-line 방식에 성급히 승리를 안겨주지 마세요
"앞으로는 그냥 fixed-line/file-level 방식을 쓰면 끝이다"라고 결론 내리고 싶은 유혹이 들 것입니다. 하지만 잠깐 기다리세요. 그 결론은 완전히 틀렸습니다. 그 이유는 표에서 편리하게 생략된 한 열(column)에 숨겨져 있습니다.
File-level 청킹은 완벽한 Recall@5를 기록하지만, 정밀도(Precision)는 0입니다.
File-level 방식이 어떻게 작동하는지 생각해 보십시오: 전체 코드베이스가 단 1개의 청크(chunk)가 됩니다. 어떤 쿼리를 던지든 동일한 청크 — 즉 266줄 전체 — 를 검색해 옵니다. Recall@5는 "상위 5개 결과 안에 정답이 있는가"를 측정하는데, 모든 함수를 포함하는 청크가 단 하나뿐이므로 당연히 항상 정답을 맞히게 됩니다.
하지만 그것이 무슨 소용일까요? "JWT 검증은 어디에 있는가"라고 물으면, 시스템은 파일 전체를 던져줍니다. 당신은 여전히 266줄 속에서 직접 찾아 헤매야 합니다. 검색 시스템(retrieval system)의 핵심은 국소화(localization)이며, file-level 청킹은 아무것도 국소화하지 못합니다. 이 방식의 완벽한 점수는 일종의 부정행위입니다.
Fixed-line 방식 또한 그리 달콤하지 않습니다. 이 데이터셋의 함수들은 짧고 밀도가 높으며, 평균 길이가 작습니다. 20줄의 청크와 3줄의 오버랩(overlap)이 우연히 많은 함수를 하나의 청크에 통째로 담아냈고, 심지어 인접한 함수들이 무임승차할 수 있게 만들었습니다. 이것은 데이터셋의 특성과 청크 크기가 맞물린 우연일 뿐, fixed-line 청킹의 미덕이 아닙니다.
실제 대규모 프로젝트에서 시도해 보십시오: 함수들은 보통 100줄이 넘어가며, 20줄짜리 청크로는 함수 절반조차 담을 수 없습니다. 도입부 데모에서 보여준 validate_jwt_token의 파편화 현상이 표준이 될 것입니다. 즉, 함수 시그니처(signature)는 한 청크에, 핵심 로직은 다른 청크에, 예외 처리(exception handling)는 세 번째 청크에 담겨 항상 함수의 파편들을 검색하게 됩니다. 그 시점에서 fixed-line의 Recall은 무너집니다.
이 실험의 규모(266줄)는 너무 작습니다. 이 규모는 정밀도(precision)의 차이를 가려버려, 두 "부정행위자"가 마치 승리자인 것처럼 보이게 만듭니다.
각 전략이 실제로 적합한 경우
규모(scale)를 고려하면 상황은 명확해집니다:
소규모 코드베이스 (< 1,000행)
파일 단위 (File-level) 방식도 통용될 수 있습니다. 어차피 LLM의 컨텍스트 윈도우 (context window)에 모두 밀어 넣는 비용이 저렴하기 때문입니다. 하지만 이 방식의 정밀도 (precision)가 제로에 가깝다는 점은 분명히 해야 합니다. 본질적으로 "검색 (retrieval) 작업을 LLM에게 떠넘기는 것"과 같습니다. "모델에게 전체를 통째로 넘겨도 될 만큼 코드가 충분히 작은" 시나리오에 적합합니다.
중규모 코드베이스 (1K - 10K행)
고정 라인 (Fixed-line) 방식에 적절한 중첩 (overlap)을 사용하는 것만으로도 보통 충분하며, 구현이 가장 간단합니다 (AST 파싱이 필요 없음). 핵심은 청크 크기 (chunk size)를 평균 함수 길이에 맞추는 것입니다. 이상적으로는 대부분의 함수가 하나의 청크에 온전히 들어가도록 하고, 중첩 (overlap)은 경계면의 안전망 역할을 하도록 설정해야 합니다.
대규모 코드베이스 (> 10K행)
AST 함수 단위 (AST function-level) 방식의 정밀도 이점이 마침내 빛을 발하는 지점입니다. 코드가 컨텍스트에 담기기에 너무 클 때는 "정확히 이 함수"를 짚어내야 하며, 모든 AST 청크는 완전하고 독립적이며 바로 이동 가능한 (jump-to-able) 의미론적 단위 (semantic unit)가 됩니다. 이번 실험의 데이터셋은 너무 작아서 AST의 핵심 강점을 완전히 가리고 있습니다.
한 줄로 요약하자면: 재현율 (Recall)은 화려해 보일 수 있지만, 실제 운영 환경 (production)에서는 정밀도 (precision)가 사용자 경험을 결정하는 지표이며, 정밀도야말로 이 작은 실험으로는 측정할 수 없는 부분입니다.
베스트 프랙티스 (Best Practice): AST + 메타데이터 (Metadata) + 호출 그래프 (Call Graph)
그렇다면 대규모 프로젝트를 위한 정답은 무엇일까요? 세 가지 전략 중 하나를 선택하는 것이 아닙니다. 정밀도를 위해 AST 청킹 (AST chunking)을 사용하되, 메타데이터 (metadata)와 호출 그래프 (call graph)를 통해 무임승차 효과 (free-rider effect)를 능동적으로 복원하는 것입니다.
AST가 놓쳤던 부분을 떠올려 보십시오. AST는 calculate_order_total을 하나의 고립된 섬으로 잘라버렸고, "이것이 create_payment_intent와 동일한 결제 흐름 내에 있다"는 문맥 (context)을 버렸습니다. 큰 청크들은 물리적 인접성 덕분에 운 좋게 그 문맥을 보존했습니다. 우리는 이를 명시적으로 다시 추가할 수 있습니다:
{
"content": func_body, # 임베딩 (embedding)에 입력될 AST로부터 추출한 완전한 함수 본문
"metadata": {
...
called_by 엣지 (edge)를 활용하면, 검색 흐름 (retrieval flow)을 다음과 같이 복구할 수 있습니다:
- 쿼리(Query)가
process payment and create Stripe charge일 때, 벡터 검색(vector search)이create_payment_intent를 찾아냅니다. - 호출 그래프(call graph)를 따라가면,
create_payment_intent가calculate_order_total을 호출한다는 것을 발견합니다. calculate_order_total을 함께 호출(recall)합니다.
이는 AST의 정밀함을 희생하지 않으면서, 큰 청크(large chunks)가 운 좋게 얻었던 '무임승차 효과(free-rider effect)'를 **호출 그래프의 결정론적 구조적 엣지(deterministic structural edge)**를 통해 능동적으로 재현하는 방식입니다. 의미론적으로는 관련이 있지만 벡터 공간상에서는 멀리 떨어져 있는 함수들이 호출 관계를 통해 다시 불러와집니다.
이 지점이 바로 벡터 검색(vector retrieval)이 한계에 부딪히는 지점입니다. calculate_order_total과 "Stripe charge" 사이의 의미론적 간극(semantic gap)은 그 어떤 임베딩(embedding) 전략으로도 메울 수 없습니다(이는 이미 03편 기사에서 검증된 결론입니다). 이 간극을 넘어서기 위해서는 코드 자체의 구조적 정보, 즉 호출 그래프(call graph), 임포트(imports), 모듈 멤버십(module membership)이 필요합니다.
그리고 이것이 바로 이 시리즈의 다음 기사 주제입니다: 벡터 검색(Vector Retrieval) vs. 지식 그래프(Knowledge Graph). 의미론적 유사성(semantic similarity)이 실패할 때, 코드의 구조적 관계가 생명줄이 됩니다.
요약
- 직관에 반하는 결과: AST 함수 수준 청킹(function-level chunking)의 Recall@5가 0.958로 가장 낮았습니다. 반면 고정 라인(fixed-line)과 파일 수준(file-level)은 모두 완벽한 1.000을 기록했습니다. 하지만 이 "손실"은 착시 현상입니다.
- 결정적인 요인은 무임승차 효과(free-rider effect)입니다. 유일하게 결과가 갈린 쿼리(Q8)에서,
calculate_order_total은 쿼리와 의미론적으로 멀리 떨어져 있습니다. AST는 이를 고립된 섬으로 잘라내어 놓치지만, 큰 청크들은 이를create_payment_intent와 함께 묶어 무임승차 방식으로 검색해냅니다. - 파일 수준의 완벽한 점수는 속임수입니다 — 정밀도(precision)가 0이기 때문입니다. 단 하나의 청크만 사용하므로 특정 함수를 정확히 지목할 수 없으며, 이는 본질적으로 검색의 책임을 LLM에 떠넘기는 것과 같습니다.
- 고정 라인의 완벽한 점수는 우연입니다. 데이터셋의 함수들이 짧고 밀집되어 있어 우연히 청크 크기와 일치했을 뿐입니다. 수백 줄에 달하는 함수가 있는 대규모 프로젝트에서 고정 라인 방식은 함수를 파편으로 조각내 버립니다.
- 규모가 선택을 결정합니다: 소규모는 파일 수준, 중규모는 고정 라인 + 오버랩(overlap), 대규모는 AST를 권장합니다. 이 266줄 규모의 실험은 너무 작아서 실제 정밀도 차이를 가리고 있습니다.
- 최선의 실무(Best practice): AST 함수 수준 + 메타데이터 + 호출 그래프(call graph). AST의 정밀함을 유지하면서, 큰 청크가 운 좋게 얻었던 무임승차 효과를 호출 그래프의 결정론적 구조적 엣지(structural edge)를 사용하여 능동적으로 복구하십시오.
References
- 전체 데모 코드: codebase-kb-04-chunking
실제 기업급 워크플로우에서 검증된 AI 에이전트와 기술의 큐레이션 마켓플레이스인 PrimeSkills를 확인해 보세요. 군더더기 없이 실제로 작동하는 것들만 모았습니다.
저의 홈페이지에서 더 유용한 지식과 흥미로운 제품들을 찾아보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기