RAG 07. 코드베이스 검색: 함수 경계와 파일·줄 번호를 함께 남기기
Python AST로 함수와 메서드를 나누고 파일 경로·원문 줄 번호를 보존하는 로컬 코드 검색기를 만듭니다.
“세금이 포함된 합계는 어디서 계산하지?”라는 질문에 함수 이름과 파일의 정확한 줄 번호를 돌려주는 검색기를 만듭니다. 예제에서는 Invoice.total_with_tax를 찾고 shop.py의 10~12줄을 그대로 보여줍니다. 저장소 코드를 실행하지 않고 읽기만 합니다.

그림은 함수 단위 검색을 설명하는 개념도입니다. 실제 실행 화면이나 검색 성능 측정값을 나타내지 않습니다.
오늘 만들 결과
[code-2] shop.py:10-12 Invoice.total_with_tax
def total_with_tax(self, amount, tax_rate):
"""Calculate invoice total including tax."""
return round(amount * (1 + tax_rate), 2)
이 출력은 아래 코드를 Python 3.12.14에서 실행한 결과입니다. 구현 범위는 RAG의 검색 단계입니다. 답변을 생성하는 언어 모델이나 임베딩 API는 사용하지 않습니다. 모델을 붙이기 전에 “찾아온 함수가 정말 그 파일의 그 위치에 있는가”부터 확인합니다.
함수 이름만 저장하면 동명 메서드를 구분하기 어렵습니다. 코드 조각만 저장하면 원본을 다시 찾기 어렵습니다. 따라서 파일 경로, 클래스까지 포함한 이름, 시작·끝 줄, 소스 내용과 파일 버전을 함께 남깁니다.
1. 문자열 길이보다 함수 경계를 먼저 보자
100자씩 자르면 함수의 조건문 중간이 잘릴 수 있습니다. Python의 ast 모듈은 소스를 구문 트리로 읽어서 함수와 클래스의 경계를 알려줍니다. 이 트리를 AST라고 부릅니다. 여기서는 트리 전체를 이해할 필요 없이 FunctionDef, AsyncFunctionDef, ClassDef 세 종류만 따라갑니다.
파싱 결과의 lineno와 end_lineno는 1부터 시작하는 소스 줄 번호입니다. 이를 이용해 원본 줄 배열에서 함수 전체를 잘라냅니다. 데코레이터가 있으면 그 줄도 포함하도록 시작점을 앞당깁니다. Python ast 공식 문서
이 방식은 Python 코드에만 적용됩니다. JavaScript나 Java에는 해당 언어의 파서가 필요합니다. 또 함수 경계를 안다고 실행 흐름, 호출 관계, 타입을 이해하는 것은 아닙니다.
2. 실습 폴더와 가상 코드 준비
Python 3.12만 있으면 됩니다. 외부 패키지는 설치하지 않습니다. 새 폴더 안에 code_search.py와 test_code_search.py를 두고, demo 폴더 안에 shop.py를 저장하세요. 아래 코드 블록이 세 파일의 전체 내용입니다.
mkdir demo
먼저 demo/shop.py입니다. 실제 결제 시스템이 아니라 검색용 가상 코드입니다. 세금 계산의 금융·회계 적합성을 검증하는 예제가 아닙니다.
"""Fictional codebase for retrieval practice."""
def retry_count(status):
"""Return retry count for a temporary payment failure."""
return 3 if status == "temporary" else 0
class Invoice:
def total_with_tax(self, amount, tax_rate):
"""Calculate invoice total including tax."""
return round(amount * (1 + tax_rate), 2)
async def cancel_order(order_id):
"""Cancel an order and return its identifier."""
return {"cancelled": order_id}
처음에는 자기 저장소 대신 이 작은 파일로 실행하세요. 검색기 자신이 있는 폴더를 검색하면 검색기 함수까지 결과에 섞입니다. 그래서 검색 대상을 demo로 분리했습니다.
3. 함수 단위 검색기 만들기
다음을 code_search.py로 저장합니다.
import argparse
import ast
import hashlib
import re
from pathlib import Path
IGNORE = {'.git', '.venv', 'venv', '__pycache__'}
STOP = {'a', 'an', 'the', 'of', 'to', 'for', 'with', 'how', 'is', 'def', 'return'}
def tokens(text):
text = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', text)
return set(re.findall(r'[a-z0-9가-힣]+', text.lower())) - STOP
def index_file(path, root):
source = path.read_text(encoding='utf-8')
tree = ast.parse(source, filename=str(path))
lines = source.splitlines()
relative = path.relative_to(root).as_posix()
revision = hashlib.sha256(source.encode()).hexdigest()[:12]
chunks = []
class Visitor(ast.NodeVisitor):
def __init__(self):
self.scope = []
def visit_ClassDef(self, node):
self.scope.append(node.name)
self.generic_visit(node)
self.scope.pop()
def visit_FunctionDef(self, node):
name = '.'.join(self.scope + [node.name])
start = min([node.lineno] + [d.lineno for d in node.decorator_list])
end = node.end_lineno
if end is None:
raise ValueError(f'No source end line: {relative}:{start}')
chunks.append({'path': relative, 'symbol': name,
'start': start, 'end': end, 'revision': revision,
'doc': ast.get_docstring(node) or '',
'text': '\n'.join(lines[start - 1:end])})
self.scope.append(node.name)
self.generic_visit(node)
self.scope.pop()
visit_AsyncFunctionDef = visit_FunctionDef
Visitor().visit(tree)
return chunks
def build_index(root):
root = Path(root).resolve()
if not root.is_dir():
raise ValueError('Repository directory does not exist.')
chunks = []
for path in sorted(root.rglob('*.py')):
if IGNORE.intersection(path.relative_to(root).parts):
continue
if path.is_symlink():
continue
chunks.extend(index_file(path, root))
for number, chunk in enumerate(chunks, 1):
chunk['id'] = f'code-{number}'
return chunks
def retrieve(question, chunks, top_k=2):
if top_k < 1:
raise ValueError('top_k must be positive.')
query = tokens(question)
def score(chunk):
return (3 * len(query & tokens(chunk['symbol']))
+ 2 * len(query & tokens(chunk['doc']))
+ len(query & tokens(chunk['text'])))
ranked = sorted(chunks, key=score, reverse=True)
return [chunk for chunk in ranked if score(chunk) > 0][:top_k]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('root', type=Path)
parser.add_argument('question')
parser.add_argument('--top-k', type=int, default=2)
args = parser.parse_args()
hits = retrieve(args.question, build_index(args.root), args.top_k)
for hit in hits:
print(f"[{hit['id']}] {hit['path']}:{hit['start']}-{hit['end']} {hit['symbol']}")
print(hit['text'])
if not hits:
print('관련 함수를 찾지 못했습니다.')
if __name__ == '__main__':
main()
코드는 다음 순서로 움직입니다.
build_index는 지정한 폴더의.py파일을 정렬해서 읽습니다..git,.venv,venv,__pycache__폴더와 심볼릭 링크 파일은 건너뜁니다. 파일을import하거나 실행하지 않습니다.index_file은 한 파일을 파싱합니다. 클래스와 함수 이름을scope에 쌓아Invoice.total_with_tax나outer.inner같은 이름을 만듭니다. 함수 안에 함수가 있으면 바깥 함수 조각과 안쪽 함수 조각이 모두 생깁니다.- 원본 줄을 그대로 잘라
text에 보관합니다.ast.unparse로 코드를 다시 만들면 원래 줄 번호와 서식이 달라질 수 있으므로 사용하지 않았습니다. retrieve는 이름에 겹치는 단어에 3점, 설명 문자열에 2점, 코드에 1점을 줍니다. 이는 설명용 가중치이며 실험으로 최적화한 값이 아닙니다.
예를 들어 totalWith_tax는 total, with, tax로 나뉜 뒤 흔한 단어 with가 제거됩니다. snake_case와 단순한 camelCase를 함께 검색하기 위한 작은 전처리입니다. 약어 경계와 모든 언어의 식별자를 완벽하게 분리하는 토크나이저는 아닙니다.
검색은 단어 겹침 방식입니다. “청구 금액”과 “invoice total”을 자동으로 같은 뜻으로 처리하지 않습니다. 따라서 첫 실행은 코드에 실제로 들어 있는 영어 키워드를 씁니다. 이 점수를 임베딩 유사도나 정답 확률로 읽으면 안 됩니다.
revision에는 파일 내용의 SHA-256 앞 12자리를 저장합니다. 이것은 파일이 바뀌었는지 비교하는 실습용 표시이며 Git 커밋 id가 아닙니다. 코드에 줄을 추가하면 기존 줄 번호는 낡은 정보가 되므로 다시 색인해야 합니다. 저장소 전체 버전 관리가 필요한 서비스라면 커밋 id도 함께 보관하세요.
4. 원문 위치까지 확인하기
검색기 파일이 있는 폴더에서 실행합니다.
python code_search.py demo "invoice total tax" --top-k 1
글 첫머리의 code-2 결과가 나옵니다. shop.py의 10~12줄을 열어 출력과 같은지 비교하세요. code-2는 이번 색인에서 부여한 번호이므로 파일 추가 후에도 영구적으로 같은 함수를 가리킨다고 가정하면 안 됩니다.
없는 기능도 검색합니다.
python code_search.py demo "spaceship"
실제 출력입니다.
관련 함수를 찾지 못했습니다.
이는 해당 기능이 저장소 어디에도 없다는 증거가 아닙니다. 이번 색인은 함수만 수집합니다. 모듈 전역 상수, 설정 파일, 클래스 설명 문자열만으로 구현된 정보는 검색 대상에 없습니다. 질문의 표현이 다르기만 해도 놓칠 수 있습니다.
함수가 중첩되면 내용이 겹치는 조각이 생긴다는 점도 기억하세요. 모델에 전달할 근거 예산이 작다면 같은 파일의 겹치는 줄 범위를 합치거나, 필요한 안쪽 함수만 고르는 후처리가 필요합니다.
5. 줄 번호와 실행 여부를 테스트하기
다음을 test_code_search.py로 저장합니다.
import tempfile
import unittest
from pathlib import Path
from code_search import build_index, index_file, retrieve, tokens
class CodeSearchTests(unittest.TestCase):
def test_method_location(self):
root = Path('demo').resolve()
hit = retrieve('invoice total tax', build_index(root), 1)[0]
self.assertEqual(hit['symbol'], 'Invoice.total_with_tax')
self.assertEqual((hit['start'], hit['end']), (10, 12))
original = (root / hit['path']).read_text().splitlines()
self.assertEqual(hit['text'], '\n'.join(original[9:12]))
def test_decorator_async_and_nested_name(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
path = root / 'sample.py'
path.write_text('@trace\nasync def outer():\n def inner():\n return 1\n return inner()\n')
chunks = index_file(path, root)
self.assertEqual(chunks[0]['start'], 1)
self.assertEqual(chunks[1]['symbol'], 'outer.inner')
def test_source_is_not_executed(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / 'sample.py').write_text('raise RuntimeError("do not run")\ndef safe():\n return 1\n')
self.assertEqual(len(build_index(root)), 1)
def test_changed_source_has_new_revision(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
path = root / 'sample.py'
path.write_text('def f():\n return 1\n')
first = build_index(root)[0]['revision']
path.write_text('def f():\n return 2\n')
self.assertNotEqual(build_index(root)[0]['revision'], first)
def test_no_match_and_identifier_split(self):
self.assertEqual(retrieve('spaceship', build_index('demo')), [])
self.assertEqual(tokens('totalWith_tax'), {'total', 'tax'})
def test_syntax_error_is_visible(self):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / 'bad.py').write_text('def broken(')
with self.assertRaises(SyntaxError):
build_index(folder)
if __name__ == '__main__':
unittest.main()
python -m unittest -v
Python 3.12.14에서 6개 테스트가 통과했습니다. 테스트는 메서드의 실제 줄 범위, 데코레이터와 async 함수, 중첩 이름, 소스 변경 표시, 미검색, 문법 오류를 확인합니다. 파일 최상위에 예외 발생 코드를 넣어도 색인이 끝나는 테스트로 검색 대상이 실행되지 않는 것도 확인했습니다.
소스를 실행하지 않는다고 모든 입력이 안전한 것은 아닙니다. 매우 큰 파일이나 깊게 중첩된 입력은 파서 자원을 많이 쓸 수 있습니다. 외부 저장소를 자동 처리하는 서비스에서는 파일 크기, 파일 수, 작업 시간 제한이 추가로 필요합니다.
문법 오류는 조용히 버리지 않고 SyntaxError로 드러나게 했습니다. 초보 실습에서 오류 파일을 몰래 제외하면 “검색은 잘 되는데 중요한 함수만 없다”는 상태를 놓치기 쉽기 때문입니다. 여러 파일을 계속 처리하도록 바꾸더라도 실패한 경로를 별도로 기록하세요.
직접 바꿔 볼 것
Invoice에subtotal메서드를 추가하고invoice total검색 순위가 어떻게 바뀌는지 확인하세요. 이름 가중치를 3에서 1로 바꾼 뒤 결과도 비교합니다.shop.py맨 위에 빈 줄을 하나 추가하세요. 줄 번호 테스트가 실패하는 이유를 확인하고 기대 범위를 수정합니다. 소스와 출처가 함께 갱신되어야 합니다.- 한 파일에 같은 이름의 메서드를 가진 두 클래스를 만드세요. 클래스 이름을 포함한
symbol이 구분에 어떻게 쓰이는지 확인합니다. - “세금 포함 합계”로 검색하세요. 영어 코드에서 결과가 나오지 않는다면 버그라고 단정하지 말고, 다국어 검색에 필요한 다음 단계를 적어보세요.
이번 편의 완성 기준은 관련 코드뿐 아니라 그 코드를 확인할 수 있는 파일·함수·줄 번호가 함께 나오는 것입니다. 생성형 설명을 붙이더라도 이 출처 묶음을 먼저 지켜야 합니다.
참고 자료
- Python 3.12 ast: 파싱, 함수 노드, 소스 위치 정보
- Python pathlib: 파일 경로 처리
RAG 실전 포트폴리오 07/25
이전: 06. 고객지원 답변 만들기: 정책 안내와 상담원 확인 나누기