THE DEVLOG

scribbly.

Next.js 블로그 프로젝트

2025.09.16 02:52:59

블로그에 Text Full Search를 구현하였으나, Supabase는 한국어 형태소 분석을 지원하지 않아서 검색결과가 부정확했다.

이에 이전부터 관심을 가지고 있던 리랭킹을 적용하였다.

또 구글의 경량 임베딩 모델인 EmbeddingGemma가 때마침 출시되어 적용하였다.

적용하고 보니, 리랭킹이 생각보다 너무 느려서... 게시글 탐색보다는 RAG를 위해 구현한 느낌...

 

배경지식

ONNX

게시글 검색은 빠른 속도가 중요하기 때문에 ollama를 호출하는 지연 시간도 줄이기 위해 node.js 환경에서 직접 실행하는 것이 좋다.

ONNX (Open Neural Network Exchange) 는 머신러닝 모델을 다양한 프레임워크와 하드웨어에서 호환 가능하게 만드는 오픈 표준 포맷이다.

transfomers.js

@huggingface/transformers는 ONNX의 node.js 런타임, 웹 어셈블리 런타임 등을 제공한다. 허깅페이스에서 모델을 다운로드 받고 ONNX 런타임으로 변환하여준다. Node.js 런타임은 C++로 코어가 작성되어 있어 CPU 온리 환경에서도 작동이 가능하다.

EmbeddingGemma

Google이 2025년 9월에 공개한 308M 매개변수 규모의 텍스트 임베딩 모델이다. 300m 이하 다국어 모델 중 SOTA급.

Gemma2 트랜스포머를 기반으로 하되, 디코더의 causal attention(왼쪽에서 오른쪽)을 bidirectional attention(양방향)으로 변경하여 맥락 이해도를 높였다.

Matryoshka Representation Learning을 적용하여 768차원의 임베딩을 128, 256, 512차원으로 축소해도 품질을 유지할 수 있다. 마트료시카는 중요한 정보들을 앞으로 배치하는 방식이다. 이 프로젝트에서는 768차원을 그대로 이용했다.

ONNX 커뮤니티에서 제공하는 모델이 있다. https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX

JINA Reranker V2

Jina AI의 cross-encoder 방식의 리랭킹 모델이다. 278M 매개변수로 구성되어 있으며, 기존 SOTA인 bge-reranker-v2-m3(560M) 대비 크기는 절반임에도 유사하거나 더 나은 성능을 보여준다. Hugging Face 모델 페이지

Cross-encoder

쿼리와 문서를 함께 트랜스포머에 입력하여 어텐션 메커니즘을 통해 토큰 간 상호작용을 분석한다. 속도는 Bi-encoder 방식에 비해 느리지만, 의미를 정확하게 파악한다.

Flash Attention

일반적인 어텐션은 시퀀스 길이 N에 대해 O(N²)의 메모리 복잡도를 가진다.

Flash Attention은 타일링 기법을 사용하여 입력을 블록 단위로 처리한다. 순차적으로 상태를 업데이트하며, 중간 상태는 메모리에 저장하지 않고 최종 상태만 저장한다. 선형적인 처리를 통해 메모리 복잡도를 O(N²)에서 O(N)으로 줄인다.

Hard negative mining

Hard negative mining은 모델이 구분하기 어려운 유사한 문서들을 학습 데이터에 포함시켜 변별력을 높이는 기법이다.

Soft negative는 분별이 쉬운 문서를 의미하고, Hard Negatives는 관련있어 보이지만 실제로는 정답이 아닌 문서를 의미한다.

Docker

 프로덕션 환경에서는 빌드시마다 허깅페이스 모델을 새로 다운받게 된다. 이러한 문제를 해결하기 위해서는 Docker에 volume을 할당하고 허깅페이스 모델을 캐싱해두는 방식을 사용하여야 한다. 참고할만한 게시글은 아래와 같다

https://huggingface.co/docs/huggingface_hub/en/guides/manage-cache : 모델 캐싱에 관한 설명

https://huggingface.co/docs/transformers.js/en/api/env : env를 이용한 모델 관리

https://huggingface.co/docs/transformers.js/en/tutorials/node : 모델 다운로드 경로 관리

