본문 바로가기
삵
AI

로컬 임베딩 API로 RAG 검색 붙이기

sstdio.h·2026년 11월 8일·조회 0

RAG 파이프라인을 짜다 보면 가장 먼저 걸리는 게 임베딩 비용이다. 문서 수십만 건을 한 번 색인하고 끝이면 모르겠는데, 게시판처럼 글이 계속 쌓이는 서비스는 임베딩 호출이 끊기지 않는다. 사내 문서나 회원 데이터를 외부 API로 흘려보내는 게 걸리는 경우도 많다. 그럴 때 OpenAI 임베딩 엔드포인트를 로컬 모델로 갈아끼우면 호출 요금과 데이터 반출 문제를 같이 덜 수 있다.

결론부터 말하자면, Ollama와 llama.cpp 둘 다 OpenAI와 같은 모양의 /v1/embeddings 엔드포인트를 제공한다. 기존 코드에서 base_url만 로컬 주소로 바꾸면 대개 그대로 돌아간다. 여기에 bge-m3나 multilingual-e5 같은 다국어 임베딩 모델을 얹고, 나온 벡터를 PostgreSQL의 pgvector에 넣어 코사인 거리로 검색하면 RAG의 검색 단이 만들어진다.

임베딩은 문장을 고정 길이 실수 벡터로 바꾼 값이다. 의미가 비슷한 문장끼리 벡터 공간에서 가깝게 놓이기 때문에, 질문 벡터와 가까운 문서 벡터를 찾으면 그게 검색 결과가 된다. pgvector는 PostgreSQL에 vector 타입과 거리 연산자를 추가하는 확장이다. 벡터를 테이블 컬럼에 저장하고 가까운 순으로 정렬하는 일을 SQL 안에서 처리한다.

1. Ollama로 bge-m3 띄우기

가장 손이 덜 가는 쪽은 Ollama다. 모델을 받아 서버를 올리면 OpenAI 호환 포트가 기본으로 열린다.

$ curl -fsSL https://ollama.com/install.sh | sh
$ ollama pull bge-m3
pulling manifest
pulling daec91ffb5dd... 100%
verifying sha256 digest
writing manifest
success

Ollama는 11434 포트에서 돌고, 같은 포트 아래 /v1 경로로 OpenAI 호환 엔드포인트가 붙는다. api_key는 검사하지 않지만 비어 있으면 클라이언트 라이브러리가 거부하므로 아무 문자열이나 넣어 준다.

$ curl -s http://localhost:11434/v1/embeddings \
  -H "Authorization: Bearer ollama" \
  -H "Content-Type: application/json" \
  -d '{"model":"bge-m3","input":"세션 쿠키 만료 시간을 늘리는 방법"}' \
  | jq '.data[0].embedding | length'
1024

차원이 1024로 찍히면 제대로 뜬 것이다. bge-m3의 덴스 벡터는 1024차원이고 최대 8192토큰까지 받는다. 100개가 넘는 언어를 한 모델로 처리하고, 이전 BGE 버전과 달리 질문에 별도 지시문을 붙이지 않아도 된다. 한국어와 영어가 섞인 사내 문서라면 무난한 기본값이다.

2. llama.cpp로 e5-large GGUF 서빙하기

GGUF 파일을 직접 고르고 양자화 레벨까지 손대고 싶으면 llama.cpp의 llama-server를 쓴다. 임베딩 모델은 생성이 필요 없으니 --embedding 플래그로 임베딩 전용으로 띄운다.

$ ./llama-server \
  -m ./models/multilingual-e5-large-Q8_0.gguf \
  --embedding --pooling mean \
  -c 512 --host 0.0.0.0 --port 8080
...
main: server is listening on http://0.0.0.0:8080 - starting the main loop

--pooling은 토큰 벡터를 한 개 문장 벡터로 합치는 방식을 정한다. 값으로 none, mean, cls, last, rank를 받고, 생략하면 모델에 박힌 기본값을 쓴다. e5 계열은 mean 풀링이고 BGE 계열은 cls 풀링이라서, 모델을 바꿀 때 풀링을 잘못 지정하면 정확도가 뚝 떨어진다. 확신이 없으면 플래그를 빼고 모델 기본값에 맡기는 쪽이 사고가 적다.

