코드를 위한 RAG: 왜 함수 단위 청킹(Chunking)이 라인 단위 청킹보다 나은가
요약
코드베이스를 위한 RAG 시스템 구축 시, 단순 라인 단위 청킹 대신 함수 단위 청킹을 사용해야 하는 이유와 구현 방법을 설명합니다. 파서를 활용해 코드의 구조적 의미를 보존함으로써 검색 품질을 높이는 전략을 다룹니다.
핵심 포인트
- 고정 크기 청킹은 함수의 문맥과 시그니처를 파괴하여 검색 성능을 저하시킴
- 함수, 메서드, 클래스 등 코드의 자연스러운 구조를 단위로 청킹해야 함
- 정규 표현식보다 AST나 컴파일러 API 기반의 파서를 사용하는 것이 효과적임
- 완전한 함수 단위 청킹은 모델이 추론 가능한 온전한 문맥을 제공함
LLM이 코드베이스에 대한 질문에 답할 수 있도록 검색 시스템을 구축했는데, 첫 번째 버전은 거의 쓸모가 없었습니다. 문제는 모델이나 임베딩(embeddings)이 아니었습니다. 바로 코드를 청크(chunks)로 나누는 방식이 문제였습니다. 소스 코드를 줄 수(line count) 기준으로 나누는 것은 코드의 의미를 만드는 구조 자체를 파괴합니다. 함수를 인식하는 청킹(function-aware chunking)이 왜 훨씬 더 효과적인지, 그리고 어떻게 구현하는지 설명하겠습니다.
단순한 접근 방식과 그것이 실패하는 이유
표준적인 RAG 튜토리얼에서는 다음과 같이 말합니다: 문서를 고정된 크기의 청크(예: 500 토큰)로 나누고, 각 청크를 임베딩(embed)한 뒤, 쿼리에 가장 가까운 것을 검색하십시오. 산문(prose)이라면 괜찮습니다. 하지만 코드의 경우, 이는 파괴적입니다.
500 토큰 창(window)은 함수의 경계를 존중하지 않습니다. 결국 "transfer()의 마지막 3분의 1과 approve()의 첫 절반"과 같은 청크가 만들어지게 됩니다. 두 함수 모두 완전하지 않습니다. 임베딩은 그 자체로는 아무런 의미가 없는 파편을 나타내며, 이를 검색했을 때 모델에게 시그니처(signature)도 없고 문맥(context)도 없는 함수의 절반만을 전달하게 됩니다.
제 초기 시스템은 함수의 중간 부분만 보고도 해당 함수에 대해 자신 있게 답변하곤 했습니다. 검색이 병목 현상이었고, 청킹이 그 원인이었습니다.
크기가 아닌 구조로 청킹하기
코드에는 함수(functions), 메서드(methods), 클래스(classes), 컨트랙트(contracts)와 같은 자연스러운 단위가 있습니다. 이것들이 개발자가 사고하는 단위이므로, 바로 이 단위로 청킹을 해야 합니다. 하나의 함수는 하나의 청크가 되어야 합니다. 청크에는 전체 시그니처(signature), 본문(body), 그리고 이상적으로는 그 위의 문서 주석(doc comment)이 포함되어야 합니다.
interface CodeChunk {
name: string; // 함수 또는 메서드 이름
signature: string; // 문맥을 위한 전체 시그니처
...
이제 각 청크는 완전하고 의미 있는 단위가 됩니다. 이를 검색하면 모델은 이름과 시그니처가 온전하게 유지된, 추론 가능한 전체 함수를 얻게 됩니다.
함수 추출하기
Solidity나 TypeScript의 경우, 정규 표현식 (regex)보다는 파서 (parser)를 사용하는 것이 훨씬 효과적입니다. TypeScript의 경우 저는 컴파일러 API (compiler API)나 ts-morph와 같은 도구를 사용하며, Solidity의 경우 AST (Abstract Syntax Tree)를 제공하는 적절한 파서를 사용합니다. 핵심은 원문 텍스트를 단순히 자르는 것이 아니라, 구문 트리 (syntax tree)를 탐색하여 함수 수준의 노드(node)당 하나의 청크 (chunk)를 생성하는 것입니다.
추출기 (extractor)의 단순화된 형태:
import { Project } from "ts-morph";
function chunkByFunction(filePath: string): CodeChunk[] {
...
각 함수가 온전하게 추출됩니다. 더 이상 함수가 중간에 잘리는 일은 없습니다.
로컬에서의 임베딩 (Embedding) 및 검색 (retrieval)
저는 이 모든 과정을 로컬 모델에서 실행하므로, 비공개 코드베이스가 제 컴퓨터를 절대 벗어나지 않습니다. Ollama가 임베딩 모델 (embedding model) 역할을 수행하며, 저는 각 함수 청크를 임베딩하고 벡터 (vectors)를 저장합니다:
import { Ollama } from "ollama";
const ollama = new Ollama();
...
저는 ${chunk.name} ${chunk.signature} ${chunk.body}를 임베딩하여, 함수의 본문 (body)뿐만 아니라 함수 이름과 시그니처 (signature)가 벡터의 일부가 되도록 합니다. 이렇게 하면 이름 기반의 쿼리("withdraw가 무엇을 하나요")가 잘 검색되는데, 이름이 임베딩된 텍스트에 포함되어 있기 때문입니다.
검색 품질의 보상
함수 단위 청크로 전환한 후, 이전에는 파편화되고 절반만 맞은 답변을 내놓았던 동일한 질문들이 명확한 답변을 얻게 되었습니다. "이 컨트랙트는 출금 시 재진입 (reentrancy) 문제를 어떻게 처리하나요?"라는 질문을 던지면, 이제는 withdraw 함수 전체와 해당 함수가 사용하는 수정자 (modifier)를 함께 검색해 옵니다. 모델은 전체를 볼 수 있기 때문에 'checks-effects-interactions' 순서에 대해 실제로 추론할 수 있습니다.
모델이 더 똑똑해진 것이 아닙니다. 검색 (retrieval)이 정직해진 것입니다. 저는 모델에게 임의의 텍스트 창 (text windows) 대신 완전한 의미 단위 (units of meaning)를 전달하고 있었습니다.
작은 개선 사항: 호출자 (callers) 포함하기
나중에 추가한 한 가지는 다음과 같습니다. 검색된 함수에 대해, 해당 함수를 호출하는 함수들의 한 줄 시그니처 (one-line signatures)도 함께 가져오는 것입니다. 이는 청크를 비대하게 만들지 않으면서도 모델에게 해당 함수가 어떻게 사용되는지에 대한 감각을 제공합니다. 이는 질문이 나오기도 전에 후속 질문에 답할 수 있게 해주는 저렴한 컨텍스트 (context)입니다.
일반적인 교훈
RAG의 품질은 대부분 검색 (retrieval) 품질에 달려 있으며, 검색 품질은 대부분 청킹 (chunking) 품질에 달려 있습니다. 크기 단위로 청킹하려는 본능은 텍스트 문서 튜토리얼에서 비롯된 것이지만, 코드는 산문 (prose)이 아닙니다. 코드는 구조를 가지고 있으며, 그 구조가 바로 의미를 전달하는 핵심입니다. 구조를 따라 청킹하고, 이름과 시그니처 (signature)를 본문과 함께 임베딩 (embedding)하며, 코드가 비공개라면 로컬에서 실행하세요. 임베딩과 모델은 결코 문제가 아니었습니다. 문제는 가위였습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기