~/ / AI 기사 / VPS에서 로컬 LLM / Ollama 임베딩을 pgvector로 PostgreSQL에 저장하고 검색하기

Ollama 임베딩을 pgvector로 PostgreSQL에 저장하고 검색하기

Ollama의 /api/embed가 돌려주는 임베딩은 어딘가에 저장해야 검색에 쓸 수 있습니다. pgvector는 PostgreSQL에 벡터 유사도 검색을 더해 주는 오픈 소스 확장이라서, 벡터를 텍스트 및 나머지 데이터와 같은 데이터베이스, 같은 서버에 둘 수 있습니다. 이 글에서는 pgvector 0.8.7 README를 바탕으로 설치, 테이블 만들기, 거리와 인덱스 고르기, 결과를 바꾸는 설정을 다룹니다. 이어서 검색 결과를 Ollama의 /api/chat에 넘겨 답변이 출처를 밝히게 하고, 그 인용을 확인합니다. SQL은 Ollama에서 만든 실제 벡터로 PostgreSQL 16의 pgvector 0.8.7에서 실행했고, 채팅 요청은 qwen2.5:7b로 Ollama 0.40.11에서 실행했습니다. 이 페이지의 도구 네 개는 브라우저에서 SQL과 요청을 작성하고 답변을 확인해 줍니다.

게시: · 읽는 데 약 13분 · 여기 적힌 사실은 pgvector 0.8.7의 README와 소스 코드, 그리고 Ollama 문서와 소스 코드(0.40.11)를 2026-11-05에 확인한 것입니다. 의존하기 전에 최신 문서를 확인하세요.

pgvector가 PostgreSQL에 더해 주는 것

pgvector README는 이를 Postgres용 오픈 소스 벡터 유사도 검색이라고 설명합니다. 정확한 최근접 이웃 검색과 근사 최근접 이웃 검색을 지원하고, 단정밀도, 반정밀도, 이진, 희소 벡터를 다루며, L2 거리, 내적, 코사인 거리, L1 거리, 해밍 거리, 자카드 거리를 쓸 수 있습니다. 벡터가 일반 PostgreSQL 열이므로 ACID 준수, 시점 복구(point-in-time recovery), JOIN도 함께 쓸 수 있습니다.

문서를 검색할 때는 청크마다 한 행에 텍스트와 벡터를 저장합니다. 질문은 같은 모델로 임베딩하고, 질문 벡터와의 거리로 행을 정렬합니다.

설치하기

pgvector는 PostgreSQL 13 이상을 지원합니다. README에는 여러 설치 방법이 나와 있으며, 그중 일부는 다음과 같습니다.

방법README의 설명
소스에서 빌드git clone --branch v0.8.7 다음에 make와 make install(sudo가 필요할 수 있음)
Dockerpgvector/pgvector 이미지는 Postgres 이미지에 pgvector를 더한 것입니다. pg18-trixie나 0.8.7-pg16 같은 태그로 Postgres 버전을 고릅니다.
APT(Debian, Ubuntu)PostgreSQL APT Repository에서 sudo apt install postgresql-18-pgvector를 실행하며, 18은 사용 중인 Postgres 버전으로 바꿉니다.
Homebrewbrew install pgvector이며, postgresql@18과 postgresql@17 formula에만 추가됩니다.

그다음 사용하려는 데이터베이스마다 한 번씩 CREATE EXTENSION vector;로 활성화합니다. 이 글에서는 Docker 이미지 pgvector/pgvector:0.8.7-pg16을 썼습니다.

테이블 만들기

vector(n) 타입 열은 정확히 n차원의 벡터를 담으며, n은 임베딩 모델이 돌려주는 벡터의 길이와 같아야 합니다. 길이가 다른 벡터는 거부됩니다. vector(3) 열에 2차원 벡터를 넣었을 때 PostgreSQL은 "expected 3 dimensions, not 2"를 돌려주었습니다. all-minilm은 384차원을 돌려주므로 그 열은 vector(384)가 됩니다.

