본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 21. 13:30

나의 Financial Mentor 검색 시스템을 처음부터 다시 구축했다. RAG 스택을 통해 배운 모든 것

요약

금융 멘토 시스템(FinMentor)을 구축하며 겪은 RAG 스택의 시행착오와 개선 과정을 다룹니다. 모든 데이터를 JSON 형태로 모델에 주입하던 방식에서 벗어나, 계층적 청킹(Hierarchical Chunking)과 실시간 데이터 분리 전략을 통해 검색 효율성과 데이터 정확도를 높이는 방법을 설명합니다.

핵심 포인트

  • 불필요한 컨텍스트 주입은 모델의 주의력을 분산시키고 비용을 증가시키는 노이즈가 됨
  • 포트폴리오 데이터와 같이 구조화된 정보에는 부모-자식 관계를 활용한 계층적 청킹이 효과적임
  • 실시간으로 변하는 금융 데이터(가격, P&L 등)는 벡터 인덱스에 포함하지 말고 쿼리 시점에 직접 주입해야 함
  • 정확한 수치 계산을 위해 인덱싱된 정적 데이터와 실시간 동적 데이터를 분리하는 설계가 필수적임

JSON을 Claude에 밀어 넣는 것부터 GraphRAG, 하이브리드 검색 (hybrid search), CRAG, 그리고 적대적 평가 (adversarial evaluation)까지 — 솔직한 전체 기록. FinMentor의 문제는 내가 이를 설명할 어휘를 갖추기도 전부터 시작되었습니다... 사용자들은 자신의 포트폴리오에 대해 합리적인 질문을 던지고 있었습니다. 시스템은 그 질문에 답변했습니다. 어떤 답변은 맞았고, 어떤 답변은 틀렸습니다. 그리고 나는 그 패턴을 설명할 수 없었는데, 왜냐하면 모델로 실제로 무엇이 흘러 들어가고 있는지 살펴보지 않았기 때문입니다. 확인해 보니: 모든 쿼리(query)는 IBKR 포트폴리오 스냅샷 전체를 전달받고 있었습니다. JSON 형식으로 말이죠. 5개의 포지션, 월간 손익 (P&L), 30개의 거래 내역, 계정 메타데이터. 무엇을 물어보든 동일한 847개의 토큰(token)이었습니다. 섹터 집중도에 대한 질문에는 전체 거래 내역이 전달되었습니다. 단일 티커 (ticker)에 대한 질문에는 다른 모든 포지션 정보가 전달되었습니다. 주어진 질문에 관련 있는 컨텍스트 (context)는 아마 10% 정도였을 것입니다. 나머지 90%는 주의력을 분산시키고, 그 특권을 누리는 대가로 나에게 비용을 청구하는 노이즈 (noise)였습니다. 나는 검색 (retrieval)을 하고 있었던 것이 아니라, 불필요한 단계를 거친 복사-붙여넣기를 하고 있었던 것입니다.

제1막 — 3단계, 그리고 그중 하나에 숨어 있는 버그
이러한 단순한 방식에 대한 해결책은 개념적으로 간단합니다: 데이터를 인덱싱 (index)하고, 관련 있는 것만 검색하여, 이를 생성 컨텍스트 (generation context)에 주입하는 것입니다. 세 단계가 있으며, 각 단계는 결정 지점입니다. 인덱스 (index) 단계는 청킹 (chunking)이 일어나는 곳입니다. 토큰 경계에서의 고정 크기 분할 (fixed-size splitting)은 빠르고 단순합니다. 문단 구분, 섹션 전환과 같은 논리적 경계에서의 의미론적 분할 (semantic splitting)은 더 느리지만 의미 단위로 유지되는 청크 (chunk)를 생성합니다. 하나의 포지션 데이터가 함께 유지되어야 하는 포트폴리오 시스템의 경우, 계층적 청킹 (hierarchical chunking)이 중요하다는 것이 밝혀졌습니다: 부모 청크 (parent chunk)는 전체 포지션을 다루고, 자식 청크 (child chunk)는 개별 속성을 다루어, 쿼리가 필요로 하는 적절한 세밀도 (granularity)에 따라 각각 검색될 수 있도록 하는 방식입니다. 검색 (retrieve) 단계에서 나는 설명할 수 없었던 버그를 발견했습니다. FinMentor의 기존 인덱스에는 실시간 가격 데이터가 포함되어 있었습니다. 현재 가격, 일일 손익 (P&L), 시가 평가 (mark-to-market) 포지션 가치 등이 말이죠.