한편 opnnxruntime-node로 모델을 실행하기 위해서는 glibc가 필요하다.
Alpine 계열이 아닌 Debian 계열로 모델 이미지를 바꿔야 한다.

  • glibc : GNU C Library의 줄임말로, 리눅스 시스템에서 가장 널리 사용되는 C 표준 라이브러리 구현체이다. onnxruntime-node와 같은 네이티브 라이브러리들이 의존하는 핵심 라이브러리이다.
  • Alpine Linux: 경량화된 리눅스 배포판으로 Docker 이미지 크기를 최소화하는 데 특화되어 있다. musl libc를 사용하여 glibc보다 작지만 호환성 문제가 발생할 수 있다. 약 150메가.

  • Debian: 안정성과 호환성에 중점을 둔 리눅스 배포판이다. glibc를 사용하여 대부분의 소프트웨어와 호환성이 좋지만 이미지 크기가 Alpine보다 크다. FROM node:20-bookworm-slim AS base 약 220메가.

  • libgomp1: GNU OpenMP 런타임 라이브러리로, 병렬 처리를 위한 OpenMP API 구현체이다. onnxruntime-node가 성능 최적화를 위해 멀티스레딩 처리에 필요로 하는 의존성이다.

구현 내용

검색 시스템은 청킹 → 임베딩 생성 → 하이브리드 서치 → 리랭킹의 순으로 처리된다.

랭그래프에 포함시켜 RAG로 사용했다.

 

청킹

토큰 단위로 청킹을 할 때에는 랭체인에서 제공되는 TokenTextSplitter를 사용하면 용이하다. OpenAI의 P50K, CL100K, O200K를 토크나이저로 제공한다.

import { TokenTextSplitter } from "@langchain/textsplitters";

   const splitter = new TokenTextSplitter({
      chunkSize: 350,
      chunkOverlap: 50,
      encodingName: "cl100k_base",
    });

    const documents = await splitter.createDocuments([postBody]);
    const textChunks = documents.map((doc) => doc.pageContent);

임베딩

EmbeddingGemma-300M 모델을 ONNX 형태로 변환하여 Transformers.js로 구동한다.

프롬프트 포맷팅

EmbeddingGemma는 쿼리와 문서에 서로 다른 프롬프트 템플릿을 적용하여 검색 성능을 최적화한다.
https://huggingface.co/onnx-community/embeddinggemma-300m-ONNX

// 검색 쿼리용
const formatQuery = (query: string): string => {
  return `task: search result | query: ${query}`;
};

// 문서 인덱싱용
const formatDocument = (content: string): string => {
  return `title: none | text: ${content}`;
};

임베딩 생성

// 캐시된 파이프라인으로 성능 최적화
const loadEmbeddingPipeline = async () => {
  if (!cachedPipeline) {
    cachedPipeline = await pipeline(
      "feature-extraction",
      "onnx-community/embeddinggemma-300m-ONNX",
      {
        dtype: "fp32",
        local_files_only: true,
      },
    );
  }
  return cachedPipeline;
};

 

하이브리드 서치

PostgreSQL에서 Full-Text Search(FTS)와 Vector Search를 RRF(Reciprocal Rank Fusion) 알고리즘으로 결합한다.

RRF 점수 계산

  1. Full-Text Search와 PGVector를 통해 각각 순위를 매긴다.
  2. 순위에 따른 점수를 매기고, 가중치를 곱한다.

RRF를 이용하면 Full-Text Search 점수와 PGVector 점수를 어떻게 통일 시킬지 고민하지 않아도 되어 편리하다.

    combined_ranks AS (
        -- FTS와 Vector 검색 결과를 FULL OUTER JOIN으로 결합하여 순위 정보 통합
        SELECT
            COALESCE(fts.post_id, vec.post_id) as post_id,
            fts.rank_position as fts_rank_position,
            vec.rank_position as vector_rank_position
        FROM fts_search fts
        FULL OUTER JOIN vector_search vec ON fts.post_id = vec.post_id
    ),
    rrf_scored AS (
        -- RRF 점수 계산
        SELECT
            cr.post_id,
            (CASE
                WHEN cr.fts_rank_position IS NOT NULL
                  THEN p_fts_weight::double precision * (1.0 / (p_rrf_k + cr.fts_rank_position))
                ELSE 0.0
            END)
            +
            (CASE
                WHEN cr.vector_rank_position IS NOT NULL
                  THEN p_vector_weight::double precision * (1.0 / (p_rrf_k + cr.vector_rank_position))
                ELSE 0.0
            END) AS rrf_score
        FROM combined_ranks cr
    )

 

1단계 : 순위 테이블

FTS (키워드) 검색 순위Vector (의미) 검색 순위
1. 게시글 A1. 게시글 E
2. 게시글 B2. 게시글 B
3. 게시글 C3. 게시글 F
4. 게시글 D4. 게시글 C

2단계 : 통합 테이블

게시글 IDfts_rank_positionvector_rank_position
A1없음
B22
C34
D4없음
E없음1
F없음3

3단계 : 점수 계산

  • 게시글 A (fts 1등, vector 등수 없음)

    • 점수 = (0.5 * (1 / (60 + 1))) + (0)

    • (0.5 * 0.0164) = 0.0082

  • 게시글 B (fts 2등, vector 2등)

    • 점수 = (0.5 * (1 / (60 + 2))) + (0.5 * (1 / (60 + 2)))

    • (0.5 * 0.0161) + (0.5 * 0.0161) = 0.00805 + 0.00805 = 0.0161

