블로그 목록
AI

텍스트 임베딩으로 시맨틱 검색 만들기 — RAG 핵심 개념 정리

사내 문서 검색을 붙이려고 하다 보면 일반 키워드 검색의 한계가 금방 드러난다. "리팩토링 방법"이라고 검색하면 "코드 개선 가이드"가 안 나온다. 단어가 다르니까.

RAG(Retrieval-Augmented Generation)에서 검색 품질이 최종 답변 품질을 결정한다. 그 검색의 핵심이 텍스트 임베딩이다.

임베딩이 뭔가

텍스트를 고차원 벡터로 변환한 것이다. 의미가 비슷한 문장은 벡터 공간에서 가까운 위치에 놓인다. "리팩토링"과 "코드 개선"은 가깝고, "피자"는 멀다.

임베딩 모델은 이 변환을 학습한 모델이다. OpenAI의 text-embedding-3-small이 현재 비용 대비 성능이 좋다. 1536차원 벡터를 반환한다.

구현 흐름

두 단계로 나뉜다.

인덱싱: 문서를 임베딩 벡터로 변환해서 저장한다. 검색: 쿼리도 임베딩으로 변환하고, 저장된 벡터 중 코사인 유사도가 높은 것을 찾는다.

pgvector로 구현하기

벡터 DB는 별도 서비스보다 기존 PostgreSQL에 pgvector 익스텐션을 붙이는 게 운영하기 편하다. Railway PostgreSQL이라면 SQL 한 줄로 활성화된다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  content TEXT NOT NULL,
  embedding vector(1536)
);

-- 검색 속도를 위한 인덱스
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops);

Node.js에서 임베딩 생성과 저장을 처리한다.

import OpenAI from 'openai';
import { Pool } from 'pg';

const openai = new OpenAI();
const db = new Pool({ connectionString: process.env.DATABASE_URL });

async function embed(text: string): Promise<number[]> {
  const res = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  return res.data[0].embedding;
}

async function indexDocument(content: string) {
  const embedding = await embed(content);
  await db.query(
    'INSERT INTO documents (content, embedding) VALUES ($1, $2::vector)',
    [content, JSON.stringify(embedding)]
  );
}

async function search(query: string, limit = 5) {
  const queryEmbedding = await embed(query);
  const { rows } = await db.query(
    `SELECT content, 1 - (embedding <=> $1::vector) AS similarity
     FROM documents
     ORDER BY embedding <=> $1::vector
     LIMIT $2`,
    [JSON.stringify(queryEmbedding), limit]
  );
  return rows;
}

<=> 연산자는 pgvector의 코사인 거리 연산자다. 거리가 작을수록 유사도가 높으니, ORDER BY 오름차순으로 정렬하면 가장 유사한 문서가 먼저 온다. 1 - 거리를 하면 0~1 사이의 유사도 점수를 얻을 수 있다.

LLM에 컨텍스트로 넘기기

검색 결과를 LLM 프롬프트에 넣으면 RAG가 완성된다.

async function ragQuery(question: string): Promise<string> {
  const docs = await search(question, 3);
  const context = docs.map((d) => d.content).join('\n\n---\n\n');

  const res = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: `다음 문서를 참고해서 질문에 답하세요. 문서에 없는 내용은 모른다고 하세요.\n\n${context}`,
      },
      { role: 'user', content: question },
    ],
  });

  return res.choices[0].message.content ?? '';
}

모델이 학습 데이터가 아니라 지금 주입한 컨텍스트 기반으로 답한다. 사내 문서, 제품 FAQ, 자주 바뀌는 정책 정보처럼 모델이 학습하지 못한 도메인 지식을 다루는 데 적합한 이유다.

청킹에 대해

문서가 길면 통째로 임베딩하는 건 좋지 않다. 벡터 하나가 너무 많은 정보를 담으면 검색 정밀도가 떨어진다.

문서를 400~600 토큰 단위로 잘라서 각각 임베딩하는 게 일반적이다. 문단 경계나 섹션 경계를 기준으로 자르면 의미가 덜 손상된다.

function chunkText(text: string, maxChars = 1500): string[] {
  const paragraphs = text.split(/\n\n+/);
  const chunks: string[] = [];
  let current = '';

  for (const para of paragraphs) {
    if ((current + para).length > maxChars && current) {
      chunks.push(current.trim());
      current = '';
    }
    current += para + '\n\n';
  }
  if (current.trim()) chunks.push(current.trim());

  return chunks;
}

핵심은 단순하다 — 의미가 비슷한 텍스트를 찾아서 모델에 넘기는 것. 복잡한 파이프라인처럼 보이지만, 실제로는 임베딩 API 호출 두 번과 벡터 검색 쿼리 하나가 전부다.