이 데이터들은 티커 (ticker), 취득 원가 (cost basis), 계정 메타데이터 (account metadata)와 같은 안정적인 데이터와 함께 인덱싱 (indexing)되었습니다. 인덱스가 새로고침 사이클 (refresh cycle)을 실행할 때마다 가격이 업데이트되었습니다. 하지만 쿼리 (query)가 실행될 때, 새로고침 사이의 약간 오래된 스냅샷 (snapshot)을 참조하는 문제가 발생했습니다. NVDA 주당 가격이 875달러일 때, 100주 포지션에 대해 하루치 데이터가 뒤처진 인덱스를 사용하면 사용자의 보고된 포트폴리오 가치에서 2,250달러의 오차가 발생합니다. 시스템은 매우 확신에 찬 구체적인 수치를 생성했지만, 실제 IBKR 피드 (feed)와 비교하기 전까지는 완전히 보이지 않는 방식으로 틀린 값을 내놓고 있었습니다. 문제를 파악하고 나면 분류는 간단합니다. 실시간 금융 데이터는 절대로 벡터 인덱스 (vector index)에 있어서는 안 됩니다. 쿼리 시점에 신선한 데이터를 가져와서 직접 주입해야 합니다. 금융 문맥에서 인덱스의 데이터 노후화 (staleness)는 성능 버그가 아닙니다. 그것은 정확도 버그 (accuracy bug)입니다. 검색 (retrieval) 단계에서 바로 이 차이를 강제해야 합니다.

제2막 — 실제 사용자가 등장했을 때 마주한 벽

나 자신의 쿼리로 FinMentor의 검색 성능을 테스트했을 때는 자랑스러울 만한 수치가 나왔습니다. 문맥 재현율 (context recall) 0.89, 충실도 (faithfulness) 0.91이었습니다. 내가 직접 골든 데이터셋 (golden dataset)을 구축했고, 인덱스를 직접 작성했기 때문에 인덱스에 어떤 어휘가 들어있는지 알고 있었습니다. 하지만 첫 번째 실제 사용자 세션에서 즉시 문제가 터졌습니다. 사용자가 기술주에 대한 Goldman의 견해를 물었습니다. 나의 테스트 데이터셋에는 "Goldman Sachs"가 사용되었습니다. 하지만 인덱스에는 다양한 애널리스트 보고서에 걸쳐 "GS Equity Research", "Goldman analysts", "GS research team"이 포함되어 있었습니다. 이는 임베딩 공간 (embedding space)에서 서로 다른 세 개의 개체 표현 (entity representations)이며, 각각 서로 다른 벡터 이웃 (vector neighborhood)에 위치합니다. "Goldman"을 언급한 쿼리는 그중 하나만 안정적으로 검색하고 나머지 두 개는 놓쳤습니다. 사용자 쿼리에 대한 문맥 재현율 (context recall)은 0.58이었습니다. 이는 어휘 불일치 (vocabulary mismatch) 문제이며, 금융 데이터에서는 예측 가능한 구조를 가집니다. 티커 (ticker) 대 회사 이름. Fed 대 Federal Reserve 대 FOMC. 공식적인 쿼리 언어 대 일상적인 대화형 언어. 테스트 쿼리를 작성하는 방식과 사용자가 실제로 질문하는 방식 사이의 간극은 직관보다 훨씬 더 넓습니다.

