Skip to content

Commit d5deb82

Browse files
authored
Merge pull request #38 from DocGrid/feature/35
[Feat] 벡터 검색 인프라 구축 - OpenSQL+pgvector 및 bge-m3 임베딩 서버
2 parents 3d0d08d + f4a6cd5 commit d5deb82

8 files changed

Lines changed: 317 additions & 9 deletions

File tree

docker-compose.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
services:
22
postgres:
3-
image: tmaxopensql/postgres:14.6
3+
build:
4+
context: ./docker/opensql
5+
dockerfile: Dockerfile
46
platform: linux/amd64
57
container_name: local-opensql
68
environment:
@@ -41,10 +43,29 @@ services:
4143
networks:
4244
- docgrid-local
4345

46+
embedding-server:
47+
build:
48+
context: ./embedding-server
49+
dockerfile: Dockerfile
50+
container_name: docgrid-embedding
51+
ports:
52+
- "8000:8000"
53+
volumes:
54+
- huggingface-cache:/root/.cache/huggingface
55+
healthcheck:
56+
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
57+
interval: 30s
58+
timeout: 10s
59+
retries: 5
60+
start_period: 900s
61+
networks:
62+
- docgrid-local
63+
4464
networks:
4565
docgrid-local:
4666

4767
volumes:
4868
opensql_data:
4969
name: opensql_data
5070
minio-data:
71+
huggingface-cache:

docker/opensql/Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
FROM tmaxopensql/postgres:14.6
2+
3+
USER root
4+
5+
RUN curl -L https://github.com/pgvector/pgvector/archive/refs/tags/v0.8.0.tar.gz \
6+
-o /tmp/pgvector.tar.gz \
7+
&& tar -xzf /tmp/pgvector.tar.gz -C /tmp \
8+
&& cd /tmp/pgvector-0.8.0 \
9+
&& make PG_CONFIG=/usr/pgsql-14/bin/pg_config \
10+
&& make install PG_CONFIG=/usr/pgsql-14/bin/pg_config \
11+
&& rm -rf /tmp/pgvector.tar.gz /tmp/pgvector-0.8.0
12+
13+
COPY init-and-start.sh /usr/local/bin/init-and-start.sh
14+
RUN chmod +x /usr/local/bin/init-and-start.sh
15+
16+
CMD ["/usr/local/bin/init-and-start.sh"]

docker/opensql/init-and-start.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/bash
2+
set -e
3+
4+
PGDATA="${PGDATA:-/var/lib/pgsql/14/data}"
5+
6+
# Temporarily start postgres (local socket only) for initialization
7+
pg_ctl start -D "$PGDATA" -l /tmp/pg_init.log -o "-h ''" -w
8+
9+
# Create docgrid database if not exists
10+
psql -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'docgrid'" | grep -q 1 \
11+
|| psql -d postgres -c "CREATE DATABASE docgrid OWNER docgrid;"
12+
13+
# Enable pgvector in docgrid database
14+
psql -d docgrid -c "CREATE EXTENSION IF NOT EXISTS vector;"
15+
16+
# Allow TCP connections from any host (needed for host-machine Spring Boot)
17+
grep -qxF "host all all 0.0.0.0/0 scram-sha-256" "$PGDATA/pg_hba.conf" \
18+
|| echo "host all all 0.0.0.0/0 scram-sha-256" >> "$PGDATA/pg_hba.conf"
19+
20+
# Stop temp postgres cleanly before handing off
21+
pg_ctl stop -D "$PGDATA" -m fast -w
22+
23+
# Start postgres in foreground (replaces this process)
24+
exec postgres