README에 따르면 벡터 하나의 저장 크기는 4 × 차원 수 + 8바이트이고 모든 요소는 단정밀도 부동소수점 수이며, 한 열에는 최대 16,000차원까지 넣을 수 있습니다. 인덱스가 지원하는 차원 수는 그보다 적으며, 아래에서 보여 드립니다. halfvec 타입은 반정밀도 값을 2 × 차원 수 + 8바이트로 저장합니다.

차원 수vectorhalfvec
3841,544 바이트776 바이트
7683,080 바이트1,544 바이트
10244,104 바이트2,056 바이트

우리의 테스트 테이블에서 pg_column_size는 vector(384)에 1,544바이트, halfvec(384)에 776바이트를 돌려주어 식과 일치했습니다. 이는 벡터만의 크기이며, 텍스트 열과 인덱스는 추가로 공간을 차지합니다.

Ollama의 벡터 불러오기

pgvector는 '[1,2,3]'처럼 대괄호로 둘러싼 텍스트를 벡터로 읽으며, README에 따르면 모든 요소는 유한해야 합니다(NaN, Infinity, -Infinity는 안 됨). /api/embed 응답의 embeddings 필드에 있는 각 배열은 이미 이 형태이고, 응답은 입력의 순서를 유지하므로 첫 번째 텍스트가 첫 번째 벡터와 짝이 됩니다.

README에는 몇 행이면 INSERT, upsert에는 INSERT … ON CONFLICT, 대량 적재에는 COPY를 쓰는 예가 나옵니다. 아래 변환기는 텍스트와 응답을 짝지어 따옴표를 이스케이프한 INSERT 문을 작성합니다. 따옴표, 백슬래시, 줄바꿈, 이모지가 들어 있는 텍스트로 그 출력을 실행했을 때, 텍스트는 그대로 돌아왔습니다.

직접 해보기: Embeddings to pgvector INSERT

예시는 읽기 쉽도록 임의로 만든 4차원의 길이 1 벡터를 씁니다. 실제 임베딩은 수백에서 수천 차원입니다.

4차원 3행. 텍스트는 /api/embed 요청 본문, 벡터는 Ollama /api/embed 응답(으)로 읽었습니다.

INSERT 문

psql이나 다른 클라이언트에서 실행하세요. 행은 하나의 트랜잭션으로 삽입됩니다.

BEGIN;

