1 도입: 왜 구조화가 필요한가
Long Context 모델의 한계에서 LC 모델의 주장을 비판적으로 분석했다. 이 글에서는 그 대안으로서 구조화된 RAG 아키텍처를 설계한다.
핵심 아이디어는 단순하다: 검색과 추론을 분리한다.
- 검색은 결정론적이어야 한다 — “A가 B를 호출하는가”는 확률이 아니라 사실이다
- 추론은 유연해야 한다 — “이 변경이 시스템에 미치는 영향은?”은 맥락에 따라 달라진다
이 두 역할을 하나의 Attention 메커니즘에 맡기면 서로 간섭한다. 구조화된 RAG는 이를 GraphRAG(결정론적 검색)과 Agentic RAG(자율 추론)의 2-Layer로 분리한다.
2 전체 아키텍처
┌─────────────────────────────────────────────────┐
│ 사용자 질의 │
│ "3번째 메서드의 설정을 바꾸면 145번째 메서드의 │
│ 출력이 어떻게 바뀌는가?" │
└───────────────────────┬─────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Layer 2: Agentic RAG (자율 추론) │
│ - 질의 분해: 어떤 정보가 필요한가? │
│ - Layer 1에 구조적 검색 요청 │
│ - 검색 결과 기반 다단계 추론 │
│ - 필요 시 추가 검색 요청 (반복) │
└───────────────────────┬─────────────────────────┘
↓ ↑
┌─────────────────────────────────────────────────┐
│ Layer 1: GraphRAG (구조적 검색) │
│ - AST 기반 호출 그래프 순회 │
│ - 온톨로지 기반 도메인 관계 탐색 │
│ - 결정론적으로 관련 코드 + 메타데이터 반환 │
└───────────────────────┬─────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Knowledge Base │
│ - Graph DB (Neo4j): 코드 관계 그래프 │
│ - 온톨로지: AST 구조 + 도메인 메타데이터 │
│ - Raw Code: 원본 소스 코드 │
└─────────────────────────────────────────────────┘
3 Layer 0: Knowledge Base 구축
3.1 AST 기반 온톨로지
코드의 구조적 관계를 명시적으로 표현하는 것이 첫 번째 단계다.
Brachman & Levesque(2004)에 따르면, 지식 표현은 5가지 역할을 수행한다:
| 역할 | 코드 온톨로지에서의 의미 |
|---|---|
| 대리(Surrogate) | 심볼이 실제 코드 엔티티를 대표 |
| 온톨로지적 약속(Ontological Commitment) | 세상을 Module, Function, Class 등으로 명시적 분류 |
| 부분 이론(Fragmentary Theory) | 코드의 특정 측면(호출관계, 타입)만 선택적으로 표현 |
| 연산 매체(Medium of Computation) | 형식적 추론(forward/backward chaining) 가능 |
| 표현 매체(Medium of Expression) | 사람이 이해할 수 있는 형태로 표현 |
핵심은 온톨로지적 약속이다. Raw code에서 함수 호출 관계는 텍스트 패턴으로만 존재하지만, 온톨로지에서는 Function --calls--> Function 이라는 명시적 관계 타입으로 선언된다.
3.2 코드 온톨로지 설계
Keet(2020)의 Competency Question 방법론을 적용하여, 온톨로지가 답해야 할 질문을 먼저 정의한다:
| 번호 | Competency Question | 필요한 관계 |
|---|---|---|
| CQ1 | 모듈 X가 변경되면 영향받는 모듈은? | Module --imports--> Module |
| CQ2 | 함수 f의 모든 호출 경로는? | Function --calls--> Function |
| CQ3 | 리포지토리 A와 B 사이의 의존 경로는? | Repository --depends_on--> Repository |
| CQ4 | 순환 의존성이 있는 모듈 그룹은? | SCC 알고리즘으로 탐지 |
| CQ5 | 설정값 C를 읽는 모든 함수는? | Function --reads--> Config |
| CQ6 | 클래스 K의 메서드 시그니처 변경 시 영향받는 하위 클래스는? | Class --inherits--> Class |
각 CQ가 온톨로지 설계를 구동한다. CQ에서 필요한 관계가 온톨로지에 명시적으로 표현되어야 그래프 쿼리로 답할 수 있다.
3.3 두 종류의 메타데이터
| 메타데이터 유형 | 추출 방법 | 내용 |
|---|---|---|
| AST 메타데이터 (구조적) | 정적 분석 도구로 자동 추출 | 함수 시그니처, 호출 관계, 상속 트리, 타입 정보, import 관계 |
| 도메인 메타데이터 (의미적) | 규칙 기반 + LLM 보조 | 비즈니스 로직 분류, 모듈 역할, 데이터 흐름 의미, 보안 등급 |
AST 메타데이터는 결정론적으로 추출 가능하다. 도메인 메타데이터는 코드에 명시되지 않은 상위 의미를 부가하므로, 도메인 전문가의 규칙이나 LLM의 보조가 필요하다.
Keet(2020)의 Participation 패턴을 적용하면:
validate_token --participatesIn--> Authentication
encrypt_data --participatesIn--> DataProtection
이렇게 하면 “인증 관련 모든 함수”를 단일 그래프 쿼리로 추출할 수 있다. Raw code에서는 함수명이나 주석의 패턴 매칭에 의존해야 한다.
3.4 코드 예시: AST에서 호출 그래프 추출 → Neo4j 적재
Python의 ast 모듈로 호출 관계를 추출하고 Neo4j에 적재하는 파이프라인이다:
import ast
import os
from neo4j import GraphDatabase
# --- Step 1: AST에서 함수 정의와 호출 관계 추출 ---
class CallGraphVisitor(ast.NodeVisitor):
"""Python 소스 파일에서 함수 정의와 호출 관계를 추출한다."""
def __init__(self, filepath):
self.filepath = filepath
self.functions = [] # (파일, 함수명, 라인번호)
self.calls = [] # (호출자, 피호출자)
self._current_func = None
def visit_FunctionDef(self, node):
self.functions.append({
"file": self.filepath,
"name": node.name,
"line": node.lineno,
"args": [arg.arg for arg in node.args.args],
})
parent = self._current_func
self._current_func = node.name
self.generic_visit(node)
self._current_func = parent
def visit_Call(self, node):
if self._current_func and isinstance(node.func, ast.Name):
self.calls.append({
"caller": self._current_func,
"callee": node.func.id,
"file": self.filepath,
"line": node.lineno,
})
self.generic_visit(node)
def extract_call_graph(project_dir: str):
"""프로젝트 디렉토리의 모든 .py 파일에서 호출 그래프를 추출한다."""
all_functions, all_calls = [], []
for root, _, files in os.walk(project_dir):
for fname in files:
if not fname.endswith(".py"):
continue
fpath = os.path.join(root, fname)
with open(fpath, "r", encoding="utf-8") as f:
try:
tree = ast.parse(f.read(), filename=fpath)
except SyntaxError:
continue
visitor = CallGraphVisitor(fpath)
visitor.visit(tree)
all_functions.extend(visitor.functions)
all_calls.extend(visitor.calls)
return all_functions, all_calls
# --- Step 2: Neo4j에 그래프 적재 ---
def load_to_neo4j(functions, calls, uri="bolt://localhost:7687", auth=("neo4j", "password")):
driver = GraphDatabase.driver(uri, auth=auth)
with driver.session() as session:
# 함수 노드 생성
for func in functions:
session.run(
"MERGE (f:Function {name: $name, file: $file}) "
"SET f.line = $line, f.args = $args",
**func,
)
# 호출 관계 생성
for call in calls:
session.run(
"MATCH (caller:Function {name: $caller}), "
" (callee:Function {name: $callee}) "
"MERGE (caller)-[:CALLS {line: $line}]->(callee)",
**call,
)
driver.close()
# 실행
functions, calls = extract_call_graph("/path/to/project")
load_to_neo4j(functions, calls)
print(f"노드: {len(functions)}개, 관계: {len(calls)}개 적재 완료")이 코드는 최소한의 AST 메타데이터(함수 정의 + 호출 관계)만 추출한다. 실무에서는 import 관계, 클래스 상속, 전역 변수 참조 등을 추가로 추출하여 그래프를 풍부하게 만든다.
4 Layer 1: GraphRAG (구조적 검색)
4.1 결정론적 검색의 원리
GraphRAG의 핵심은 검색 결과의 정확성이 보장된다는 것이다.
Robinson(2015, Ch.6)에 따르면, 네이티브 그래프 저장소는 Index-Free Adjacency 원칙을 따른다. 각 노드가 인접 노드에 대한 직접 참조를 유지하므로, 쿼리 시간은 그래프 전체 크기와 무관하고 탐색된 그래프의 양에만 비례한다.
| 연산 | 벡터 검색 | 그래프 순회 |
|---|---|---|
| “A의 호출 대상 찾기” | 임베딩 유사도 Top-K | A의 outgoing calls 관계 순회, \(O(1)\) /관계 |
| “A→Z 경로 찾기” | 불가능 (유사도는 경로를 모른다) | BFS/DFS로 모든 경로 열거 |
| “순환 의존성 탐지” | 불가능 | SCC 알고리즘, \(O(\|V\|+\|E\|)\) |
| “가장 중요한 모듈” | 불가능 | PageRank / Betweenness Centrality |
Kejriwal(2021)의 GraphRAG 아키텍처에서 핵심 원칙:
“그래프가 의존성 추적을 담당(결정론적)하고, LLM은 결과를 설명만 한다. 이것이 구조적으로 hallucination을 방지하는 메커니즘이다.” (Kejriwal, 2021, Part 7)
4.2 쿼리 패턴 매핑
사용자 질의가 어떤 그래프 연산으로 변환되는지가 Layer 1의 핵심 설계이다:
| 사용자 질의 | 그래프 연산 | 쿼리 패턴 |
|---|---|---|
| “이 함수가 뭘 하는가?” | 노드 속성 + 1-hop 이웃 | 단일 노드 + 관계 |
| “설정 변경의 영향은?” | 역방향 의존성 추적 | 1-2 hop 역순회 |
| “모듈 3→97 영향?” | 경로 탐색 | 다중 hop 결정론적 경로 |
| “순환 의존성?” | Strongly Connected Components | 그래프 전체 알고리즘 |
| “가장 중요한 모듈?” | 중심성 분석 | PageRank/Betweenness |
4.3 코드 예시: Cypher 쿼리로 영향도 분석
위 테이블의 쿼리 패턴을 Neo4j Cypher로 구현한 예시다:
from neo4j import GraphDatabase
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
with driver.session() as session:
# --- 쿼리 1: 특정 함수의 1-hop 호출 관계 ---
result = session.run("""
MATCH (f:Function {name: 'process_payment'})-[:CALLS]->(callee:Function)
RETURN callee.name AS called_function, callee.file AS file, callee.line AS line
""")
print("process_payment이 호출하는 함수:")
for record in result:
print(f" → {record['called_function']} ({record['file']}:{record['line']})")
# --- 쿼리 2: 설정 변경의 영향도 — 역방향 의존성 추적 ---
# "MAX_RETRY를 참조하는 모든 함수와, 그 함수를 호출하는 함수"를 2-hop으로 추적
result = session.run("""
MATCH (config:Config {name: 'MAX_RETRY'})<-[:READS]-(f:Function)
OPTIONAL MATCH (caller:Function)-[:CALLS]->(f)
RETURN f.name AS direct_reader,
f.file AS file,
collect(DISTINCT caller.name) AS upstream_callers
""")
print("\nMAX_RETRY 변경 시 영향받는 함수:")
for record in result:
print(f" 직접: {record['direct_reader']} ({record['file']})")
print(f" 상위: {record['upstream_callers']}")
# --- 쿼리 3: 두 함수 사이의 모든 경로 (다단계 인과추론) ---
result = session.run("""
MATCH path = (start:Function {name: 'update_config'})
-[:CALLS*1..10]->
(end:Function {name: 'send_notification'})
RETURN [node IN nodes(path) | node.name] AS call_chain,
length(path) AS depth
ORDER BY depth
LIMIT 5
""")
print("\nupdate_config → send_notification 경로:")
for record in result:
print(f" 경로 (depth={record['depth']}): {' → '.join(record['call_chain'])}")
# --- 쿼리 4: 순환 의존성 탐지 ---
result = session.run("""
MATCH path = (f:Function)-[:CALLS*2..]->(f)
WITH f, nodes(path) AS cycle
RETURN f.name AS function_name,
[node IN cycle | node.name] AS cycle_path
LIMIT 10
""")
print("\n순환 호출 탐지:")
for record in result:
print(f" {record['function_name']}: {' → '.join(record['cycle_path'])}")
driver.close()쿼리 3이 핵심이다. LC 모델에서 “update_config에서 send_notification까지의 경로를 추적하라”고 요청하면 Attention이 확률적으로 경로를 추론하지만, Cypher에서는 [:CALLS*1..10] 패턴으로 존재하는 모든 경로를 결정론적으로 열거한다.
4.4 추론 규칙에 의한 자동 지식 도출
Brachman & Levesque(2004)의 Forward Chaining을 적용하면, 명시적으로 저장하지 않은 관계도 자동으로 도출할 수 있다:
사실: imports(A, B), imports(B, C)
규칙: imports(x, y) ∧ imports(y, z) → depends_on(x, z)
추론: depends_on(A, C) ← 새로운 사실 도출
이것은 확률적 추론이 아니다. 형식 논리에 의한 보장된 추론이다.
5 Layer 2: Agentic RAG (자율 추론)
5.1 역할 분담
Layer 1이 “어디에 무엇이 있는가”를 결정론적으로 답한다면, Layer 2는 “그래서 어떤 의미인가”를 추론한다.
| 역할 | Layer 1 (GraphRAG) | Layer 2 (Agentic RAG) |
|---|---|---|
| 핵심 능력 | 정확한 검색 | 유연한 추론 |
| 연산 방식 | 그래프 알고리즘 | LLM 기반 계획-실행-반영 |
| 결과 보장 | 결정론적 | 확률적 (but 구조화된 입력) |
| 실패 모드 | 온톨로지에 없는 관계 → 검색 불가 | 추론 오류 → 잘못된 해석 |
5.2 작동 흐름
질의: "3번째 메서드의 설정을 바꾸면 145번째 메서드에 어떤 영향?"
Layer 2 (Agent):
Step 1: 질의 분해
- "3번째 메서드가 변경하는 설정값은?"
- "그 설정값을 참조하는 경로는?"
- "145번째 메서드까지의 전파 경로는?"
Step 2: Layer 1에 검색 요청
→ GraphRAG: method_3 --modifies--> config_X
→ GraphRAG: config_X를 읽는 모든 함수 (CQ5)
→ GraphRAG: method_3에서 method_145까지의 모든 경로 (BFS)
Step 3: 검색 결과 기반 추론
- 경로: method_3 → config_X → module_Y → method_145
- config_X의 변경이 module_Y의 조건 분기에 영향
- 이로 인해 method_145의 입력 파라미터가 변경
Step 4: 반영 (필요 시 추가 검색)
- "module_Y의 조건 분기 로직을 확인해야 한다"
→ Layer 1에 module_Y의 상세 코드 요청
→ Raw code 조각을 받아 추론 보강
5.3 LC 모델과의 구조적 차이
LC 모델은 이 모든 과정을 단일 Attention 패스로 처리한다. 이것은 단순하지만, 두 가지 문제가 있다:
- 검색과 추론의 간섭: Attention이 “관련 코드를 찾는 것”과 “찾은 코드의 의미를 해석하는 것”을 동시에 수행해야 한다
- 검증 불가: 중간 과정이 블랙박스이므로, “왜 이 결론에 도달했는가”를 추적할 수 없다
2-Layer 구조에서는: - Layer 1의 검색 결과는 검증 가능하다 (그래프 쿼리의 결과는 재현 가능) - Layer 2의 추론 과정은 추적 가능하다 (Agent의 계획-실행 로그) - 오류 발생 시 어느 레이어에서 실패했는지 식별할 수 있다
6 설계 시 고려사항
6.1 온톨로지 구축 비용
구조화된 RAG의 최대 단점은 초기 구축 비용이다.
| 구성 요소 | 구축 난이도 | 자동화 가능성 |
|---|---|---|
| AST 파싱 | 낮음 | 높음 (tree-sitter, ast 모듈) |
| 호출 그래프 | 중간 | 높음 (정적 분석 도구) |
| 타입 관계 | 중간 | 높음 (타입 체커) |
| 도메인 메타데이터 | 높음 | 낮음 (전문가 규칙 + LLM 보조) |
| 온톨로지 스키마 설계 | 높음 | 낮음 (CQ 기반 수동 설계) |
도메인 메타데이터와 온톨로지 스키마 설계는 자동화가 어렵고, 도메인 전문가의 참여가 필요하다. 이 비용을 감당할 수 없는 경우에는 LC 모델이 현실적 대안이 된다.
6.2 온톨로지 유지보수
코드는 계속 변한다. 온톨로지가 코드의 현재 상태를 반영하지 못하면 검색 결과가 부정확해진다.
| 전략 | 동작 | 적합한 경우 |
|---|---|---|
| CI/CD 연동 | 커밋마다 AST 재파싱 → 그래프 갱신 | 활발한 개발 중인 코드 |
| 배치 갱신 | 주기적으로 전체 재구축 | 안정된 레거시 코드 |
| 증분 갱신 | 변경된 파일만 그래프 업데이트 | 대규모 코드베이스 |
6.3 하이브리드: GraphRAG + LC
GraphRAG와 LC 모델은 배타적이지 않다. Layer 2의 Agentic RAG에서 추론 엔진으로 LC 모델을 사용할 수 있다:
Layer 1 (GraphRAG): 구조적 관계 + 관련 코드 추출 (결정론적)
↓
Layer 2 (Agentic RAG + LC 모델):
GraphRAG 결과 + 관련 Raw Code를 LC 컨텍스트에 로드
→ 구조화된 입력 위에서 LC 모델이 추론
이 구조에서 LC 모델은 보조 추론 엔진이지, RAG를 대체하는 것이 아니다. GraphRAG가 “어디를 봐야 하는가”를 결정하고, LC 모델이 “그것이 무슨 의미인가”를 해석한다.
7 정리
| 설계 원칙 | 내용 |
|---|---|
| 검색과 추론의 분리 | Layer 1(결정론적 검색)과 Layer 2(자율 추론)를 독립적으로 설계 |
| 메타데이터는 증폭 | AST + 도메인 온톨로지는 정보를 줄이는 것이 아니라 늘리는 것 |
| 결정론적 우선 | 참/거짓으로 판단 가능한 관계는 그래프에, 해석이 필요한 부분만 LLM에 |
| 검증 가능성 | 각 레이어의 결과를 독립적으로 검증할 수 있어야 한다 |
| 하이브리드 개방 | LC 모델을 Layer 2의 추론 엔진으로 활용 가능 |
8 관련 주제
이 시리즈
- Long Context 모델의 한계: NIAH에서 추론까지 — LC 모델의 주장 비판
- RAG vs Long Context: 유즈케이스별 선택 프레임워크 — 실무 의사결정 가이드
구현 참조
- LangGraph Agentic-RAG — Agentic RAG LangGraph 구현
- GraphRAG 시리즈 — Neo4j 기반 지식 그래프 구축
교재 참조
- Robinson, I. et al. (2015). Graph Databases. 2nd Ed., O’Reilly. Ch.6.
- Kejriwal, M. et al. (2021). Knowledge Graphs. MIT Press. Part 7.
- Keet, C.M. (2020). An Introduction to Ontology Engineering. Ch.1, Ch.4.
- Brachman, R. & Levesque, H. (2004). Knowledge Representation and Reasoning. Ch.1, Ch.9.