세 가지 해결책: 하이브리드 검색 (Hybrid search)은 희소 검색 (Sparse retrieval, 정확한 토큰 문자열을 일치시키는 BM25)과 밀집 검색 (Dense retrieval, 의미를 일치시키는 시맨틱 임베딩 (Semantic embeddings))을 결합합니다. 상호 순위 결합 (Reciprocal Rank Fusion)은 이 두 개의 순위 목록을 하나로 합칩니다. BM25는 쿼리가 "Goldman"이라고 되어 있어도 "GS Equity Research"라는 정확한 문자열을 잡아냅니다. 시맨틱 검색 (Semantic search)은 어휘가 일치하지 않더라도 개념적 중첩을 잡아냅니다. 이들이 함께 작동하면 어느 한쪽이 단독으로 놓칠 수 있는 사례들을 모두 커버할 수 있습니다. HyDE (Hypothetical Document Embeddings, 가설 문서 임베딩)는 "사람들이 기술주에 대해 뭐라고 말하고 있나"와 같은 모호한 쿼리를 처리합니다. 쿼리의 임베딩으로 검색하는 대신, 질문에 답이 될 법한 가설적인 애널리스트 발췌문을 생성한 다음, 그 발췌문의 임베딩으로 검색합니다. 가설 문서는 인덱스(Index)가 실제로 포함하고 있는 어휘를 사용합니다. 따라서 임베딩이 더 풍부한 이웃 영역에 위치하게 됩니다. 쿼리 분해 (Query decomposition)는 다중 의도 질문을 처리합니다. "내 기술주 집중도가 섹터 벤치마크 대비 어느 정도이며, Goldman은 이에 대해 뭐라고 말하는가?"는 두 번의 검색이 필요합니다. 검색 전에 이를 분해하고, 둘 다 실행한 뒤, 생성(Generation) 전에 병합합니다. 저의 시간을 가장 많이 아껴준 디버깅 순서는 다음과 같습니다: 출력(Output)으로부터 상류(Upstream) 방향으로 작업하십시오. 잘못된 답변 → 먼저 검색된 청크(Retrieved chunks)를 확인합니다. 청크가 괜찮다면 → 인덱스(Index)를 확인합니다. 인덱스가 괜찮다면 → 라우팅(Routing)을 확인합니다. 문제가 검색(Retrieval)에 있을 수 있다면 절대 생성(Generation) 단계를 디버깅하지 마십시오. 각 계층은 별개입니다. 순서대로 테스트하십시오.

제3막 — 아무런 의미도 없었던 평가 점수. 검색 성능 개선 후, RAGAS 충실도 (Faithfulness) 점수는 0.92에 도달했습니다. 저는 시스템이 프로덕션(Production) 준비가 되었다고 생각했습니다. 하지만 곧 첫 사용자 피드백이 도착했습니다. 시스템이 사용자의 포트폴리오에 대해 사실이 아닌 내용을 말한 것입니다. 확신에 찬 어조, 구체적인 숫자, 하지만 틀린 답변이었습니다. 저는 골든 데이터셋 (Golden dataset)을 다시 훑어보았습니다. 데이터셋의 모든 질문은 인덱스 안에 답이 있었습니다. 저는 시스템이 '답할 수 있는 질문'에 얼마나 잘 답하는지를 측정하고 있었던 것입니다. 시스템이 '답할 수 없을 때' 무엇을 하는지는 전혀 측정하지 않았습니다.

적대적 (Adversarial) 카테고리는 지식 베이스 (Knowledge Base)에 답이 존재하지 않는 질문들입니다. 예를 들어, '워런 버핏이 이번 분기에 반도체에 대해 무엇이라고 말했는가?', '연준 (Federal Reserve)의 다음 금리 결정은 무엇인가?', '테슬라 주식을 사야 하는가?'와 같은 질문들입니다. 올바른 동작은 명시적인 거절입니다. 잘못된 동작은 훈련 데이터 (Training Data)로부터 근거가 있는 것처럼 들리지만 실제로는 그렇지 않은, 자신감 있게 합성된 답변을 내놓는 것입니다. 평가 세트 (Eval Set)에 적대적 사례를 추가하기 전, 시스템의 적대적 통과율 (Adversarial Pass Rate)은 60%였습니다. 범위를 벗어난 질문 10개당 4개는 거절 대신 답변을 받았습니다. 금융 맥락에서 이는 품질 지표 (Quality Metric)가 아니라 부채 지표 (Liability Metric)입니다. 해결책은 하이브리드 검색 (Hybrid Retrieval, 정밀도가 높아지면 낮은 관련성의 청크가 생성 단계로 전달되는 것을 줄임), 더 엄격한 시스템 프롬프트 (System Prompt, 문맥이 불충분할 때 거절하라는 명시적 지침), 그리고 CRAG (Corrective Retrieval-Augmented Generation)의 조합이었습니다. CRAG는 생성 전 관련성을 평가하는 게이트 (Gate) 역할을 하며, 관련성이 낮은 검색 결과는 생성 대신 거절로 명시적으로 라우팅합니다. 이 세 가지를 적용한 후, 적대적 통과율은 95%가 되었습니다.

