---
title: "pgvector로 사내 문서형 RAG 서비스 만들기"
slug: "llm-backend-10-pgvector-document-rag"
canonicalUrl: "https://moonshotnotes.com/posts/llm-backend-10-pgvector-document-rag/"
sourceUrl: "https://moonshotnotes.com/posts/llm-backend-10-pgvector-document-rag/"
markdownUrl: "https://moonshotnotes.com/agent/posts/llm-backend-10-pgvector-document-rag.md"
language: "ko"
category: "AI Backend"
updatedAt: "2026-05-26"
agentTokenEstimate: 1774
---

# pgvector로 사내 문서형 RAG 서비스 만들기

PostgreSQL의 pgvector 확장을 사용해 문서 chunk와 embedding을 저장하고, metadata filter와 vector similarity query로 문서형 RAG 서비스를 구현하는 방법을 정리합니다.

## Agent metadata

- Source: https://moonshotnotes.com/posts/llm-backend-10-pgvector-document-rag/
- Markdown: https://moonshotnotes.com/agent/posts/llm-backend-10-pgvector-document-rag.md
- Language: ko
- Category: AI Backend
- Tags: LLM, Backend, RAG, pgvector, PostgreSQL
- Updated: 2026-05-26
- Estimated tokens: 1774

## PostgreSQL 안에서 문서 chunk와 embedding 검색하기

이 글에서는 PostgreSQL과 pgvector를 사용해 문서형 RAG 서비스의 기본 검색 구조를 만들어봅니다.

전용 vector DB를 바로 도입할 수도 있지만, 학습과 초기 서비스에서는 PostgreSQL 안에서 벡터 검색을 시작하는 선택이 꽤 실용적입니다. 사용자, 문서, 질문 이력, 색인 상태를 이미 PostgreSQL에 저장한다면 embedding도 같은 DB에서 관리할 수 있기 때문입니다.

분석 기준일: 2026-05-12
실습 기준 환경: PostgreSQL 16+, pgvector, Python, FastAPI
주요 참고자료: pgvector GitHub, Supabase pgvector Docs, RAG Paper

## 핵심 요약

- pgvector는 PostgreSQL에서 vector similarity search를 가능하게 하는 확장이다.
- 문서형 RAG에서는 `documents`, `document_chunks`, `embeddings`, `index_jobs` 테이블이 기본이다.
- 검색 쿼리는 vector similarity뿐 아니라 metadata filter와 권한 조건을 함께 적용해야 한다.
- chunk_id는 citation과 eval에 연결되는 핵심 식별자다.
- 초기에는 exact search로 시작하고, 데이터가 커지면 index 전략을 검토한다.

## 1. 왜 pgvector인가

pgvector의 장점은 운영 단순성입니다.

| 선택지 | 장점 | 주의점 |
|---|---|---|
| PostgreSQL + pgvector | 기존 DB와 함께 운영 가능 | 대규모 벡터 전용 최적화는 제한적 |
| 전용 vector DB | 대규모 검색 기능 풍부 | 별도 운영 복잡도 |
| 검색 엔진 + vector | hybrid search에 유리 | 학습 비용 증가 |

초기 학습 프로젝트에서는 pgvector가 좋습니다. SQL로 데이터 모델과 검색을 함께 이해할 수 있기 때문입니다.

## 2. RAG 데이터 모델

기본 테이블은 다음과 같습니다.

```text
# 예시입니다.
documents
- id
- title
- source_type
- source_url
- version
- created_at
- updated_at

document_chunks
- id
- document_id
- chunk_index
- content
- token_count
- metadata
- embedding
- created_at

index_jobs
- id
- document_id
- idempotency_key
- status
- error_code
- created_at
- updated_at
```

## 3. pgvector 설치와 확장 활성화

```sql
/* 예시 SQL입니다. */
CREATE EXTENSION IF NOT EXISTS vector;
```

embedding 차원이 1536이라고 가정하면 다음처럼 컬럼을 만들 수 있습니다.

