사내 문서 검색을 붙이려고 하다 보면 일반 키워드 검색의 한계가 금방 드러난다. "리팩토링 방법"이라고 검색하면 "코드 개선 가이드"가 안 나온다. 단어가 다르니까.
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 호출 두 번과 벡터 검색 쿼리 하나가 전부다.