제가 측정할 수 있는 것을 변화시킨 평가 프레임워크 (Evaluation Framework)의 두 가지 추가 사항은 다음과 같습니다. 사고 사슬 (Chain-of-Thought)이 없는 LLM-as-judge 방식은 두 가지 체계적인 편향 (Bias)을 보입니다. 답변이 길수록 높은 점수를 받는 '장황함 편향 (Verbosity Bias)'과 답변 초반의 주장이 후반의 주장보다 더 큰 비중을 차지하는 '위치 편향 (Position Bias)'입니다. 심사역 (Judge)이 점수를 부여하기 전에 구체적인 사실적 주장들을 나열하고, 각 주장을 검색된 문맥과 대조하도록 강제하는 G-Eval 접근 방식은 이 두 가지를 모두 제거합니다. 이를 통해 심사역은 자신이 평가한다고 명시한 내용을 실제로 평가하게 됩니다. Claude가 생성한 사용자 쿼리 변형들 — "내 애플 주식 어때?", "AAPL에 무엇을 가지고 있지?", "내 애플 보유 현황을 보여줘" — 은 작성자가 직접 쓴 쿼리가 완전히 놓치는 어휘의 표면 영역 (Vocabulary Surface Area)을 커버합니다. 이들은 실제 세션 데이터 (Session Data)를 대체하는 것은 아닙니다.

하지만 이는 여러분이 직접 작성한 쿼리(Query)로만 테스트하는 것보다 훨씬 의미 있게 더 나은 방법입니다. 실패 모드(Failure Modes)를 명확하게 파악할 수 있게 해준 진단 테이블(Diagnostic Table)은 다음과 같습니다:

Faithfulness (충실도)Context Recall (문맥 재현율)의미해결책
Low (낮음)High (높음)생성(Generation)은 괜찮으나 검색(Retrieval)이 문제임검색(Retrieval) 수정
High (높음)Low (낮음)검색(Retrieval)은 괜찮으나 생성(Generation)이 문제임생성(Generation) 수정
Low (낮음)Low (낮음)둘 다 낮음 — 검색(Retrieval)을 먼저 수정하세요. 생성(Generation) 문제가 이를 악화시킵니다.검색(Retrieval) 우선 수정

각 조합은 특정 계층(Layer)을 가리킵니다. 무엇인가를 건드리기 전에 이 조합을 읽는 법을 배우세요.

Act 4 — 벡터 인덱스(Vector Index)가 답할 수 없었던 질문

한 사용자가 다음과 같이 질문했습니다: "AAPL과 MSFT는 섹터 리스크(Sector Risk) 측면에서 어떤 관계가 있나요?"

두 청크(Chunk) 모두 인덱스에 있었습니다. 각각의 쿼리에 대해서는 검색(Retrieval)이 잘 이루어졌습니다. AAPL의 청크에는 'Technology sector(기술 섹터)'가 언급되어 있었고, MSFT의 청크에도 'Technology sector(기술 섹터)'가 언급되어 있었습니다. 검색(Retrieval)은 두 정보를 모두 찾아냈습니다.