docker/opensql/vars.yml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
pg_owner: app
2-
pg_group: app
3-
pg_superuser: app
4-
pg_superuser_password: local_password
1+
pg_owner: docgrid
2+
pg_group: docgrid
3+
pg_superuser: docgrid
4+
pg_superuser_password: docgrid1234
55
pg_database: postgres
6-
pg_databases:
7-
- name: app
8-
owner: app
9-
encoding: UTF-8
106
use_system_user: false
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# 벡터 검색 인프라 구축 (#35)
2+
3+
## 1. OpenSQL + pgvector 구성
4+
5+
### 배경
6+
7+
벡터 검색 기능 구현에 pgvector 확장이 필요하다.
8+
공식 `pgvector/pgvector` Docker 이미지가 있지만, 대회 요구사항이 **OpenSQL(tmaxopensql) 기반**이라 교체할 수 없었다.
9+
`tmaxopensql/postgres:14.6` 이미지에는 pgvector가 포함되어 있지 않아서 직접 컴파일해서 설치하는 방식을 택했다.
10+
11+
### 해결 방법
12+
13+
`docker/opensql/Dockerfile`을 새로 만들어 `tmaxopensql/postgres:14.6` 위에 pgvector 0.8.0을 소스에서 빌드해 설치했다.
14+
기존 이미지 안에 `gcc`, `make`, `pg_config`가 모두 있어서 별도 도구 설치 없이 컴파일이 가능했다.
15+
16+
```dockerfile
17+
FROM tmaxopensql/postgres:14.6
18+
19+
USER root
20+
21+
RUN curl -L https://github.com/pgvector/pgvector/archive/refs/tags/v0.8.0.tar.gz \
22+
-o /tmp/pgvector.tar.gz \
23+
&& tar -xzf /tmp/pgvector.tar.gz -C /tmp \
24+
&& cd /tmp/pgvector-0.8.0 \
25+
&& make PG_CONFIG=/usr/pgsql-14/bin/pg_config \
26+
&& make install PG_CONFIG=/usr/pgsql-14/bin/pg_config \
27+
&& rm -rf /tmp/pgvector.tar.gz /tmp/pgvector-0.8.0
28+
29+
COPY init-and-start.sh /usr/local/bin/init-and-start.sh
30+
RUN chmod +x /usr/local/bin/init-and-start.sh
31+
32+
CMD ["/usr/local/bin/init-and-start.sh"]
33+
```
34+
35+
### init-and-start.sh
36+
37+
OpenSQL entrypoint는 Ansible을 실행해 PostgreSQL을 초기화한다.
38+
Ansible 완료 후 CMD로 지정한 `init-and-start.sh`가 실행되며 아래 작업을 수행한다.
39+
40+
1. postgres를 로컬 소켓으로 임시 시작
41+
2. `docgrid` DB 생성 (없으면)
42+
3. `docgrid` DB에 `vector` 확장 활성화
43+
4. 외부 TCP 접속 허용을 위해 `pg_hba.conf`에 항목 추가
44+
5. postgres 정지 후 foreground로 재시작
45+
46+
```bash
47+
pg_ctl start -D "$PGDATA" -l /tmp/pg_init.log -o "-h ''" -w
48+
49+
psql -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'docgrid'" | grep -q 1 \
50+
|| psql -d postgres -c "CREATE DATABASE docgrid OWNER docgrid;"
51+
52+
psql -d docgrid -c "CREATE EXTENSION IF NOT EXISTS vector;"
53+
54+
grep -qxF "host all all 0.0.0.0/0 trust" "$PGDATA/pg_hba.conf" \
55+
|| echo "host all all 0.0.0.0/0 trust" >> "$PGDATA/pg_hba.conf"
56+
57+
pg_ctl stop -D "$PGDATA" -m fast -w
58+
exec postgres
59+
```
60+
61+
### 트레이드오프 — pg_hba.conf 규칙 범위
62+
63+
현재 `host all all 0.0.0.0/0 trust`로 설정되어 있다.
64+
로컬 개발 환경에서는 문제없지만, 포트가 외부 네트워크에 노출되는 상황(공용 와이파이, 시연 환경 등)에서는 인증 없이 접속이 가능해질 수 있다.
65+
시연 전에는 `192.168.65.1/32` 또는 실제 Docker 브리지 대역으로 범위를 좁히는 것을 권장한다.
66+
67+
### 참고 — Ansible 재실행 동작
68+
69+
OpenSQL entrypoint는 컨테이너가 새로 생성될 때마다 Ansible을 실행한다 (볼륨 유지 여부와 무관).
70+
이미 초기화된 경우 대부분의 태스크가 skip되지만 전체 플레이가 돌기 때문에 약 5분이 소요된다.
71+
`docker compose build` 후 재시작 시에도 동일하게 발생하므로, Dockerfile 변경은 확실한 사항만 모아서 한 번에 반영하는 것이 좋다.
72+
73+
---
74+
75+
## 2. 임베딩 서버 구축
76+
77+
### 설계 배경
78+
79+
pgvector 기반 벡터 검색을 위해 텍스트를 1024차원 벡터로 변환하는 임베딩 서버가 필요했다.
80+
Spring Boot에서 직접 모델을 실행하기 어렵기 때문에 Python 서버를 별도 서비스로 분리하고 HTTP로 통신하는 구조를 채택했다.
81+
82+
모델은 **BAAI/bge-m3**를 사용한다. 다국어(한국어 포함) 지원, 1024차원 dense vector 출력, pgvector HNSW 인덱스와의 궁합이 선택 이유다.
83+
84+
### 구현 구조
85+
86+
```
87+
embedding-server/
88+
├── main.py # FastAPI 앱
89+
├── requirements.txt # 의존성 (버전 고정)
90+
└── Dockerfile # python:3.11-slim 기반
91+
```
92+
93+
Spring Boot와 같은 `docgrid-local` Docker 네트워크에 올라가며, `http://docgrid-embedding:8000`으로 통신한다.
94+
95+
### API 명세
96+
97+
#### GET /health
98+
99+
서버 및 모델 로드 상태 확인.
100+
101+
**Response 200**
102+
```json
103+
{ "status": "ok" }
104+
```
105+
106+
**Response 503** — 모델 미로드 시
107+
```json
108+
{ "detail": "Model not loaded" }
109+
```
110+
111+
#### POST /embed
112+
113+
텍스트를 1024차원 벡터로 변환.
114+
115+
**Request**
116+
```json
117+
{ "text": "검색할 텍스트" }
118+
```
119+
120+
**Response 200**
121+
```json
122+
{ "vector": [0.012, -0.034, ..., 0.087] }
123+
```
124+
125+
**Response 503** — 모델 미로드 시
126+
```json
127+
{ "detail": "Model not loaded" }
128+
```
129+
130+
### 주요 설계 결정
131+
132+
#### FastAPI 선택
133+
134+
async 기반으로 Spring Boot에서 동시 요청이 들어올 때 안정적으로 처리한다.
135+
Swagger UI가 자동 생성되어 별도 설정 없이 `http://localhost:8000/docs`에서 확인 가능하다.
136+
137+
#### 모델 런타임 다운로드 + 볼륨 캐시
138+
139+
빌드 시 모델을 이미지에 포함하면 이미지 크기가 3GB 이상 커진다.
140+
대신 첫 컨테이너 시작 시 HuggingFace에서 다운로드하고 `huggingface-cache` Docker 볼륨에 캐시한다.
141+
두 번째 실행부터는 볼륨에서 로드하므로 다운로드 없이 빠르게 뜬다.
142+
143+
```yaml
144+
volumes:
145+
- huggingface-cache:/root/.cache/huggingface
146+
```
147+
148+
#### 의존성 버전 고정
149+
150+
`FlagEmbedding==1.2.11`이 최신 `transformers`(4.47+)를 끌어오면 `torch 2.4.1`과 DTensor import 충돌이 발생한다.
151+
`transformers==4.44.2`로 고정해서 해결했다.
152+
153+
`peft` 패키지는 `FlagEmbedding`의 reranker 모듈이 의존하지만 자동 설치되지 않아 명시적으로 추가했다.
154+
155+
```
156+
FlagEmbedding==1.2.11
157+
torch==2.4.1
158+
transformers==4.44.2
159+
peft==0.12.0
160+
```
161+
162+
#### healthcheck — curl 대신 python3 urllib
163+
164+
`python:3.11-slim`에는 `curl`이 없다.
165+
별도 패키지 설치 없이 Python 표준 라이브러리로 healthcheck를 구현했다.
166+
167+
```yaml
168+
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
169+
```
170+
171+
### 로컬 실행 방법
172+
173+
```bash
174+
docker compose build embedding-server
175+
docker compose up -d embedding-server
176+
```
177+
178+
- 첫 실행 시 bge-m3 모델 다운로드로 약 10~15분 소요 (약 3GB)
179+
- `docker logs -f docgrid-embedding` 으로 진행 상태 확인
180+
- `Uvicorn running on http://0.0.0.0:8000` 로그가 뜨면 준비 완료
181+
- 이후 재시작은 볼륨 캐시에서 로드하므로 빠름

