Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ neo4j/data/
neo4j/logs/
neo4j/plugins/
neo4j/import/
pg_experiment/data/
venv/
__pycache__/
*.pyc
Expand Down
81 changes: 81 additions & 0 deletions pg_experiment/EXPERIMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# RDB vs GraphDB(Neo4j) 성능 비교 실험

## 목적

지금 서비스(4EVR0-Server)는 `Product-CONTAINS->Ingredient-AFFECTS->Effect-RELATES_TO->Concern`
구조의 지식 그래프를 Neo4j로 운영 중이다. 데이터 규모가 크지 않고(Product ~3,123 / Ingredient ~3,222
/ CONTAINS ~112,967 / AFFECTS ~5,387 / RELATES_TO 25), 실제 프로덕션 쿼리도 hop 수가 고정된
2~3-hop 패턴이라 "이 규모/패턴에서 RDB가 Neo4j보다 느리다는 게 실제로 맞나?"를 직접 재본다.

동일 데이터를 Postgres에 동일 개체/관계로 옮기고, 프로덕션이 실제로 쓰는 Cypher 쿼리 3개
(`4EVR0-Server/app/clients/neo4j_client.py`)를 동등한 SQL로 포팅해서 같은 조건으로 latency를 비교한다.

## 논의 및 결정 사항

### 1. 왜 지금 이 실험이 필요한가

"데이터 간 관계가 명확하니까 그래프DB가 맞는 선택 아니냐"는 질문이 있었음.
결론: 관계가 **고정되고 명확**한 것은 오히려 RDB가 유리해지는 조건에 가깝다.
그래프DB의 강점은 관계가 가변적이거나(hop 수를 미리 모름), 임의 깊이 탐색, 그래프 알고리즘
(커뮤니티 탐지, 최단경로 등)이 필요할 때 나온다. 지금 구조는 고정된 4단계 계층 + 고정 hop
쿼리라서, "관계가 명확함 = 그래프DB가 맞다"는 직관과 반대로 갈 수 있음. 그래서 실측이 필요함.

### 2. 스키마를 "그대로 가져간다"는 것의 의미

Neo4j의 property graph를 복사하는 게 아니라, 같은 개체·관계 모델을 RDB 정규화 규칙으로 옮기는 것.
- `Product`, `Ingredient`, `Effect`, `Concern` → 각각 테이블
- `CONTAINS`, `AFFECTS`, `RELATES_TO` → 각각 연결(junction) 테이블

### 3. 왜 CONTAINS/AFFECTS/RELATES_TO를 별도 테이블로 만드는가

이건 그래프 흉내가 아니라 RDB에서 다대다(M:N) 관계를 표현하는 표준 방식이다.
- `Product`-`Ingredient`, `Ingredient`-`Effect`, `Effect`-`Concern` 모두 다대다 관계라
한쪽 테이블에 FK 하나 추가하는 식(1:N)으로는 표현이 안 되고 연결 테이블이 필수임.
- `AFFECTS`는 관계 자체에 속성(`evidence_type`, `graph_score`, `paper_count`)이 있어서
애초에 두 엔티티 중 하나에 넣을 수 없고 연결 테이블에만 있을 수 있음.
- 배열 컬럼(`Product.ingredients TEXT[]`) 같은 대안은 인덱스/조인 최적화를 못 받아
"RDB가 이 규모에서 얼마나 빠른가"를 공정하게 재는 실험 취지에 맞지 않아서 배제.

## 진행 단계