하지만 그 후 생성(Generation) 단계에서 각 주식을 독립적으로 설명하는 답변을 내놓았습니다. 왜냐하면 그들 사이의 관계 — 즉, 공유된 섹터 멤버십(Sector Membership), 상관관계가 있는 낙폭(Drawdown) 행동, 결합된 집중 리스크(Concentration Risk) — 가 텍스트로서 인덱스 어디에도 존재하지 않았기 때문입니다. 연결 고리가 암시적(Implicit)이었습니다. 검색(Retrieval)에는 그것이 명시적(Explicit)이어야 합니다.

GraphRAG는 엔티티(Entity)를 노드(Node)로, 관계(Relationship)를 엣지(Edge)로 저장합니다. Claude를 통해 말뭉치(Corpus)에서 그래프 요소(타입이 지정된 엔티티, 라벨이 지정된 관계)를 추출한 후, NetworkX 유향 그래프(Directed Graph)가 벡터 인덱스(Vector Index)가 할 수 없었던 것을 수행합니다.

AAPL --[belongs_to]--> Technology SectorMSFT --[belongs_to]--> Technology Sector라는 엣지는 이제 일급 객체(First-class) 사실이 됩니다. 사용자가 연준(Fed) 정책이 그들의 기술주 포지션에 어떻게 영향을 미치는지 물으면, 시스템은 다음과 같이 경로를 탐색합니다:

Fed Rate Hike (연준 금리 인상) → affects (영향을 미침) → Tech Sector Valuations (기술 섹터 밸류에이션) → compresses (압축함) → Growth Stock Multiples (성장주 멀티플) → AAPL is_a (AAPL은 ~이다) → Growth Stock (성장주)

답변은 이 경로로부터 조립됩니다. 단일 문서에는 이 내용이 포함되어 있지 않았습니다.

엔티티 해상도(Entity Resolution)는 이를 대규모로 유용하게 만드는 실질적인 승리입니다. 50개의 애널리스트 보고서 전체에서 'Goldman Sachs'는 세 개의 서로 다른 문자열로 나타납니다. 벡터 인덱스(Vector Index)는 이를 세 개의 별개 엔티티로 취급합니다. 하지만 지식 그래프(Knowledge Graph)는 인덱싱 시점에 한 번의 해상도(Resolution) 과정을 거친 후, 세 개를 모두 하나의 정형화된 노드(Canonical Node)로 병합하고 50개의 보고서를 모두 그곳에 연결합니다.

모든 쿼리 변형(Query variant)은 동일한 지점으로 수렴합니다. 비용은 단 한 번만 지불됩니다. 이점은 영구적입니다. 반대 사례(Counter-case)는 일반적인 사례만큼이나 중요합니다. 의미 있는 엔티티 관계(Entity relationships)가 없는 만 개의 독립적인 FAQ 문서가 있다고 가정해 봅시다. GraphRAG는 청크(Chunk)당 추출 비용, 그래프 구축 지연 시간(Latency), 엔티티 해소(Entity resolution) 유지 관리

공식적인 어휘(Formal vocabulary)에 대해서는 검색 성능이 뛰어나지만, 일상적인 어휘(Casual vocabulary)에 대해서는 커버리지가 전혀 없는 시스템은 개발자에게는 유효할지 몰라도 사용자에게는 실패한 시스템입니다. 전체 JSON 스냅샷(Snapshot) 위에서 작동하던 FinMentor 버전은 기술적으로는 작동하는 시스템이었습니다. 답변을 생성했고, 어떤 답변들은 맞았습니다. 하지만 인덱싱(Indexed), 라우팅(Routed), 하이브리드 검색(Hybrid-retrieved), CRAG-게이트(CRAG-gated), RAGAS-평가(RAGAS-evaluated), 적대적 테스트(Adversarially-tested) 파이프라인 위에서 실행되는 버전은 완전히 다른 차원의 것입니다. 이는 개별 구성 요소가 똑똑해서가 아니라, 각 계층(Layer)이 바로 아래 계층에서 해결하지 못한 특정 실패 모드(Failure mode)를 해결하기 때문입니다. 그것이 바로 스택(Stack)의 본질입니다. 4주 동안 시스템이 고장 나는 구체적인 방식들을 찾아내고, 그때마다 새로운 계층을 추가해 왔습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0