Home Graph RAG — import 파싱으로 의존성 그래프를 만들어 검색 결과 확장하기
Post
Cancel

Graph RAG — import 파싱으로 의존성 그래프를 만들어 검색 결과 확장하기

에러 스택 트레이스에는 보통 하나의 클래스가 명시된다. 하지만 실제 원인은 그 클래스가 의존하는 다른 파일에 있는 경우가 많다.

1
java.lang.NullPointerException at UserService.findById(UserService.java:42)

UserService를 찾아서 LLM에게 주면 분석은 시작할 수 있다. 하지만 UserServiceUserRepository를 주입받고, UserRepository의 쿼리가 문제라면? 검색 결과에 UserRepository가 없으면 LLM도 원인을 찾지 못한다.

Graph RAG는 이 문제를 코드 의존성 그래프로 해결한다.


아이디어: 코드는 이미 의존관계를 선언한다

소스 파일은 import 구문으로 자신이 의존하는 파일을 명시한다. 이걸 파싱하면 별도의 분석 없이 의존성 그래프를 만들 수 있다.

1
2
3
UserService.java
  └── import com.example.repository.UserRepository  →  UserRepository.java
        └── import com.example.config.DatabaseConfig  →  DatabaseConfig.java

구현: 언어별 import 파싱

언어마다 import 문법이 다르므로 정규식을 언어별로 정의한다.

1
2
3
4
5
6
7
8
_IMPORT_PATTERNS: dict[str, str] = {
    ".java": r"^import\s+[\w.]+\.(\w+);",
    ".kt":   r"^import\s+[\w.]+\.(\w+)",
    ".py":   r"^(?:from\s+[\w.]+\s+import\s+([\w, ]+)|import\s+([\w.]+))",
    ".ts":   r'from\s+[\'"]([./][\w./@-]+)[\'"]',
    ".tsx":  r'from\s+[\'"]([./][\w./@-]+)[\'"]',
    ".js":   r'from\s+[\'"]([./][\w./@-]+)[\'"]',
}

파싱된 심볼명(클래스명 또는 파일 스템)을 리포지토리 내 실제 파일과 매칭한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def _build_dependency_graph(chunks: list[tuple[str, str]]) -> dict[str, list[str]]:
    # 파일 스템 → 실제 경로 인덱스
    stem_index = {Path(path).stem.lower(): path for path, _ in chunks}

    graph: dict[str, list[str]] = {}
    for path, content in chunks:
        deps: set[str] = set()
        for sym in _extract_symbol_names(path, content):
            target = stem_index.get(sym.lower())
            if target and target != path:
                deps.add(target)
        graph[path] = list(deps)
    return graph

그래프는 JSON으로 저장해두고 인덱싱 때마다 갱신한다.


검색 시: RRF 결과를 그래프로 확장

기존 하이브리드 검색(벡터 + BM25 + RRF) 결과에서 그래프를 따라 의존 파일을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
def expand_with_graph(server_id: int, paths: list[str], depth: int = 1) -> list[str]:
    graph = _load_graph(server_id)
    expanded = list(paths)
    seen = set(paths)
    for _ in range(depth):
        for path in list(seen):
            for dep in graph.get(path, []):
                if dep not in seen:
                    expanded.append(dep)
                    seen.add(dep)
    return expanded

전체 검색 흐름은 이렇게 된다.

1
2
3
4
5
6
7
쿼리: "UserService NullPointerException"
    ↓
벡터 검색 + BM25 → RRF
    ↓ ["UserService.java", "OrderService.java"]
Graph 확장 (depth=1)
    ↓ ["UserService.java", "OrderService.java", "UserRepository.java", "DatabaseConfig.java"]
LLM에게 전달

depth를 1로 제한하는 이유

depth를 높이면 너무 많은 파일이 딸려온다. 의존성 그래프는 대규모 프로젝트에서 거의 모든 파일이 연결되어 있어 depth=2만 해도 수십 개 파일이 나올 수 있다. LLM 컨텍스트 창이 한정적이므로 직접 의존(depth=1)만 추가하는 것이 실용적이다.


한계

import 파싱은 정적 분석이라 런타임 의존성은 잡지 못한다. 리플렉션, DI 컨테이너(@Autowired), 동적 import 등은 그래프에 나타나지 않는다. 하지만 명시적 import만으로도 직접 연관 파일 대부분을 커버할 수 있어 실용적인 개선이 된다.

This post is licensed under CC BY 4.0 by the author.

LLM이 코드를 수정할 때 생기는 문제들 — Self-reflection과 전체 파일 교체 전략

Error Memory — 과거 에러 분석 사례를 RAG로 재활용하기