- [x] 데이터/쿼리 조사: 그래프 구조(`README.md`), 데이터 규모(`csv/nodes`, `csv/edges`),
프로덕션 Cypher 쿼리 3개(`4EVR0-Server/app/clients/neo4j_client.py`) 확인
- [x] `pg_experiment/` 폴더 생성
- [x] `pg_experiment/schema.sql` 작성 — product/ingredient/effect/concern + 연결 테이블 3개,
프로덕션 쿼리가 실제로 타는 컬럼(`contains.inci_name`, `product.category`,
`affects.effect_code`) 기준 인덱스 포함
- [x] `pg_experiment/docker-compose.yml` 작성 — 5433 포트로 별도 Postgres 컨테이너
(4EVR0-Server의 Postgres(5432, 세션 저장용)와 분리, 데이터도 독립)
- [x] Postgres 컨테이너 기동 및 스키마 적용
- [x] CSV → Postgres 적재 스크립트 작성/실행
(product 3122 / ingredient 3221 / effect 15 / concern 15 /
contains 112966 / affects 5386 / relates_to 24 — Neo4j import 원본과 동일)
- 적재 중 `affects.csv`에서 (inci_name, effect_code) 중복 58쌍 발견 →
Neo4j는 멀티그래프라 같은 두 노드 사이 관계가 여러 개 있을 수 있음.
PK를 (inci_name, effect_code)로 걸면 이 행들이 유실되어 Postgres가
실제보다 적은 데이터로 조인하게 됨 → `affects` PK를 BIGSERIAL로 변경,
전체 행 유지 (schema.sql 수정, 커밋 2082434)
- [x] Cypher 쿼리 3종 → SQL 포팅 (`queries.py`) + 결과 일치 검증 (`verify_parity.py`)
- 검증 중 발견한 이슈 1: Postgres DB collation(`en_US.utf8`)이 한글 문자열을
Neo4j(유니코드 코드포인트 기준)와 다르게 정렬 → `query_products_by_ingredients`에서
동점(matched_count 같음) 처리 시 top-5 상품 집합 자체가 완전히 달라짐.
`ORDER BY product_name COLLATE "C"`로 코드포인트 기준 정렬을 맞춰 해결.
- 검증 중 발견한 이슈 2: `query_path_by_effects`는 원본 Cypher가
`ORDER BY r.graph_score DESC` 하나만 쓰고 2차 정렬 기준이 없어서, 동점(같은
graph_score) 행이 많으면 LIMIT 10 안에 어느 행이 들지가 **원본 쿼리 자체가
이미 비결정적**. SQL 포팅 버그가 아니라 프로덕션 쿼리의 기존 특성이라
"고치지" 않고 그대로 반영, 검증도 신원이 아닌 graph_score 분포로만 비교.
- [ ] 벤치마크 하네스 작성 (반복 실행, p50/p95/p99, hop 수 확장 시나리오)
- [ ] 벤치마크 실행 및 결과 정리

## 파일 구성

```
pg_experiment/
├── EXPERIMENT.md # 이 문서 — 과정 기록
├── docker-compose.yml # 벤치마크 전용 Postgres 컨테이너 (5433 포트)
├── schema.sql # RDB 스키마
├── load_csv.py # (예정) CSV -> Postgres 적재
├── queries_sql.py # (예정) Cypher 3종의 SQL 버전
└── benchmark.py # (예정) Neo4j vs Postgres latency 비교
```
103 changes: 103 additions & 0 deletions pg_experiment/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# RDB(Postgres) vs GraphDB(Neo4j) 성능 비교 — 결과 보고서

배경/설계 논의는 [`EXPERIMENT.md`](./EXPERIMENT.md) 참고. 이 문서는 실제 실행 결과만 정리한다.

## 1. 실험 조건

- 데이터: `csv/nodes`, `csv/edges` (Neo4j import에 쓰는 원본과 100% 동일)
- product 3,122 / ingredient 3,221 / effect 15 / concern 15
- contains 112,966 / affects 5,386 / relates_to 24
- 비교 대상 쿼리: 4EVR0-Server가 실제로 쓰는 Cypher 쿼리 3개(`neo4j_client.py`) +
hop-scaling 확인용 4-hop 쿼리 1개(실서비스 미사용)
- 드라이버: Neo4j는 `neo4j` 공식 드라이버(Bolt), Postgres는 `psycopg` v3 — 둘 다
세션/커넥션을 미리 열어 재사용, 순수 쿼리 실행 시간만 측정
- 측정: 워밍업 20회 제외, 200회 반복, 두 엔진에 **완전히 동일한 파라미터 시퀀스**(seed=42)
- LLM 응답 시간은 포함하지 않음 — 이유는 `EXPERIMENT.md` 및 대화 기록 참고 (LLM 추론이
DB latency보다 훨씬 커서 같이 재면 DB 간 차이가 노이즈에 묻힘)

## 2. 결과 정합성 검증 (`verify_parity.py`)

SQL이 Cypher와 실제로 같은 결과를 내는지 먼저 확인했다. 과정에서 실제 버그 2건을 발견/수정했다.

### 1차 실행 — mismatch 발견