```sql
/* 예시 SQL입니다. */
CREATE TABLE document_chunks (
    id UUID PRIMARY KEY,
    document_id UUID NOT NULL,
    chunk_index INT NOT NULL,
    content TEXT NOT NULL,
    token_count INT NOT NULL,
    metadata JSONB NOT NULL DEFAULT '{}',
    embedding vector(1536),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

embedding 차원은 사용하는 embedding 모델에 맞춰야 합니다.

## 4. 문서와 chunk 테이블 설계

문서 원본과 chunk는 분리해야 합니다.

| 테이블 | 역할 |
|---|---|
| `documents` | 문서 단위 metadata, version 관리 |
| `document_chunks` | 검색 가능한 본문 조각 |
| `index_jobs` | 색인 작업 상태 관리 |
| `questions` | 사용자 질문 이력 |
| `answers` | 모델 답변과 citation 저장 |

chunk에는 반드시 `document_id`, `chunk_index`, `content`, `metadata`, `embedding`이 있어야 합니다.

## 5. Embedding 저장

embedding 생성 흐름:

```text
# 예시입니다.
문서 업로드
→ 텍스트 추출
→ chunking
→ embedding 생성
→ document_chunks에 저장
```

Python 예시:

```python
# 예시 코드입니다.
async def index_chunks(document_id: str, chunks: list[str]):
    for idx, content in enumerate(chunks):
        embedding = await create_embedding(content)
        await repo.insert_chunk(
            document_id=document_id,
            chunk_index=idx,
            content=content,
            embedding=embedding,
        )
```

## 6. Similarity Search 쿼리

pgvector에서는 거리 연산자를 이용해 유사한 vector를 찾을 수 있습니다.

```sql
/* 예시 SQL입니다. */
SELECT
    id,
    document_id,
    content,
    metadata,
    embedding <-> :query_embedding AS distance
FROM document_chunks
ORDER BY embedding <-> :query_embedding
LIMIT 5;
```

거리 값이 낮을수록 더 가깝다는 의미입니다. 사용하는 거리 방식은 embedding 모델과 검색 전략에 맞춰 선택해야 합니다.

## 7. Metadata Filter와 권한

실서비스에서는 vector similarity만으로 검색하면 안 됩니다. 문서 권한과 범위를 필터링해야 합니다.

```sql
/* 예시 SQL입니다. */
SELECT
    c.id,
    c.document_id,
    c.content,
    c.embedding <-> :query_embedding AS distance
FROM document_chunks c
JOIN documents d ON d.id = c.document_id
WHERE d.source_type = :source_type
  AND d.visibility = 'public'
ORDER BY c.embedding <-> :query_embedding
LIMIT :limit;
```

사용자별 권한이 있으면 ACL 테이블과 join해야 합니다.

## 8. Citation 연결

RAG 답변에서 citation을 제공하려면 chunk_id가 답변에 연결되어야 합니다.

```jsonc
// 예시 JSON 구조입니다.
{
  "answer": "Cache Aside는 캐시를 먼저 조회하고 miss 시 DB를 조회하는 패턴입니다.",
  "citations": [
    {
      "document_id": "doc_123",
      "chunk_id": "chunk_456",
      "quote": "check Redis first, return cached data on a hit..."
    }
  ]
}
```

citation은 모델이 임의 생성하면 안 됩니다. 검색 결과로 제공된 chunk 목록에서만 선택하게 해야 합니다.

## 9. 운영 지표와 한계

| 지표 | 의미 |
|---|---|
| `retrieval_latency_ms` | 검색 지연 |
| `retrieval_top_k` | 검색 결과 수 |
| `retrieval_empty_result_count` | 검색 실패 수 |
| `chunk_count_per_document` | 문서별 chunk 수 |
| `embedding_generation_latency` | embedding 생성 지연 |
| `index_job_failure_rate` | 색인 실패율 |

pgvector는 초기 RAG 구현에 좋지만, 데이터가 커지고 검색 요구사항이 복잡해지면 전용 vector DB, hybrid search, reranker를 검토해야 합니다.

## 10. 실무 체크리스트

```text
# 예시입니다.
[ ] documents와 chunks를 분리했는가?
[ ] document version을 저장하는가?
[ ] chunk_id가 citation에 연결되는가?
[ ] embedding model과 dimension을 기록하는가?
[ ] metadata filter를 적용하는가?
[ ] 사용자 권한이 retrieval 단계에서 강제되는가?
[ ] 색인 job 상태를 추적하는가?
[ ] 검색 실패 케이스를 eval dataset으로 저장하는가?
```

## 실패 사례: 검색은 되지만 답변 근거를 설명하지 못하는 RAG

pgvector를 붙인 뒤 가장 먼저 만나는 실패는 "비슷한 문서는 찾았는데 답변 품질을 설명할 수 없는" 상태입니다. embedding column과 similarity query만 있으면 demo는 빠르게 됩니다. 사용자가 질문을 보내면 가까운 chunk를 가져오고, 모델은 그 chunk를 바탕으로 답합니다. 하지만 운영자가 "왜 이 문서를 골랐는가", "권한 없는 문서가 섞이지 않았는가", "답변에 사용된 chunk가 정확히 무엇인가"를 묻기 시작하면 단순 vector search는 부족합니다.

실패의 원인은 보통 metadata와 citation 설계가 늦게 들어가기 때문입니다. `content`와 `embedding`만 저장하면 검색은 가능하지만 문서 버전, tenant, 권한, source URL, chunk 순서, embedding model version을 추적할 수 없습니다. 나중에 hallucination 신고가 들어와도 어떤 chunk가 prompt에 들어갔는지 찾기 어렵습니다.

## 구현 예시: citation 중심 테이블 설계

RAG table은 답변 생성보다 감사 가능성을 먼저 고려해야 합니다.

```sql
create table document_chunks (
  id uuid primary key,
  document_id uuid not null,
  tenant_id text not null,
  source_url text,
  chunk_index integer not null,
  content text not null,
  content_hash text not null,
  embedding_model text not null,
  embedding vector(1536),
  created_at timestamptz not null default now()
);