embedding-server/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY main.py .
9+
10+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

embedding-server/main.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import threading
2+
from contextlib import asynccontextmanager
3+
from fastapi import FastAPI, HTTPException
4+
from pydantic import BaseModel
5+
from FlagEmbedding import BGEM3FlagModel
6+
import logging
7+
8+
logger = logging.getLogger(__name__)
9+
10+
model: BGEM3FlagModel | None = None
11+
12+
13+
def _load_model():
14+
global model
15+
logger.info("Loading BAAI/bge-m3 model...")
16+
model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
17+
logger.info("Model loaded.")
18+
19+
20+
@asynccontextmanager
21+
async def lifespan(app: FastAPI):
22+
thread = threading.Thread(target=_load_model, daemon=True)
23+
thread.start()
24+
yield
25+
global model
26+
model = None
27+
28+
29+
app = FastAPI(lifespan=lifespan)
30+
31+
32+
class EmbedRequest(BaseModel):
33+
text: str
34+
35+
36+
class EmbedResponse(BaseModel):
37+
vector: list[float]
38+
39+
40+
@app.get("/health")
41+
def health():
42+
if model is None:
43+
raise HTTPException(status_code=503, detail="Model not loaded")
44+
return {"status": "ok"}
45+
46+
47+
@app.post("/embed", response_model=EmbedResponse)
48+
def embed(req: EmbedRequest):
49+
if model is None:
50+
raise HTTPException(status_code=503, detail="Model not loaded")
51+
result = model.encode([req.text], batch_size=1, max_length=8192)
52+
vector = result["dense_vecs"][0].tolist()
53+
return EmbedResponse(vector=vector)

embedding-server/requirements.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
fastapi==0.115.0
2+
uvicorn==0.30.6
3+
FlagEmbedding==1.2.11
4+
torch==2.4.1
5+
transformers==4.44.2
6+
peft==0.12.0
7+
numpy==1.26.4

0 commit comments

Comments
 (0)