```
[MISMATCH] query_products_by_ingredients: cypher=5 rows, sql=5 rows
cypher: [('b404934a-bcad-5e25-92c5-0530ce9bc76f',), ('476a6bf3-9f00-5af4-8df3-579240ab5a2a',), ('dad7b7ae-79bc-5dc1-ab5f-16c5141d7267',), ('4529246a-125a-5e09-b642-319b4dda3b8b',), ('da16bdbf-689d-5b22-bb22-2e99111155d4',)]
sql : [(UUID('fadd2cce-b52d-523c-b3bb-e152a4aff367'),), (UUID('4f2a538e-9dad-55ca-bd8f-32b1785fee98'),), (UUID('e466b944-e9d1-5e5e-a42b-9ad07f7d381f'),), (UUID('061dd41b-23d4-58d3-99e3-6b0aeebefc59'),), (UUID('cb08106d-ca4b-5fb5-982e-d2b650e0d359'),)]
[OK] query_ingredients_by_effects: cypher=20 rows, sql=20 rows
[MISMATCH] query_path_by_effects: cypher=10 rows, sql=10 rows
cypher: [('SOOTHING', 'RETINOL', '코스메쉐프 흑당고 진액 영양 주름앰플', 0.71784), ('SOOTHING', 'RETINOL', '썸바이미 레티놀 인텐스 액션 아이크림', 0.71784), ...]
sql : [('SOOTHING', 'RETINOL', '토리든 셀메이징 저분자 콜라겐 탄력 아이크림', 0.71784), ('SOOTHING', 'RETINOL', '피캄 레티놀라겐 앰플샷 폼클렌저', 0.71784), ...]
```

원인 규명:
- `query_products_by_ingredients`: Postgres DB collation이 `en_US.utf8`이라 한글 상품명
정렬 순서가 Neo4j(유니코드 코드포인트 기준)와 다름 → 동점(matched_count 같음) 상품 중
어느 5개가 뽑히는지가 완전히 달라짐. `ORDER BY product_name COLLATE "C"`로 해결.
- `query_path_by_effects`: 원본 Cypher가 `ORDER BY graph_score DESC` 하나뿐이라 동점 구간
처리가 **프로덕션 쿼리 자체부터 비결정적**. SQL 버그가 아니라서 "고치지" 않고, 검증
기준을 신원 비교 대신 graph_score 분포 비교로 바꿈.

### 재검증 — 전부 통과

```
[OK] query_products_by_ingredients: cypher=5 rows, sql=5 rows
[OK] query_ingredients_by_effects: cypher=20 rows, sql=20 rows
[OK] query_path_by_effects (graph_score만 비교, 동점 구간 비결정적): cypher=10 rows, sql=10 rows
```

## 3. 벤치마크 원본 출력 (`benchmark.py`)

```
=== products_by_ingredients ===
neo4j : {'n': 200, 'mean_ms': 13.716, 'p50_ms': 12.861, 'p95_ms': 17.818, 'p99_ms': 23.432, 'min_ms': 10.525, 'max_ms': 44.21}
postgres: {'n': 200, 'mean_ms': 3.806, 'p50_ms': 1.992, 'p95_ms': 8.553, 'p99_ms': 30.526, 'min_ms': 0.949, 'max_ms': 92.555}
=== ingredients_by_effects ===
neo4j : {'n': 200, 'mean_ms': 10.533, 'p50_ms': 7.299, 'p95_ms': 21.322, 'p99_ms': 29.485, 'min_ms': 3.99, 'max_ms': 51.012}
postgres: {'n': 200, 'mean_ms': 7.229, 'p50_ms': 3.897, 'p95_ms': 17.844, 'p99_ms': 20.968, 'min_ms': 0.984, 'max_ms': 22.026}
=== path_by_effects ===
neo4j : {'n': 200, 'mean_ms': 36.391, 'p50_ms': 25.086, 'p95_ms': 70.702, 'p99_ms': 96.088, 'min_ms': 5.578, 'max_ms': 109.467}
postgres: {'n': 200, 'mean_ms': 54.216, 'p50_ms': 49.82, 'p95_ms': 89.997, 'p99_ms': 116.116, 'min_ms': 6.861, 'max_ms': 118.414}
=== products_by_concern ===
neo4j : {'n': 200, 'mean_ms': 3.448, 'p50_ms': 3.281, 'p95_ms': 4.884, 'p99_ms': 5.848, 'min_ms': 2.48, 'max_ms': 6.759}
postgres: {'n': 200, 'mean_ms': 92.82, 'p50_ms': 18.537, 'p95_ms': 234.146, 'p99_ms': 247.685, 'min_ms': 6.418, 'max_ms': 254.962}

결과 저장: pg_experiment/results/latencies.json
```

원본 JSON: [`results/latencies.json`](./results/latencies.json)

## 4. 요약 표 (hop 수 순)