INSERT INTO documents (content, embedding) VALUES
  ('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
  ('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
  ('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');

COMMIT;

벡터 형식은 pgvector README에서 가져왔으며, 2026-11-04에 pgvector 0.8.7로 확인했습니다. pgvector README

모든 처리는 브라우저에서 이루어집니다. 이 도구는 SQL을 작성할 뿐 어디에도 연결하지 않습니다.

거리 고르기

Ollama 문서는 대부분의 시맨틱 검색에는 코사인 유사도를 권하며, /api/embed가 길이 1인 벡터를 돌려준다고 밝힙니다. 이런 벡터에서는 L2 거리의 제곱이 2에서 코사인 유사도의 2배를 뺀 값이므로, 코사인 거리, L2 거리, 내적 모두 행의 순서가 같습니다. all-minilm으로 한 우리의 테스트에서는 세 방식 모두 상위 3개 행이 같았고, L2 거리 0.931은 코사인 유사도 0.566과 일치했습니다.

거리마다 자기 연산자가 있습니다. 검색은 그 연산자로 정렬하고 LIMIT으로 앞쪽 행을 가져오며, 인덱스는 하나의 연산자 클래스(operator class)에 대해 만듭니다.

연산자반환 값인덱스 연산자 클래스
<=>코사인 거리. 코사인 유사도는 1 - (a <=> b)입니다.vector_cosine_ops
<->L2(유클리드) 거리vector_l2_ops
<#>내적에 음수를 취한 값. 내적을 얻으려면 -1을 곱합니다.vector_ip_ops
<+>L1 거리vector_l1_ops

README는 <#>가 내적에 음수를 취한 값을 돌려주는 이유를, Postgres가 연산자에 대해 ASC 순서의 인덱스 스캔만 지원하기 때문이라고 설명합니다. halfvec의 연산자 클래스는 halfvec_cosine_ops, halfvec_l2_ops 등입니다.

정확한 검색인가, 인덱스인가

인덱스가 없으면 pgvector는 정확한 최근접 이웃 검색을 하며, README에 따르면 완벽한 재현율(recall)을 제공합니다. 근사 인덱스는 재현율을 조금 내주고 속도를 얻으며, README는 인덱스를 추가한 뒤 쿼리 결과가 달라질 수 있다고 경고합니다.

인덱스는 쿼리에 ORDER BY와 LIMIT이 있고, ORDER BY가 거리 연산자 자체를 오름차순으로 쓸 때만 사용됩니다. ORDER BY embedding <=> $1 LIMIT 5는 인덱스를 쓸 수 있지만, ORDER BY 1 - (embedding <=> $1) DESC는 쓸 수 없습니다. README는 테이블이 작으면 테이블 스캔이 더 빠를 수 있다고도 적습니다.

항목HNSWIVFFlat
동작 방식다층 그래프벡터를 list로 나누고 쿼리에 가까운 list를 검색
다른 쪽과 비교속도와 재현율의 균형은 더 좋지만 빌드가 느리고 메모리를 더 씀빌드가 빠르고 메모리를 덜 쓰지만 속도와 재현율의 균형은 더 낮음
만드는 시점빈 테이블에도 만들 수 있음(학습 단계가 없음)테이블에 데이터가 어느 정도 들어간 뒤
빌드 옵션m(기본값 16)과 ef_construction(기본값 64)lists: 100만 행까지는 rows / 1000으로 시작하고, 그보다 많으면 sqrt(rows)
검색 옵션hnsw.ef_search, 기본값 40ivfflat.probes, 기본값 1. sqrt(lists)로 시작
차원 수vector는 2,000까지, halfvec는 4,000까지vector는 2,000까지, halfvec는 4,000까지

pgvector 0.8.7에서 vector(2001) 열에는 HNSW 인덱스를 만들 수 없고, IVFFlat에는 L1 거리용 연산자 클래스가 없으며, ef_construction은 2 × m 이상이어야 한다는 것도 확인했습니다. 더 많은 차원에 대해서는 README의 FAQ가 halfvec, 이진 양자화 등의 선택지를 들고 있습니다.

직접 해보기: pgvector SQL Builder

벡터 하나가 1,544바이트이므로, 10,000행이면 벡터만으로 약 15.4 MB입니다(인덱스와 다른 열은 포함하지 않음).

1. 확장과 테이블

CREATE EXTENSION IF NOT EXISTS vector;

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

2. 인덱스

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

3. 검색

PREPARE는 /api/embed로 얻은 질문의 벡터를 $1로 받습니다. '[0.01,-0.02,…]'처럼 텍스트로 전달하세요.

PREPARE search_documents(vector(384)) AS
  SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
  FROM documents
  ORDER BY embedding <=> $1
  LIMIT 5;

-- EXECUTE search_documents('[0.0123, -0.0456, ...]');

한계와 기본값은 pgvector 0.8.7 README와 소스 코드에서 가져왔으며, 2026-11-04에 확인했습니다. pgvector README

모든 처리는 브라우저에서 이루어집니다. 이 도구는 SQL을 작성할 뿐 어디에도 연결하지 않습니다.

검색이 LIMIT보다 적은 행을 돌려줄 수 있는 이유

HNSW에서는 결과 수가 hnsw.ef_search로 제한되며, 기본값은 40입니다. 200행짜리 테스트 테이블에 HNSW 인덱스를 만들고 LIMIT 100을 실행하면 40행이 돌아왔고, SET hnsw.ef_search = 100 뒤에는 100행이 돌아왔습니다. hnsw.ef_search는 필요한 행 수 이상으로 설정하세요. 트랜잭션 안에서 SET LOCAL을 쓰면 쿼리 하나에만 적용됩니다.

필터링도 결과를 줄입니다. README에 따르면 근사 인덱스에서는 인덱스를 스캔한 뒤에 WHERE 조건이 적용됩니다. 조건이 행의 10%와 맞으면, 기본 hnsw.ef_search인 40의 HNSW는 평균 4행만 돌려줍니다. 버전 0.8.0부터는 반복 인덱스 스캔(SET hnsw.iterative_scan = strict_order 또는 relaxed_order)이 충분한 행을 찾을 때까지 hnsw.max_scan_tuples(기본값 20,000)를 한도로 인덱스를 더 스캔합니다.

README는 NULL 벡터는 인덱싱되지 않으며, 코사인 거리에서는 영벡터도 인덱싱되지 않는다고 덧붙입니다.

검색 결과를 번호 붙인 출처로 /api/chat에 넘기기

검색이 가장 가까운 행을 돌려주면, 모델이 그 행을 프롬프트에서 볼 수 있어야 합니다. Ollama의 /api/chat은 메시지 목록을 받으며, 각 메시지에는 role(system, user, assistant 또는 tool)과 content가 있습니다. 간단한 구성은 규칙을 담은 system 메시지 하나와, 각 행에 [1], [2] 같은 번호를 붙여 나열하고 그 뒤에 질문을 둔 user 메시지 하나입니다. 번호가 있으면 답변이 어떤 행을 썼는지 밝힐 수 있고, 각 번호를 원래 행으로 되돌릴 수 있습니다.

인용을 정해진 형태로 받으려면 format 필드에 답변과 인용 목록(각 인용에 출처 번호와 짧은 인용문)을 담은 JSON Schema를 주세요. Ollama의 구조화된 출력 페이지는 스키마를 프롬프트에도 적고, 더 결정적인 출력을 위해 temperature를 낮추라고(예를 들어 0으로) 권합니다. psql에서는 json_agg 함수가 쿼리의 행을 JSON 배열로 모으며, 아래 도구는 그것을 그대로 읽습니다.

직접 해보기: RAG Prompt Builder

예시의 출처는 json_agg가 돌려주는 모양으로 직접 쓴 것입니다.

출처 3개, 객체의 JSON 배열로 읽었습니다.

curl 명령

이 컴퓨터의 Ollama(localhost:11434)로 요청을 보냅니다.

curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}'

요청 본문(JSON)

{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}

응답에서 답변은 message.content에 있습니다. prompt_eval_count가 예상보다 훨씬 낮다면 프롬프트가 잘렸을 수 있습니다.

요청 필드는 Ollama의 API 레퍼런스와 소스 코드에서 2026-11-05에 Ollama 0.40.11로 확인했습니다. Ollama API: /api/chat

모든 처리는 브라우저 안에서 이루어집니다. 도구는 요청을 작성할 뿐 어디에도 연결하지 않습니다.

출처가 들어가지 않으면 Ollama는 프롬프트의 앞부분을 자른다

규칙, 행, 질문이 모두 컨텍스트 길이 안에 들어가야 합니다. Ollama의 컨텍스트 길이 페이지는 VRAM이 24 GiB 미만이면 기본값을 4k 토큰이라고 하고, FAQ는 4096 토큰이라고 합니다. 우리의 테스트 머신에서 ollama ps는 4096을 보여 주었습니다. options의 num_ctx나 환경 변수 OLLAMA_CONTEXT_LENGTH로 바꿀 수 있습니다.

프롬프트가 더 길어도 /api/chat은 기본적으로 오류를 돌려주지 않습니다. Ollama의 소스 코드(0.40.11)에서 서버는 먼저 system 메시지와 마지막 메시지는 남겨 둔 채 앞쪽 메시지를 통째로 버립니다. 그래도 프롬프트가 너무 길면 처음 몇 개의 토큰(num_keep, 기본값 4)과 프롬프트의 끝을 남기고 그 사이를 버립니다. qwen2.5:7b와 num_ctx 1024로 한 우리의 테스트에서는 2,068 토큰의 프롬프트가 514 토큰으로 잘렸습니다. system 메시지와 첫 번째 출처들이 사라졌습니다. 답이 출처 [1]에 있을 때 모델은 어느 출처에도 없는 암호 단어로 답했고, 답이 마지막 출처에 있을 때는 올바르게 답했지만 system 메시지가 요구한 출처 번호는 붙이지 않았습니다. 응답에 오류는 없었고, 서버 로그에 경고 "truncating input prompt"가 있었습니다.

Ollama의 소스 코드에는 채팅 요청용 truncate 필드도 있으며, /api/chat 페이지에는 나와 있지 않습니다. "truncate": false를 주면 같은 요청이 HTTP 400과 오류 "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it"를 돌려주었습니다. 응답의 prompt_eval_count 필드는 프롬프트의 토큰 수이며, 잘린 테스트에서는 2,068이 아니라 514였습니다.

프롬프트의 끝은 남으므로 질문은 출처 뒤에 두세요. 출처가 들어가지 않는 때를 알려면 "truncate": false를 보낸 다음 num_ctx를 올리거나 보내는 행을 줄이세요. num_ctx를 키우면 메모리가 더 필요합니다.

인용 확인하기

인용은 기계적으로 확인할 수 있습니다. 번호는 보낸 출처를 가리켜야 하고, 인용문은 그 출처에 들어 있어야 합니다. 출처 네 개와 qwen2.5:7b로 한 우리의 테스트에서, JSON 답변의 인용문은 출처와 한 글자도 다르지 않게 일치했습니다. 그렇다고 답변이 맞는다는 증명은 아닙니다. 384차원 벡터의 크기를 물었을 때 모델은 올바른 규칙(4 × 차원 수 + 8바이트)을 인용하고도 1,544가 아니라 1552바이트라고 답했습니다.

인용이 없는 답변도 살펴보세요. 출처에 답이 없었을 때 모델은 한 번은 인용 없이 자신의 지식으로 답했고, 한 번은 모른다고 하면서도 관련 없는 출처를 인용했습니다. 응답의 done_reason이 "length"이면 답변이 num_predict에서 잘렸으며 인용이 빠져 있을 수 있습니다.

아래 검사기는 텍스트만 비교합니다. 출처가 정말로 그 문장을 뒷받침하는지는 사람이 판단해야 합니다.

직접 해보기: RAG Citation Checker

예시 답변은 출처에 없는 인용문을 보여 주기 위해 직접 쓴 것입니다.

출처 4개(문자열의 JSON 배열). 답변은 Ollama /api/chat 응답로 읽었고, 형식은 인용이 담긴 JSON입니다.

프롬프트의 토큰 수(prompt_eval_count): 306

  • 인용 #2의 인용문이 출처 [4]에 없습니다.

인용

출처인용문결과
[2]hnsw.ef_search is 40 by default그대로 찾음
[4]4 bytes per dimension plus 8이 출처에 없음

번호와 인용문이 출처와 일치하는지만 확인합니다. 답변이 맞는지, 출처가 정말로 그 문장을 뒷받침하는지는 알 수 없습니다.

응답 형식은 Ollama의 API 레퍼런스와 소스 코드에서 2026-11-05에 Ollama 0.40.11로 확인했습니다. Ollama API: /api/chat

모든 처리는 브라우저 안에서 이루어집니다. 도구는 어디에도 연결하지 않습니다.

작은 서버의 메모리

README에 따르면 HNSW 인덱스는 그래프가 maintenance_work_mem에 들어갈 때 훨씬 빠르게 만들어지고, 더는 들어가지 않으면 알림을 표시하며, 서버의 메모리를 모두 써 버릴 만큼 maintenance_work_mem을 높게 설정하지 말라고 경고합니다. 다른 인덱스처럼 처음 데이터를 적재한 뒤에 인덱스를 만드는 편이 빠릅니다. 인덱스가 메모리에 다 들어갈 필요는 없지만, 들어가면 성능이 더 좋을 가능성이 높습니다. 인덱스 크기는 SELECT pg_size_pretty(pg_relation_size('index_name'))로 볼 수 있습니다.

작업 집합과 인덱스를 더 작게 하려면 README는 halfvec, 반정밀도 인덱싱, 이진 양자화를 제안합니다. Ollama를 같은 서버에서 돌린다면 PostgreSQL과 모델이 같은 메모리를 나눠 씁니다. 모델이 쓰는 양을 무엇이 정하는지는 Ollama의 메모리를 살펴보는 글에서 설명합니다.

출처

다음에 읽기