본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 02. 17:26

RAG 파이프라인에 웹 검색 추가하기: 무엇이 망가졌고 그 이유는 무엇인가

요약

로컬 RAG 파이프라인에 웹 검색 기능을 통합할 때 발생하는 기술적 문제와 해결 방안을 다룹니다. 점수 산정 오류, 안전 모델의 오작동, 교차 인코더의 랭킹 문제 등 실제 구현 과정에서 마주한 시행착오를 공유합니다.

핵심 포인트

  • 웹 검색을 별도 케이스가 아닌 하나의 검색 단계로 취급할 것
  • 점수 정규화를 먼저 수정하여 임계값 문제를 해결할 것
  • 안전 장치는 계층화하되 예측 가능성을 위해 코드 기반으로 관리할 것
  • 비용 절감을 위해 고비용 단계 이전에 공격적인 사전 필터링 적용

우리는 로컬 RAG (Retrieval-Augmented Generation) 파이프라인에 웹 검색을 추가했습니다. 겉보기에는 간단해 보였습니다.

하지만 그렇지 않았습니다.

그 결과 발생한 일들은 다음과 같습니다:

  • 점수 산정 (scoring)에 대한 잘못된 가정
  • 예상과 다르게 동작하는 안전 모델 (safety model)
  • 너무 오랫동안 유지되어 버린 "임시" 우회책
  • 그리고 콘텐츠 대신 탐색 메뉴를 기쁘게 랭킹화해 버린 교차 인코더 (cross-encoder)

실제로 무엇이 망가졌는지, 그리고 우리가 어떻게 이를 해결했는지 설명하겠습니다.

요약 (TL;DR)

  • 웹 검색 (web retrieval)을 특별한 케이스가 아닌, 또 다른 검색 단계 (retrieval leg)로 취급할 것
  • 안전 (safety)에는 계층이 필요하지만, 기본 설정(baseline)을 구성 불가능하게 만들어서는 안 됨
  • 임계값 (thresholds)을 건드리기 전에 점수 정규화 (score normalization)를 먼저 수정할 것
  • 비용이 많이 드는 단계 이전에 공격적으로 사전 필터링 (pre-filter)할 것
  • 어떤 우회책(workaround)이든 그것이 해결하려 했던 문제보다 더 오래 살아남을 것

1. 네 번째 검색 단계 추가

시작점은 꽤 표준적인 하이브리드 RAG 설정이었습니다: 벡터 검색 (ChromaDB), BM25, 그리고 그래프 인덱스(graph index)를 사용했으며, 이 모두를 상호 순위 융합 (Reciprocal Rank Fusion, RRF)을 통해 병합했습니다.

웹 검색을 추가하는 것은 당연한 확장처럼 느껴졌습니다: 그저 또 다른 검색기 (retriever)를 추가하는 것뿐이니까요. 결과를 가져오고, 이를 래핑(wrap)하여 RRF에 입력한 다음, 동일한 재순위화 (reranking) 및 필터링 파이프라인이 모든 것을 처리하도록 하면 되었습니다.

그 부분은 잘 작동했습니다. 웹 결과는 로컬 결과와 정확히 동일하게 시스템을 통해 흘러갔습니다.

돌이켜보면 중요한 부분은 이것이었습니다:

하위 단계(downstream)의 어떤 것도 문서가 웹에서 왔는지 아니면 로컬 코퍼스 (local corpus)에서 왔는지 알 필요가 없었습니다. 덕분에 아키텍처를 깔끔하게 유지할 수 있었지만, 이는 점수 산정이나 필터링의 결함이 모든 것에 동시에 영향을 미친다는 것을 의미하기도 했습니다.

2. 안전은 단순한 "필터"가 아니다 (safeguards)

금방 명확해진 한 가지는 다음과 같습니다: 웹 검색 (web retrieval)은 단순한 검색이 아닙니다. 그것은 부수 효과 (side-effect)입니다. 사용자의 쿼리를 네트워크 호출 (network call)로 전환하는 것이기 때문입니다.

그런 관점에서 바라보면, 안전 (safety)은 시스템 정확성 (system correctness)의 일부가 됩니다.

우리는 결국 계층화된 안전 장치 (safeguards) 세트를 구축하게 되었습니다:

  • 절대 통과해서는 안 되는 카테고리에 대한 강력한 차단 (hard blocks)
  • 인젝션 (injection) 및 명백한 공격에 대한 패턴 필터 (pattern filters)
  • 경계선에 있는 사례를 위한 경량 의도 분류기 (intent classifier)
  • 외부로 무엇인가를 보내기 전의 쿼리 정화 (query sanitation)