| 쿼리 | hop 수 | 프로덕션 사용 | Neo4j p50 | Postgres p50 | Neo4j p99 | Postgres p99 | 승자(p50) |
|---|---|---|---:|---:|---:|---:|---|
| products_by_ingredients | 1 | O | 12.9 ms | **2.0 ms** | 23.4 ms | 30.5 ms | Postgres |
| ingredients_by_effects | 1 | O | 7.3 ms | **3.9 ms** | 29.5 ms | 21.0 ms | Postgres |
| path_by_effects | 2 | O | **25.1 ms** | 49.8 ms | 96.1 ms | 116.1 ms | Neo4j |
| products_by_concern | 4 | X(실험용) | **3.3 ms** | 18.5 ms | **5.8 ms** | 247.7 ms | Neo4j (압도적) |

## 5. 해석

- **1-hop, 고정 패턴에서는 Postgres가 이긴다.** 인덱스 잘 걸린 단순 JOIN + 집계는
이 데이터 규모(수천~10만 행)에서 Postgres 플래너가 Neo4j보다 빠르다.
- **hop이 늘어날수록 역전되고, 격차가 급격히 커진다.** 2-hop에서 이미 Neo4j가 앞서고,
4-hop(`products_by_concern`)에서는 p99 기준 **약 43배** 차이(Neo4j 5.8ms vs Postgres
247.7ms). Postgres는 JOIN을 늘릴수록 플래너 비용/중간 결과 크기가 커지는 반면, Neo4j는
포인터 추적(index-free adjacency)이라 hop이 늘어도 비용이 상대적으로 완만하게 증가.
- 다만 4번째 쿼리는 프로덕션에서 안 쓰는 실험용 쿼리이고, `DISTINCT` 결과 신원 자체는
검증하지 않았음(값 분포만 신뢰) — 참고용 신호로만 볼 것.
- **지금 프로덕션 쿼리 3개만 놓고 보면 승부는 갈린다** (1-hop 둘은 RDB 승, 2-hop 하나는
Neo4j 승). "그래프DB가 무조건 유리하다"도 "RDB로 충분하다"도 성급한 결론.

## 6. 이번 실험이 다루지 않은 것

- **LLM 응답 시간을 포함한 end-to-end 지연**: DB 선택과 독립적인 변수라 의도적으로 제외.
필요하면 GPU 서버(`GPU_SERVER_URL`)를 띄우고 `4EVR0-Server/load/locustfile.py`로
별도 실험 필요.
- **가변 길이 경로 탐색** (예: `SIMILAR_TO`/`SUBSTITUTE_FOR` 성분 유사도로 `*1..3` 확장
탐색): 지금 데이터엔 그런 관계가 없어 테스트 못 함. 이런 케이스는 Postgres도
재귀 CTE(`WITH RECURSIVE`)로 구현은 가능하나 대체로 그래프DB가 유리한 전형적 영역 —
"성분 표기가 달라 매칭이 안 되는" 커버리지 부족 문제를 풀려면 이쪽 실험이 이어서 필요.
- **동시 부하(동시성) 상황의 처리량**: 이번 벤치마크는 순차 실행 latency만 측정, 동시
요청 시 커넥션 풀 경합 등은 안 봄.
163 changes: 163 additions & 0 deletions pg_experiment/benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Neo4j vs Postgres 쿼리 latency 벤치마크.

4개 쿼리(프로덕션 3개 + hop-scaling 실험용 1개)를 동일한 파라미터 시퀀스로
양쪽 엔진에 반복 실행해서 p50/p95/p99를 비교한다.