여기서 한 번 제대로 걸린다. multilingual-e5는 입력 앞에 query: 또는 passage: 접두사를 붙여야 제값이 나온다. 검색할 질문에는 query: , 색인할 문서 본문에는 passage: 를 붙인다. 접두사를 빼먹으면 서버는 에러 없이 멀쩡히 벡터를 돌려주지만 검색 품질이 조용히 나빠진다. 에러가 안 나서 더 찾기 어려운 함정이다.

$ curl -s http://localhost:8080/v1/embeddings \
  -H "Content-Type: application/json" \
  -d '{"input":"passage: 세션 타임아웃은 server.xml에서 설정한다"}' \
  | jq '.data[0].embedding | length'
1024

multilingual-e5-large는 24개 레이어에 임베딩 크기 1024차원이다. 같은 계열이라도 multilingual-e5-base는 768차원이라 둘을 섞으면 안 된다. 뒤에서 만들 pgvector 컬럼의 차원을 여기 맞춰야 한다.

3. pgvector 테이블 만들고 차원 맞추기

PostgreSQL에 확장을 켜고 벡터 컬럼을 만든다. 차원 숫자는 쓰는 모델에 정확히 맞춘다. bge-m3나 e5-large면 1024, e5-base면 768이다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunks (
  id        bigserial PRIMARY KEY,
  content   text NOT NULL,
  embedding vector(1024)
);

차원을 틀리면 적재하는 순간 막힌다. vector(768) 컬럼에 e5-large가 뱉은 1024차원 벡터를 넣으면 이런 에러가 난다.

ERROR:  expected 768 dimensions, not 1024

이 에러를 보면 컬럼 정의와 모델 출력 차원이 어긋난 것이다. 모델을 바꿔 끼웠다면 테이블도 새 차원으로 다시 만들어야 한다. 반대로 차원을 아예 비워 vector로 두면 아무 길이나 들어가지만, 그러면 인덱스를 못 걸어서 검색이 전수 스캔이 된다. 운영에서는 차원을 못박는 게 맞다.

적재는 OpenAI 파이썬 클라이언트를 그대로 쓰되 base_url만 로컬로 돌린다.

from openai import OpenAI
import psycopg

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
conn = psycopg.connect("dbname=sarc")

def embed(text):
    r = client.embeddings.create(model="bge-m3", input=text)
    return r.data[0].embedding

for chunk in chunks:
    vec = embed(chunk)
    conn.execute(
        "INSERT INTO doc_chunks (content, embedding) VALUES (%s, %s)",
        (chunk, str(vec)),
    )
conn.commit()

pgvector는 벡터를 [0.1,0.2,...] 모양 문자열로 받으므로 str(vec)로 넘기면 된다. e5 모델을 쓴다면 이 embed() 안에서 문서에 passage: 접두사를 붙여야 한다는 점을 잊지 않는다.

4. 코사인 거리로 검색 붙이기

검색은 질문도 똑같이 임베딩해서 거리가 가까운 행을 정렬하면 된다. pgvector의 <=>는 코사인 거리 연산자다. bge-m3와 e5 모두 L2 정규화된 벡터를 내보내므로 코사인 거리가 잘 맞는다.

-- $1 은 질문을 임베딩한 벡터 (e5면 'query: ' 접두사)
SELECT content,
       1 - (embedding <=> $1) AS score
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 5;

행이 수만 건을 넘어가면 정렬이 느려진다. HNSW 인덱스를 코사인 연산자 클래스로 걸어 준다.

CREATE INDEX ON doc_chunks
  USING hnsw (embedding vector_cosine_ops);

검색 연산자와 인덱스의 연산자 클래스가 어긋나면 인덱스를 안 탄다. 쿼리에서 코사인 거리 <=>를 쓰면 인덱스도 vector_cosine_ops로 만들어야 한다. 재현율이 낮으면 세션에서 SET hnsw.ef_search = 100;처럼 후보 수를 늘려 조정한다.