핵심적인 결정은 베이스라인(baseline)을 어디에 둘 것인가였습니다. 우리는 이를 설정(config)이 아닌 코드(code)에 유지했습니다. 설정은 무언가를 조일 수만 있을 뿐, 결코 완화할 수는 없기 때문입니다.

이는 준수(compliance)의 문제라기보다 예측 가능성(predictability)의 문제였습니다. 만약 설정(config)을 통해 동작이 조용히 변할 수 있다면, 프로덕션(production) 환경에서 시스템이 실제로 무엇을 수행할지 추론하기 어려워집니다.

3. 두 개의 불리언(boolean) 함정

우리는 하나의 개념을 두 개의 불리언(boolean)으로 표현하는 전형적인 실수를 저질렀습니다.

_ALLOW_WEB_SEARCH
_WEB_SEARCH_DRY_RUN

우리가 원했던 상태는 세 가지였습니다:

  • 꺼짐 (off)
  • 드라이 런 (dry run)
  • 라이브 (live)

두 개의 불리언은 네 가지 상태를 만듭니다. 그중 하나는 아무런 의미가 없었지만, 어쨌든 존재했습니다.

결국 우리는 이를 하나의 명시적인 모드 플래그(mode flag)로 교체했습니다. 실제로 파이프라인의 나머지 부분이 안정화되고 안전장치(safeguards)가 마련되자, 드라이 런(dry-run) 모드는 더 이상 필요하지 않게 되어 제거되었습니다.

더 넓은 교훈은 여전히 유효합니다: 제어 흐름(control flow)은 잘못된 상태(invalid states)를 표현하는 것이 불가능하도록 설계되어야 합니다.

4. 리랭킹(reranking) 전 필터링

웹 검색 결과가 시스템에 들어온 후, 다음 문제는 성능에서 나타났습니다.

크로스 인코더(Cross-encoder) 리랭킹(reranking)은 비용이 많이 들며, 웹 검색 결과는 노이즈(noisy)가 많습니다. 나중에 대부분을 버리기 위해 평범한 스니펫(snippets) 뭉치를 리랭킹 과정에 통과시키는 것은 낭비였습니다.

우리는 리랭킹(reranking) 전에 두 가지 경량 필터(lightweight filters)를 추가했습니다:

  • 스니펫 자체에 대한 BM25
  • 임베딩 모델(embedding model)을 사용한 코사인 유사도 (cosine similarity)

대단한 것은 아니었습니다. 그저 명백히 약한 후보들을 조기에 버리기 위한 저렴한 방법들이었습니다.

이를 통해 크로스 인코더(cross-encoder)의 부하를 줄이는 동시에 출력 품질을 개선할 수 있었습니다. 아마도 전체 파이프라인에서 가장 적은 노력으로 가장 큰 효과를 본 변화였을 것입니다.

5. 너무 오래 남아있었던 바이패스(bypass)

초기에는 웹 검색 결과가 리랭크 임계값(rerank threshold)을 완전히 건너뛰도록(skip) 허용했습니다.

그 논리는 타당해 보였습니다. 검색 엔진이 이미 결과를 순위 매기고 있으며, 스니펫(snippets)은 종종 크로스 인코더(cross-encoder) 입장에서 전체 문서보다 더 나빠 보이기 때문입니다.

실제로 이는 다른 무언가를 보완하고 있었던 것이었습니다. 바로 우리의 정규화(normalization)가 잘못되어 있었던 것입니다.

정규화(normalization)를 수정한 후, 이 바이패스(bypass)는 문제가 되었습니다:

  • 저품질 스니펫(snippets)이 그대로 통과됨
  • 이들이 더 나은 로컬 결과들을 밀어냄
  • 모델이 더 관련성 높은 로컬 데이터보다 짧고 깔끔한 웹 콘텐츠를 선호하기 시작함

우리는 바이패스(bypass)를 제거하고 대신 웹 결과에 대한 별도의 임계값(threshold)을 도입했습니다.

중요한 점은 해결책 자체가 아니라, 바이패스가 그 목적을 다했다는 사실을 깨달은 것이었습니다.

고장 난 시스템을 위해 도입된 임시 방편(workaround)은, 의도적으로 제거하지 않는 한 시스템이 수리된 후에도 여전히 남아 있을 것입니다.

6. 정규화(normalization) 수정하기

이것이 가장 큰 문제였습니다.

크로스 인코더(Cross-encoders)는 가공되지 않은 로짓(logits)을 출력하며, 우리는 처음에 이를 [0, 1] 범위로 매핑하기 위해 시그모이드(sigmoid) 함수를 적용했습니다. 원칙적으로는 타당해 보였습니다.