측정 대상은 순수 쿼리 실행 시간(세션/커넥션은 미리 열어두고 재사용)이다.
실제 서비스도 드라이버가 커넥션을 풀링하므로 매 요청마다 핸드셰이크가
일어나지 않는 것과 같은 조건.
"""
import json
import os
import random
import time
from pathlib import Path

import numpy as np
import psycopg
from neo4j import GraphDatabase

from queries import (
CYPHER_INGREDIENTS_BY_EFFECTS,
CYPHER_PATH_BY_EFFECTS,
CYPHER_PRODUCTS_BY_CONCERN,
CYPHER_PRODUCTS_BY_INGREDIENTS,
SQL_INGREDIENTS_BY_EFFECTS,
SQL_PATH_BY_EFFECTS,
SQL_PRODUCTS_BY_CONCERN,
SQL_PRODUCTS_BY_INGREDIENTS,
)

PG_DSN = os.environ.get(
"PG_BENCH_DSN",
"postgresql://bench_user:bench_pass@localhost:5433/graphdb_bench",
)
NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.environ["NEO4J_PASSWORD"]

WARMUP = 20
ITERATIONS = 200
SEED = 42

CONCERNS_WITH_EDGES = [
"BARRIER_DAMAGE", "SENSITIVE_SKIN", "DRY_SKIN",
"ACNE", "OILY_SKIN", "HYPERPIGMENTATION", "POST_ACNE_MARKS",
]


def fetch_param_pools(pg_conn):
with pg_conn.cursor() as cur:
cur.execute("SELECT inci_name FROM ingredient")
ingredients = [r[0] for r in cur.fetchall()]
cur.execute("SELECT DISTINCT category FROM product WHERE category IS NOT NULL")
categories = [r[0] for r in cur.fetchall()]
cur.execute("SELECT effect_code FROM effect")
effects = [r[0] for r in cur.fetchall()]
return ingredients, categories, effects


def build_param_sequences(ingredients, categories, effects, n, seed):
rng = random.Random(seed)
seq = {
"products_by_ingredients": [],
"ingredients_by_effects": [],
"path_by_effects": [],
"products_by_concern": [],
}
for _ in range(n):
seq["products_by_ingredients"].append({
"ingredient_names": rng.sample(ingredients, 3),
"appropriate_categories": rng.sample(categories, min(5, len(categories))),
})
seq["ingredients_by_effects"].append({
"effects": rng.sample(effects, 3),
})
seq["path_by_effects"].append({
"effects": rng.sample(effects, 3),
})
seq["products_by_concern"].append({
"concern_code": rng.choice(CONCERNS_WITH_EDGES),
})
return seq


def time_neo4j(driver, cypher, params_list):
latencies = []
with driver.session() as session:
for i, params in enumerate(params_list):
start = time.perf_counter()
result = session.run(cypher, **params)
_ = [dict(r) for r in result]
elapsed_ms = (time.perf_counter() - start) * 1000
if i >= WARMUP:
latencies.append(elapsed_ms)
return latencies


def time_postgres(conn, sql, params_list):
latencies = []
with conn.cursor() as cur:
for i, params in enumerate(params_list):
start = time.perf_counter()
cur.execute(sql, params)
_ = cur.fetchall()
elapsed_ms = (time.perf_counter() - start) * 1000
if i >= WARMUP:
latencies.append(elapsed_ms)
return latencies


def summarize(latencies):
arr = np.array(latencies)
return {
"n": len(arr),
"mean_ms": round(float(arr.mean()), 3),
"p50_ms": round(float(np.percentile(arr, 50)), 3),
"p95_ms": round(float(np.percentile(arr, 95)), 3),
"p99_ms": round(float(np.percentile(arr, 99)), 3),
"min_ms": round(float(arr.min()), 3),
"max_ms": round(float(arr.max()), 3),
}


def main():
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
pg_conn = psycopg.connect(PG_DSN)

ingredients, categories, effects = fetch_param_pools(pg_conn)
total = WARMUP + ITERATIONS
params = build_param_sequences(ingredients, categories, effects, total, SEED)

query_pairs = {
"products_by_ingredients": (CYPHER_PRODUCTS_BY_INGREDIENTS, SQL_PRODUCTS_BY_INGREDIENTS),
"ingredients_by_effects": (CYPHER_INGREDIENTS_BY_EFFECTS, SQL_INGREDIENTS_BY_EFFECTS),
"path_by_effects": (CYPHER_PATH_BY_EFFECTS, SQL_PATH_BY_EFFECTS),
"products_by_concern": (CYPHER_PRODUCTS_BY_CONCERN, SQL_PRODUCTS_BY_CONCERN),
}

results = {}
for name, (cypher, sql) in query_pairs.items():
print(f"=== {name} ===")
neo4j_lat = time_neo4j(driver, cypher, params[name])
pg_lat = time_postgres(pg_conn, sql, params[name])
results[name] = {"neo4j": summarize(neo4j_lat), "postgres": summarize(pg_lat)}
print(f" neo4j : {results[name]['neo4j']}")
print(f" postgres: {results[name]['postgres']}")

driver.close()
pg_conn.close()

out_dir = Path(__file__).parent / "results"
out_dir.mkdir(exist_ok=True)
out_path = out_dir / "latencies.json"
out_path.write_text(json.dumps(
{"warmup": WARMUP, "iterations": ITERATIONS, "seed": SEED, "results": results},
ensure_ascii=False, indent=2,
))
print(f"\n결과 저장: {out_path}")


if __name__ == "__main__":
main()
17 changes: 17 additions & 0 deletions pg_experiment/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
services:
postgres:
image: postgres:16
container_name: pg_experiment
environment:
POSTGRES_USER: bench_user
POSTGRES_PASSWORD: bench_pass
POSTGRES_DB: graphdb_bench
ports:
- "5433:5432"
volumes:
- ./data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bench_user -d graphdb_bench"]
interval: 5s
timeout: 5s
retries: 5
Loading