5. 모델을 고르는 기준과 주의할 점

한국어가 섞인 일반 문서 검색이면 bge-m3를 먼저 놓고 본다. 접두사 규칙이 없어 적재 코드가 단순하고, 8192토큰까지 받아 긴 문단을 통째로 넣기 편하다. 영어 비중이 높거나 e5 계열로 벤치마크를 맞춰 둔 파이프라인이면 multilingual-e5-large가 대안이다. 대신 query:, passage: 접두사를 코드 양쪽에 일관되게 넣어야 한다.

차원이 곧 저장 비용이자 인덱스 크기다. 1024차원과 768차원은 디스크와 메모리를 그만큼 더 먹는다. 데이터가 작고 가벼운 검색이면 e5-base(768차원)로 시작했다가, 품질이 모자라면 1024차원 모델로 올리는 식으로 잡으면 된다. 올릴 때는 테이블 차원과 인덱스를 다시 만들고 전체를 재색인해야 한다.

Ollama와 llama.cpp 중에서는, 모델을 받아 바로 쓰고 싶으면 Ollama, GGUF 양자화 레벨과 풀링을 직접 통제하고 싶으면 llama.cpp를 쓴다. 두 서버 모두 /v1/embeddings를 열어 두니 적재 코드는 base_url과 model 이름만 바꾸면 그대로 옮겨간다. 운영에 올리기 전에 차원을 jq '.data[0].embedding | length'로 한 번 확인하고 테이블 정의와 맞는지만 보면 적재가 조용히 깨지는 사고는 피할 수 있다.

자주 묻는 질문

bge-m3와 multilingual-e5 모델의 임베딩 차원은 각각 몇인가

bge-m3의 덴스 벡터는 1024차원이다. multilingual-e5-large는 24개 레이어에 임베딩 크기 1024차원, multilingual-e5-base는 768차원이다. pgvector 컬럼을 vector(1024) 또는 vector(768)로 모델 출력 차원에 정확히 맞춰 만들어야 한다. 틀리면 INSERT 시점에 'expected N dimensions' 에러로 막힌다.

OpenAI 임베딩 코드를 로컬로 바꿀 때 무엇만 고치면 되나

OpenAI 파이썬 클라이언트의 base_url을 Ollama면 http://localhost:11434/v1, llama.cpp면 http://localhost:8080/v1 로 바꾸고 model 이름을 로컬 모델명으로 바꾼다. api_key는 Ollama가 검사하지 않지만 빈 문자열이면 클라이언트가 거부하므로 아무 문자열이나 넣는다. embeddings.create() 호출 모양은 그대로 쓴다.

e5 모델에서 query: 와 passage: 접두사를 꼭 붙여야 하나

그렇다. multilingual-e5 계열은 검색 질문에 'query: ', 색인할 문서에 'passage: ' 접두사를 붙여야 제값이 나온다. 빼먹으면 서버는 에러 없이 벡터를 돌려주지만 검색 품질이 조용히 떨어진다. 반면 bge-m3는 질문에 지시문을 붙이지 않아도 된다.

llama-server에서 --pooling 값은 무엇으로 두어야 하나

e5 계열은 mean 풀링, BGE 계열은 cls 풀링이다. 값으로 none, mean, cls, last, rank를 받고 생략하면 모델에 박힌 기본값을 쓴다. 모델에 맞는 풀링을 모르면 플래그를 빼고 기본값에 맡기는 게 안전하다. 잘못 지정하면 정확도가 떨어진다.

pgvector 검색이 느릴 때 인덱스는 어떻게 거나

코사인 거리 <=> 로 검색한다면 CREATE INDEX ... USING hnsw (embedding vector_cosine_ops) 로 연산자 클래스를 맞춰 인덱스를 건다. 쿼리 연산자와 인덱스 연산자 클래스가 어긋나면 인덱스를 타지 않는다. 재현율이 낮으면 세션에서 SET hnsw.ef_search 값을 올려 후보 수를 늘린다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.