당신의 Node.js RAG가 잘못된 소스를 가져오고 있다면: 4MB Cross-encoder를 이용한 해결책
요약
Node.js 환경에서 RAG 파이프라인의 검색 정확도를 높이기 위해 크로스-인코더(Cross-encoder)를 활용하는 방법을 소개합니다. 바이-인코더의 한계를 극복하고 로컬에서 가볍게 실행 가능한 flashrank-js 라이브러리 사용법을 다룹니다.
핵심 포인트
- 바이-인코더의 정보 손실 문제를 크로스-인코더로 해결 가능
- 2단계 RAG 아키텍처(ANN 검색 후 리랭킹) 권장
- flashrank-js를 통한 Node.js 로컬 리랭킹 구현
- API 비용과 네트워크 지연 시간 없이 정확도 향상
JS RAG 튜토리얼에서 아무도 말하지 않는 버그
당신은 Node.js 검색 파이프라인 (retrieval pipeline)을 구축합니다. OpenAI 또는 Voyage로 문서를 임베딩 (embedding)하고, Pinecone 또는 pgvector에 저장한 뒤, top-5 쿼리를 생성하여 Claude 또는 GPT에 전달합니다. 데모는 잘 작동합니다.
하지만 프로덕션 (production)에 배포하면 사용자들은 동일한 문제를 발견합니다:
"봇이 잘못된 문서를 인용했습니다."
평가 (eval) 단계에서는 검색이 정상적으로 보였습니다. 그럴듯하게 관련된 5개의 청크 (chunks)를 가져왔으니까요. 하지만 LLM이 인용할 대상을 잘못 선택한 이유는, 리스트의 맨 위에 '잘못된 것'이 있었기 때문입니다.
이것이 바로 바이-인코더 (bi-encoder) 문제입니다.
임베딩 유사도 (embedding similarity)가 부족한 이유
벡터 DB (vector DB)에서 cosine(query_vec, doc_vec)를 수행할 때, 당신은 바이-인코더 (bi-encoder)를 사용하고 있는 것입니다. 즉, 쿼리 (query)와 문서 (doc)가 동일한 공간으로 독립적으로 인코딩 (encoded)되었습니다. 이는 매우 빠릅니다 (수백만 개의 벡터에 대해 밀리초 단위의 ANN 검색 가능). 하지만 정보 손실이 발생합니다. 인코더는 쿼리와 문서 쌍을 '함께' 본 적이 없습니다. 독립적인 의미를 기반으로 관련성을 추정했을 뿐입니다.
크로스-인코더 (cross-encoder)는 두 가지를 동시에 봅니다. 작은 트랜스포머 (transformer)를 통해 [query, doc]를 공동으로 인코딩하고, 쌍당 하나의 관련성 점수 (relevance score)를 출력합니다. 호출당 비용은 더 높지만, 올바른 것을 선택하는 데 있어 압도적으로 정확합니다.
2025년의 표준적인 RAG 아키텍처 (architecture)는 다음과 같습니다:
- 바이-인코더 (bi-encoder)와 ANN을 사용하여 상위 50개의 후보를 가져옵니다 (빠르고 저렴함).
- 크로스-인코더 리랭커 (cross-encoder reranker)를 사용하여 그 50개 중 상위 5개를 선택합니다.
- 상위 5개를 LLM에 전달합니다.
Python 개발자들은 2023년부터 FlashRank, sentence-transformers, BGE 리랭커 (rerankers)를 통해 이를 사용해 왔습니다. Node.js 개발자들에게는 두 가지 선택지뿐이었습니다:
- Cohere Rerank 비용 지불 (1,000회 호출당 $1, 약 300ms의 네트워크 지연 시간).
@huggingface/transformers를 사용하여 직접 구현 (모델 로딩, 배치 처리, 점수 정규화 등 유지보수하고 싶지 않은 약 80줄의 복잡한 코드 작업).
flashrank-js는 바로 그 격차를 메워줍니다.
설치
npm install flashrank-js
API 키가 필요 없습니다. 클라우드도 필요 없습니다. @huggingface/transformers를 통해 로컬에서 실행되는 ONNX 크로스-인코더 (cross-encoder)입니다. 4MB에서 280MB까지 5가지 모델 계층이 있습니다. 당신의 지연 시간 (latency) 예산에 맞는 것을 선택하세요.
6줄 튜토리얼
import { Reranker } from "flashrank-js";
const reranker = await Reranker.create({ model: "mini" });
...
ranked[0]이 가장 관련성이 높은 문서입니다. 끝.
실제 쿼리에 대한 적용 전/후 (Before / after)
벡터 저장소(vector store)가 "How does retrieval-augmented generation work?"라는 쿼리에 대해 다음 다섯 가지 후보를 반환한다고 가정해 봅시다:
const candidates = [
"RAG combines a retriever and a generator. The retriever finds relevant docs, the generator uses them to answer.",
"Karachi is a city in Pakistan with a population over 16 million.",
...
벡터 유사도(Vector similarity)는 이들을 다음과 같은 순서로 배치할 수 있습니다 (사용 중인 임베딩 모델 (embedding model)에 따라 다르지만, 도시나 수도에 관한 조각들은 쿼리와 토큰 통계가 유사하여 종종 몰래 끼어들곤 합니다):
0.81 RAG combines a retriever and a generator...
0.78 Karachi is a city in Pakistan... (노이즈)
0.76 Retrieval-augmented generation grounds...
...
다섯 개 중 두 개가 노이즈입니다. LLM이 인용 과정에서 그중 하나를 선택하면, 사용자는 환각 (hallucination)을 보게 됩니다.
동일한 후보들을 flashrank-js에 통과시켜 보겠습니다:
const ranked = await reranker.rerank({
query: "How does retrieval-augmented generation work?",
documents: candidates,
...
출력 결과:
[0.9982] Retrieval-augmented generation grounds LLM outputs in real documents...
[0.0005] Cross-encoders rerank retrieved documents to surface the most relevant ones...
[0.0004] RAG combines a retriever and a generator...
상위권에 RAG 관련 문서 3개가 배치되었습니다. 카라치(Karachi)와 파리(Paris)는 완전히 제외되었습니다.
이 상위 3개 문서를 보는 LLM은 도시를 인용하는 환각을 일으킬 수 없습니다. 컨텍스트 (context) 안에 도시가 존재하지 않기 때문입니다.
이것이 프로덕션 환경에서 중요한 이유
저는 한동안 프로덕션 환경에서 RAG 파이프라인과 AI 에이전트 워크플로우를 배포해 왔습니다. 이러한 스택의 Python 측에는 수년 동안 FlashRank가 연결되어 있었습니다. 하지만 클라이언트 UI로 배포되는 JavaScript 부분에는 그렇지 않았습니다. 클라이언트 측에서 크로스 인코더 (cross-encoder) 재순위화 (reranking)가 필요한 모든 새로운 프로젝트는 항상 동일한 80줄의 @huggingface/transformers 보일러플레이트 (boilerplate) 코드로 시작했습니다. 이 보일러플레이트는 transformers.js가 마이너 릴리스마다 API를 리팩토링하기 때문에 금방 구식이 되어버립니다.
그래서 저는 그 보일러플레이트를 패키지화했습니다.
5가지 모델 계층
flashrank-js는 4개의 사전 구성된 크로스 인코더 (cross-encoder) 모델을 제공합니다. 다섯 번째 옵션은
(주의: 이것은 익숙한 형태를 가진 독립형 함수이며, ai 패키지의 Vercel rerank()에 전달하는 RerankingModelV2 프로바이더가 아닙니다. 진정한 프로바이더 어댑터는 v1.x 로드맵에 포함되어 있습니다.)
솔직한 한계점
이것은 만능 해결책(silver bullet)이 아닙니다:
- 크로스 인코더 (Cross-encoders)는 바이 인코더 (bi-encoders)보다 호출당 속도가 더 느립니다. 따라서 1단계 검색을 위한 벡터 스토어 (vector store)가 여전히 필요합니다. 리랭킹 (Reranking)은 수백만 개의 데이터가 아닌, 상위 50개(top-50)를 대상으로 수행됩니다.
- 다국어 크로스 인코더 (Multilingual cross-encoders)는 크기가 더 큽니다.
bge-v2-m3는 571 MB입니다. 순수 영어 앱의 경우, 23 MB인mini모델이 가장 적절한 지점(sweet spot)입니다. - 순수 엣지 런타임 (Pure-edge runtimes, Cloudflare Workers, Vercel Edge)은
onnxruntime-web의 번들링된 WASM 빌드가 필요합니다. v0.1은 Node.js 20+를 우선적으로 타겟팅하며, 엣지 런타임 지원은 v1.1에 예정되어 있습니다.
사용해보기
npm install flashrank-js
- npm: https://www.npmjs.com/package/flashrank-js
- GitHub: https://github.com/zeeshan56656/flashrank-js
- 이슈(Issues)와 풀 리퀘스트(PRs)를 환영합니다.
만약 Node.js RAG를 배포했는데 사용자가 "봇이 잘못된 내용을 인용했다"라고 불평한 적이 있다면, 이것을 시도해 보세요. 23 MB의 기본 모델은 추가 비용이 거의 들지 않으며, 통합하는 데 약 30분 정도 소요됩니다. 인용 정확도(Citation accuracy)가 향상됩니다.
여러분의 버그 리포트를 기다리겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기