하지만 작동하지 않았습니다.

기술적인 콘텐츠—특히 Q&A 스타일이 아닌 텍스트—는 모든 곳에서 낮은 점수를 받았습니다. 임계값(thresholding)을 적용한 후에는 아무것도 살아남지 못했습니다. 시스템은 조용히 모델의 내부 지식(internal knowledge)으로 회귀했습니다.

대신 효과적이었던 방법은 접근 방식을 분리하는 것이었습니다:

  • 로컬 문서: 최소-최대 정규화(min–max normalization), 결과 풀(result pool) 내에서 상대적 적용
  • 웹 결과: 시그모이드(sigmoid), 절대적 수치로 취급

이것이 수학적으로 완벽하지는 않지만, 우리가 실제로 필요로 했던 동작과 일치했습니다:

  • 로컬 결과들은 서로 경쟁함
  • 웹 결과들은 그 자체의 가치로 판단됨

7. 크로스 인코더(cross-encoder)가 실제로 보는 것

한때 우리는 관련성을 높일 수 있을 것이라 생각하여 웹 결과에 대해 전체 페이지 가져오기(full-page fetching)를 활성화했습니다.

하지만 효과가 없었습니다.

크로스 인코더는 약 512개의 토큰(tokens)만 볼 수 있습니다. 웹 페이지의 경우, 이는 보통 다음과 같습니다:

  • 헤더(headers)
  • 내비게이션(navigation)
  • 메타데이터(metadata)

유용한 문단은 대개 훨씬 더 아래에 있습니다.

결과적으로 모델은 페이지의 크롬(chrome, UI 요소)을 랭킹 매기고 있었던 셈입니다.

해결책은 간단했습니다:

  • 항상 원래의 검색 스니펫(search snippet)을 유지할 것
  • 재순위화(reranking)에는 스니펫을 사용할 것
  • 선택적으로 전체 페이지를 LLM에 보낼 것

단계마다 서로 다른 입력값이 필요합니다. 이는 모든 단계를 통합하려고 시도한 후에야 명확해졌습니다.

8. 좁은 범위의 의도 필터링(Intent filtering)

의도 분류기(intent classifier)는 결국 작은 가드레일(guardrail) 역할을 하게 되었습니다.

이것은 완벽함을 추구하는 것이 아니라, 다른 안전장치(safeguards)를 통과해 버리는 것들을 잡아내는 역할을 합니다:

  • 개체(entity) + 동작(action)의 조합
  • 구문론적으로는 깨끗하지만 명백히 문제가 있는 쿼리(queries)

두 가지 제약 사항이 예측 가능성을 유지해 주었습니다:

  • 베이스라인(baseline)은 코드 내에 고정되어 있음
  • 설정(config)은 베이스라인에 내용을 추가하거나 더 엄격하게 만들 수만 있음

이는 다른 안전 조치들을 대체하는 것이 아니라, 단지 추가적인 계층(layer)일 뿐입니다.

9. 우리가 (아직) 최적화하지 않은 것들

우리가 즉시 추진하지 않은 몇 가지 명백한 확장 기능들이 있습니다:

  • 다중 검색 제공자(multiple search providers)
  • 필터링 전 쿼리 번역
  • 웹 검색을 위한 다중 쿼리 확장(multi-query expansion)

이 모든 것들은 특히 네트워크 부작용(network side-effects)이 개입될 때 트레이드오프(tradeoffs)가 발생합니다. 우리는 먼저 핵심 파이프라인(core pipeline)을 안정화하는 것을 선택했습니다.

한 줄 요약

웹 검색은 단순히 덧붙이는 기능이 아닙니다. 그것은 파이프라인 전체의 동작을 변화시킵니다.

쿼리가 네트워크 호출(network calls)로 변하는 순간, 점수 산정(scoring), 필터링(filtering), 그리고 안전장치(safeguards)는 이전과는 다른 방식으로 모두 결합됩니다. 대부분의 복잡성은 이러한 상호작용을 예측 가능하게 만드는 과정에서 발생했습니다.

다른 분들은 이를 어떻게 처리하는지 궁금합니다:

  • 크로스 인코더(cross-encoder) 점수를 전역적으로 정규화(normalize)하시나요, 아니면 쿼리별로 하시나요?
  • 정규화 전략을 혼합하는 대신 로짓(logits)을 보정(calibrating)하여 성공한 사례가 있나요?

구현 내용을 자세히 살펴보고 싶다면:

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0