코드베이스 지식 베이스 시리즈 (08): 프로덕션 아키텍처 — Vector, Graph, 그리고 Symbolic 인덱스를 결합하는 방법
요약
코드베이스 검색의 한계를 극복하기 위해 Vector, Graph, Symbolic 인덱스를 결합하는 프로덕션 아키텍처 설계 방안을 다룹니다. 단순 텍스트 검색의 한계를 지적하며, 구조적 관계를 파악하기 위한 그래프와 정확한 매칭을 위한 심볼릭 방식의 통합 필요성을 강조합니다.
핵심 포인트
- 순수 텍스트 기반 Vector/Hybrid 검색은 코드의 구조적 관계를 파악하는 데 한계가 있음
- 의미론적 유사도는 Vector, 구조적 관계는 Graph, 정확한 매칭은 Symbolic 방식이 담당해야 함
- 효율적인 코드베이스 지식 베이스 구축을 위해 세 가지 신호의 영역을 구분하여 설계해야 함
우리는 경계를 측정하는 데 다섯 개의 글을 소비했습니다 — 이제 더 큰 지도가 필요합니다
만약 당신이 03번 글부터 쭉 읽어왔다면, 우리는 다섯 번의 글에 걸쳐 우리를 괴롭혔던 유령인 Q8을 마침내 잠재웠습니다.
긴 추적 과정을 요약해 보겠습니다. Q8은 process payment and create Stripe charge이며, 그 Ground Truth(정답)에는 calculate_order_total이 포함되어 있습니다. 우리는 이를 잡아내기 위해 텍스트 검색(text-retrieval) 분야의 모든 기술을 동원했습니다. Vector(벡터) 베이스라인은 실패했고, 세 가지 Chunking(청킹) 전략도 실패했습니다. called_by를 Embedding(임베딩)에 인코딩하는 방식은 유사도를 0.51까지 겨우 끌어올렸을 뿐 여전히 실패했습니다. 마지막으로 업계에서 보편적으로 권장되는 "궁극의 무기"인 BM25 + Vector 하이브리드 검색(hybrid search)조차 실패했습니다. 이는 Q8을 해결하지 못했을 뿐만 아니라, 전체 점수를 0.958에서 0.931로 떨어뜨렸습니다.
다섯 편의 글을 통해 도출된 결론을 한 문장으로 압축하면 다음과 같습니다:
calculate_order_total과 "Stripe payment" 사이의 연결 고리는 순수하게 **구조적(structural)**입니다. 이는 Call Graph(호출 그래프) 상의 Edge(엣지)에 존재하며(process_checkout에 의해 호출됨), 그 어떤 함수의 텍스트에도 명시되어 있지 않습니다. 따라서 Vector, BM25, Hybrid와 같은 순수 텍스트 접근 방식은 물리적으로 이에 도달할 수 없습니다.
이 문장은 다섯 편의 실험을 통해 얻어낸, 수치로 뒷받침되는 확고한 결론입니다. 이는 가치 있는 결과이지만, 퍼즐의 한 조각일 뿐입니다.
왜냐하면 Q8을 추적하는 과정에서, 우리는 부수적으로 세 가지 뚜렷한 검색 경로의 영역을 파악했기 때문입니다: Vector가 잘하는 것과 도달할 수 없는 것; Graph가 잘하는 것과 그 비용; 그리고 "validate_jwt_token이라는 이름의 함수가 어디에 있는가"와 같이 의미론(semantics)이 전혀 필요하지 않으며, 정확한 매칭(exact matching)을 통해 밀리초 단위로 결과를 반환하는 일련의 쿼리들. 이 세 영역을 하나로 엮으면 코드베이스 검색의 완전한 지도를 얻을 수 있습니다.
따라서 이 글은 관점을 전환합니다. 이전의 다섯 편의 글이 단일 검색 알고리즘의 재현율 (Recall)을 돋보기로 들여다보듯 세밀하게 다루었다면, 이번 글은 위성 뷰로 시야를 넓혀 시스템 아키텍처를 그려냅니다. 즉, 벡터 (vector), 그래프 (graph), 심볼 (symbol) 신호들을 실제 작동하는 프로덕션 시스템으로 어떻게 조직해야 하는가를 다룹니다.
읽기 전에 유의할 점이 있습니다. 이 글에서 구체적인 재현율 (Recall) 수치가 언급된 모든 내용은 이전 다섯 편의 글에서 실험을 통해 이미 검증된 결론입니다. 반면, "시스템 설계 방법"을 설명하는 모든 내용은 해당 결론들로부터 도출된 엔지니어링 설계 권장 사항이며, 전체 프로덕션 시스템에서 엔드 투 엔드 (end-to-end)로 아직 검증된 것은 아닙니다. 저는 주요 지점마다 이 부분을 반복해서 표시할 것입니다. 설계 권장 사항을 실험적 결론과 혼동하지 마십시오.
첫째, 세 가지 신호의 영역을 명확히 구분하기
무엇인가를 설계하기 전에, 이전 다섯 편의 글에서 측정했던 세 가지 영역을 먼저 테이블 위에 올려놓겠습니다. 이것이 전체 아키텍처의 근간이며, 이 근간을 이루는 모든 벽돌에는 실험 번호가 매겨져 있습니다.
경로 1: 벡터 검색 (vector retrieval) — 의미적 유사도 (semantic-similarity) 쿼리의 본거지.
이것은 아티클 03에서 확립되었고 이후 네 편의 글에서 반복적으로 검증된 베이스라인입니다: AST 함수 수준 청킹 (chunking) + 원시 코드 임베딩 (raw-code embedding), Recall@5 = 0.958로, 모든 텍스트 접근 방식 중 가장 강력한 벡터 베이스라인입니다 (실험적으로 검증됨). 이 방식은 "자연어로 기능을 설명하여 구현체를 찾는다"는 형태의 쿼리에 탁월합니다. 예를 들어 "비밀번호를 암호화하고 안전하게 저장하는 함수를 찾아줘"라고 하면, 함수 이름을 한 번도 언급하지 않았더라도 hash_password를 찾아냅니다.
그 경계 또한 매우 명확하게 측정되었습니다: 의미적 간극 (semantic gap)은 메울 수 없습니다 (아티클 06, 07). 실제 세상에서 calculate_order_total과 "Stripe 결제"는 진정으로 다른 것이며, 어떤 임베딩 기술로도 이 둘을 더 가깝게 끌어당길 수 없습니다.
경로 2: 그래프 검색 (graph retrieval) — 구조적 관계 (structural-relationship) 쿼리의 본거지.
Article 05는 콜 그래프 (call graph)가 담고 있는 구조적 정보가 도달할 수 없는 직교 진리 벡터 (orthogonal truth vector can't reach) 임을 증명했습니다 (실험적으로 검증됨): Q8은 process_checkout → calculate_order_total 호출 엣지 (call edge)를 통해 두 단계(two hops) 만에 다시 끌어올려졌습니다. 하지만 Article 05는 그 비용 또한 측정했습니다. 단순한 BFS 2-hop 확장은 후보군 (candidate set)을 부풀려, Q1의 정확히 적중했던 verify_password를 top-5에서 밀어내 버렸습니다. 결과적으로 하나는 고정되었고 하나는 깨져서, 순이익은 0이 되었습니다.
결론은 미묘합니다: 방향은 맞지만, 방법이 조잡합니다. 그래프 신호 (graph signal)는 가치가 있지만, "검색 (retrieval) 후에 단순한 BFS 확장을 수행하는 것"은 이를 사용하는 잘못된 방법입니다. 이 교훈은 아래 아키텍처에서 그래프 검색 (graph retrieval)의 역할을 직접적으로 결정합니다.
경로 3: 심볼 검색 (symbol retrieval) — 정확한 일치 (exact-match) 쿼리의 본거지.
이전의 다섯 개 아티클은 이 경로에 대한 전용 실험을 수행하지 않았지만, 그 가치는 자명하며 증명이 필요 없습니다. 쿼리 자체가 정밀한 심볼 (symbol) 일 때 — "validate_jwt_token이라는 이름의 함수가 어디에 정의되어 있는가", "어떤 파일들이 redis.Redis를 임포트(import)하는가" — 당신이 원하는 것은 "의미론적으로 가까운 것 (semantically close)"이 아니라 "문자 그대로의 정확한 일치 (literal exact hit)"입니다. 여기서 벡터 (vector)는 견과류를 깨는 데 망치를 쓰는 격이며, 의미론적 근사치 (semantic approximation)에 의해 쉽게 길을 잃을 수 있습니다. 반면 grep이나 함수 이름 → 파일 → 줄 번호 (function-name → file → line-number) 심볼 테이블 (symbol table)은 10밀리초 (milliseconds) 미만으로 정확한 결과를 반환합니다.
세 영역을 나란히 놓으면 아름다운 사실이 드러납니다 — 그들은 거의 겹치지 않습니다:
쿼리 유형 최적 경로 예시
──────────────────────────────── ────────── ─────────────────────────
NL 능력 설명, 구현 찾기 Vector "JWT를 검증하는 함수를 찾아줘"
...
01번 기사에서 다룬 "4가지 지식 계층 (four knowledge layers)" 프레임워크는 이 구조에 깔끔하게 매핑됩니다: 구문 계층 (syntax layer, AST/symbol), 의미 계층 (semantic layer, vector), 아키텍처 계층 (architecture layer, graph), 그리고 의도 계층 (intent layer, Git history). 프로덕션 시스템의 제1원칙은 이 네 가지 신호가 각각 독립적이며 어느 것도 대체 불가능하다는 점을 받아들이는 것입니다. 네 가지 계층 모두를 지배하는 단 하나의 "최선의 검색 방법 (best retrieval method)"란 존재하지 않습니다.
원칙 1: 다중 경로 검색, 각자의 역할에 충실할 것
첫 번째 원칙이자 가장 직관에 어긋나는 원칙은 다음과 같습니다: "단 하나의 최선의 검색 방법"을 찾는 것을 멈추십시오.
그것이 바로 우리가 지난 다섯 편의 기사 동안 했던 행동이었습니다. "벡터 (vector)가 작동할까? BM25가 작동할까? 하이브리드 (hybrid)가 작동할까?"라고 끊임없이 자문하며, 매번 하나의 경로가 모든 쿼리를 지배할 수 있다고 도박을 걸었습니다. 다섯 번의 연속된 실패 끝에 우리는 인정할 수밖에 없었습니다. Q8과 같은 구조적 쿼리에 대해서는 텍스트 경로가 물리적으로 도달할 수 없다는 사실을 말입니다.
관점을 바꾸십시오: 모든 것을 다 잘하는 올라운더를 찾는 것이 아니라, 각 경로가 가장 잘하는 일만 하게 두는 것입니다. 의미론을 위한 벡터 (vector), 구조를 위한 그래프 (graph), 정확한 매칭을 위한 심볼 (symbol) — 세 가지 경로가 병렬로 검색을 수행하며, 각자 자신의 영역을 커버합니다.
비유를 들자면, 이는 병원의 트리아주 (triage, 환자 분류) 데스크와 같습니다. 일반의(GP) 한 명이 심장 우회술, 치아 발치, 혈액 검사를 한꺼번에 처리하기를 기대하지는 않을 것입니다. 먼저 분류를 해야 합니다. 골절은 정형외과로, 치통은 치과로, 혈액 검사는 검사실로 보냅니다. 각 부서는 자신의 영역에서는 전문가이지만, 그 외의 영역에서는 아마추어입니다. 검색도 마찬가지입니다. 정확한 심볼 매칭을 위해 벡터를 사용하는 것은 치과의사에게 심장 우회술을 맡기는 것과 같습니다. 물론 그는 기술적으로 심장이 무엇인지 알고 있겠지만, 당신은 정말로 그가 수술하기를 원치 않을 것입니다.
이 원칙은 앞선 다섯 편의 글에서 얻은 교훈에서 직접적으로 이어집니다. 우리는 이미 다섯 편의 글 분량에 달하는 실패를 통해, 하나의 신호(signal)에 맞지 않는 역할을 강요했을 때 어떤 일이 발생하는지 증명했습니다. Vector는 Q8의 구조적 관계를 파악하도록 강요받았고, 다섯 편의 글에 걸쳐 시도했으나 결코 도달하지 못했습니다. 그러니 Graph가 그 역할을 수행하게 하고, Vector는 자신의 본래 영역인 의미론적(semantic) 영역으로 돌아가게 하십시오.
주의(Flag): 아키텍처 방향으로서의 "Multi-path retrieval (다중 경로 검색)"은 Article 05/07의 실험적 결론에서 직접 도출된 설계 원칙입니다. 각 경로의 영역은 실험적으로 뒷받침되어 있지만 (vector 0.958, graph는 Q8 문제 해결, symbol은 실험 불필요), "세 경로를 하나의 전체 시스템으로 결합한 후의 전체 Recall (재현율)"에 대해서는 아직 엔드 투 엔드(end-to-end) 실험 데이터가 없습니다. 즉, 이것은 실험적 결론이 아니라 설계 권장 사항입니다.
원칙 2: 쿼리 라우팅(Query Routing), 의도에 따라 올바른 경로 활성화
세 가지 검색 경로가 있다면 즉시 다음과 같은 질문이 생깁니다: 쿼리가 들어왔을 때, 어떤 경로를 활성화해야 하는가?
매번 세 경로를 맹목적으로 모두 실행한 뒤 융합(fuse)한다면, 단순히 속도가 느려지는 것뿐만 아니라 경로들이 서로를 오염시킵니다. Article 07은 이를 시각적으로 보여주었습니다. Vector는 Q7에서 완벽한 1.00의 점수를 기록했지만, BM25가 빈도가 높은 일반 단어인 "execute"를 과도하게 매칭하면서 점수를 0.67로 끌어내렸고, 일단 RRF가 이들을 융합하자 좋은 경로가 나쁜 경로에 의해 함께 하락했습니다. 맹목적인 융합은 특정 쿼리에 취약한 경로가 해당 쿼리에 강한 경로를 끌어내리게 만듭니다.
따라서 우리에게는 **Query Router (쿼리 라우터)**가 필요합니다. 먼저 쿼리의 유형이 무엇인지 판단한 다음, 어떤 경로를 활성화할지 결정해야 합니다.
Query "where is the validate_jwt_token function" → Symbol route (정확한 일치)
Query "the function that validates JWT tokens" → Vector route (의미론적)
Query "what does createPayment call" → Graph route (구조적)
...
구현을 위해 처음부터 LLM을 사용할 필요는 없습니다. 대다수의 쿼리 의도는 몇 가지 규칙만으로도 식별할 수 있습니다:
-
쿼리에 정확한 식별자(
snake_case/camelCase함수 이름,import된 모듈 이름)가 포함된 경우 → 심볼 (symbol) 경로를 우선합니다. -
구조적 키워드가 나타나는 경우 — "누가 호출하는지(who calls)", "무엇에 의존하는지(what does it depend on)", "호출 체인(call chain)", "전체 흐름(full flow)\
-
그래프 인덱스(graph index)와 벡터 인덱스(vector index)는 병렬로 구축되며, 둘 다 독립적인 일급 검색 진입점(first-class retrieval entry points)으로서 서로에게 의존하지 않습니다.
-
벡터 경로(vector route)는 자체적인 top-k를 취하며, 그래프 경로(graph route)는 독립적으로 실행됩니다. 즉, 쿼리에 언급된 함수/모듈 이름을 식별하고 그래프를 직접 탐색(
CALLS/CALLED_BY엣지를 따라)하여 구조적으로 관련된 함수들을 반환합니다. -
두 경로의 결과는 마지막에 **결합(fused)**됩니다. 이때 이 결합은 07편에서 다룬 문맥을 고려하지 않는 RRF(Reciprocal Rank Fusion)가 아니라, **쿼리 유형에 따른 가중치 병합(weighted merge by query type)**이라는 점에 유의하십시오. 구조적 쿼리에는 그래프 경로에 높은 가중치를 부여하고, 의미적(semantic) 쿼리에는 벡터 경로에 높은 가중치를 부여합니다.
한 다이어그램이 이러한 변화를 명확히 보여줍니다:
❌ 05편 (사후 처리 패치로서의 그래프)
쿼리 → 벡터 top-k → BFS 확장 → 재순위화(rerank)
↑ 여기서 그래프가 벡터 뒤를 따라오며 순위를 오염시킴
...
왜 이러한 변화가 05편의 문제를 해결할까요? 05편의 근본 원인은 "그래프 확장(graph expansion)이 벡터의 후보군을 부풀리고 벡터의 순위를 희석시켰기" 때문입니다. 그래프 경로가 자체적인 검색 로직과 트리거 조건(07편의 결론에서 권장된 방식: 높은 신뢰도의 함수에서만 트리거, 단 1홉(hop)만 수행, 비즈니스 규칙에 따라 이웃 필터링)을 가진 독립적인 경로가 되면, 더 이상 벡터의 후보 풀에 무분별하게 노이즈를 채워 넣지 않습니다. 그래프 경로는 Q8의 calculate_order_total을 다시 가져오고, 벡터 경로는 Q1의 verify_password를 유지하며, 각자 자신의 업무를 수행할 뿐 서로 간섭하지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기