4단계 : 최종 테이블

최종 순위게시글 ID최종 RRF 점수비고
1B0.0161두 검색 모두 2위
2C0.01575두 검색 모두 3,4위
3A0.0082<br />
4E0.0082<br />
5F0.00795<br />
6D0.0078<br />

 

리랭킹

하이브리드 검색 결과를 JINA Reranker V2를 사용하여 검색 결과의 관련도를 점수화한다.

리랭킹 과정

const rerank = async (query: string, documents: string[]) => {
  const { tokenizer, model } = await loadReranker();

  // 쿼리와 문서를 쌍으로 토큰화
  const inputs = tokenizer(new Array(documents.length).fill(query), {
    text_pair: documents,
    padding: true,
    truncation: true,
  });

  // 시그모이드로 0-1 사이 점수 변환
  const { logits } = await model(inputs);
  const scores = logits.sigmoid().tolist();

  return scores
    .map(([score], i) => ({ corpus_id: i, score }))
    .sort((a, b) => b.score - a.score);
};

시그모이드는 점수를 S자 형태의 곡선으로 파싱하는 함수이다.

threshhold

const selectOptimalResults = (rerankedResults, originalResults, params) => {
  const aboveThreshold = rerankedResults.filter(
    (result) => result.rerankScore >= params.minThreshold,
  );

  if (aboveThreshold.length < params.effectiveMinResults) {
    // 부족한 만큼 원본 점수 순으로 보충
    const needed = params.effectiveMinResults - aboveThreshold.length;
    const fillers = originalResults
      .sort((a, b) => b.final_score - a.final_score)
      .slice(0, needed);
    return [...aboveThreshold, ...fillers];
  }

  return aboveThreshold;
};

점수가 minThreshold를 넘는 검색 결과만 반환한다.

이때 만일 유효한 검색결과 수가 minLen을 넘지 못하면, 그때는 하이브리드 서치 결과 순으로 추가해서 반환한다.

RAG

블로그 검색 노드

export async function blogSearchNode(state) {
  const queries = Array.isArray(state.routingQuery)
    ? state.routingQuery
    : [state.routingQuery];

  const allPosts = [];

  for (const query of queries) {
    const res = await fetch("/api/posts/semantic-search", {
      method: "POST",
      body: JSON.stringify({
        query: query,
        overSampleCount: 10,
        maxResults: 10,
      }),
    });

    const data = await res.json();
    allPosts.push(...data.results);
  }

  // 중복 제거 및 시스템 메시지 생성
  const uniquePosts = allPosts.filter(
    (post, index, array) => array.findIndex((p) => p.id === post.id) === index,
  );

  const searchResultsMessage = formatSearchResults(uniquePosts);

  return new Command({
    goto: LangNodeName.routing,
    update: {
      messages: [new SystemMessage(searchResultsMessage)],
      routeType: "chat",
    },
  });
}

 

라우팅 노드

기존에는 슈퍼바이저가 검색 결과가 걸리도록 여러 단어를 만드는 방식으로 구현하였는데,

semantic search를 적용하였으니, 의미있는 검색어로 검색어를 반환하도록 수정하였다.

    const decisionRes = await routingModel.invoke([
      new SystemMessage(
        `당신은 기술블로그 챗봇의 라우팅을 결정하는 AI입니다. 사용자의 질문을 분석하여 다음 형태로 응답해야 합니다.

응답 형태:
- 이 기술블로그 내 검색이 필요한 경우 {"type": "blogSearch", "query": ["한글 쿼리", "english query"]} (예시 : ["Next.js 사용법", "Next.js tutorial"], ["MCP 설정 방법", "MCP setup guide"])

판단 기준:
- 'blogSearch': '관련된 게시물', '연관된 게시물', '이 블로그에 있는 게시물'에 대한 검색이 필요한 질문 (Semantic Search가 작동할 수 있도록 의미 기반으로 한글 쿼리와 영어 쿼리 각 1개 씩)

반드시 유효한 형태로만 응답하세요.`,
      ),
      new HumanMessage(lastUserMessage.content as string),
    ]);

 

결과

이제 FTS 뿐 아니라 다국어 지원 임베딩 모델과 다국어 지원 리랭킹 모델이 적용되어 언어에 상관없이 적절한 검색 결과가 느리지만 나오게 된다.

1.00

 

챗봇 역시도 chunk 단위로 검색된 내용을 정확하게 확인하기 때문에 실제 게시글 내용을 토대로 답변을 할 수 있게 된다. 실제로 해당 게시글은 GEMINI를 Command로 추가하도록 하고 있다.

0.31