create index document_chunks_tenant_idx
  on document_chunks (tenant_id, document_id);
```

검색 쿼리도 similarity만 보지 말고 tenant와 문서 상태를 함께 걸어야 합니다.

```sql
select id, document_id, source_url, chunk_index, content
from document_chunks
where tenant_id = $1
order by embedding <=> $2
limit 8;
```

이 예시는 단순하지만 두 가지 가치를 더합니다. 첫째, 답변에 사용한 chunk id를 그대로 citation으로 남길 수 있습니다. 둘째, 권한 조건을 vector search 바깥이 아니라 같은 query 안에 넣습니다. 실제 서비스에서는 문서 공개 범위, 삭제 상태, 최신 버전 여부도 filter에 포함해야 합니다.

## 체크리스트 적용 결과

| 항목 | 확인 질문 | 운영상 의미 |
|---|---|---|
| 권한 | tenant와 ACL filter가 검색 query에 포함되는가 | 내부 문서 노출 방지 |
| 버전 | embedding model과 content hash를 저장하는가 | 재색인 필요 여부 판단 |
| citation | chunk id와 source URL을 답변 로그에 남기는가 | 신고와 eval 재현 |
| 품질 | top-k, threshold, rerank 결과를 측정하는가 | 검색 품질 개선 |

이 표를 통과하면 "pgvector를 붙였다"에서 "문서형 RAG 서비스를 운영할 수 있다"로 한 단계 올라갑니다.

## 11. Q&A

### Q1. pgvector만으로 충분한가요?

초기 학습과 작은 서비스에는 충분할 수 있습니다. 하지만 대규모 검색, 복잡한 hybrid search, 고성능 요구사항이 생기면 별도 검색 엔진이나 vector DB를 검토해야 합니다.

### Q2. chunk 크기는 얼마가 적당한가요?

정답은 없습니다. 문서 유형, 질문 패턴, embedding 모델에 따라 다릅니다. 보통 여러 크기를 실험하고 retrieval eval로 비교해야 합니다.

### Q3. embedding 모델을 바꾸면 어떻게 하나요?

기존 embedding과 차원이 다르거나 의미 공간이 다르면 재색인이 필요합니다. embedding_model_version을 반드시 저장해야 합니다.

## 12. 참고자료와 불확실성

### 참고자료

- pgvector GitHub: https://github.com/pgvector/pgvector
- Supabase pgvector Docs: https://supabase.com/docs/guides/database/extensions/pgvector
- RAG Paper: https://arxiv.org/abs/2005.11401

### 불확실성

- embedding 차원과 거리 연산자는 사용하는 모델에 따라 달라집니다.
- 인덱스 전략은 데이터 크기와 latency 요구사항에 따라 실험이 필요합니다.

---
