diff --git a/.gitignore b/.gitignore index d33ee91..35dc114 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ neo4j/data/ neo4j/logs/ neo4j/plugins/ neo4j/import/ +pg_experiment/data/ venv/ __pycache__/ *.pyc diff --git a/pg_experiment/EXPERIMENT.md b/pg_experiment/EXPERIMENT.md new file mode 100644 index 0000000..85a7eb4 --- /dev/null +++ b/pg_experiment/EXPERIMENT.md @@ -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 비교 +``` diff --git a/pg_experiment/RESULTS.md b/pg_experiment/RESULTS.md new file mode 100644 index 0000000..69da925 --- /dev/null +++ b/pg_experiment/RESULTS.md @@ -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만 측정, 동시 + 요청 시 커넥션 풀 경합 등은 안 봄. diff --git a/pg_experiment/benchmark.py b/pg_experiment/benchmark.py new file mode 100644 index 0000000..d815fc7 --- /dev/null +++ b/pg_experiment/benchmark.py @@ -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() diff --git a/pg_experiment/docker-compose.yml b/pg_experiment/docker-compose.yml new file mode 100644 index 0000000..4aeb448 --- /dev/null +++ b/pg_experiment/docker-compose.yml @@ -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 diff --git a/pg_experiment/load_csv.py b/pg_experiment/load_csv.py new file mode 100644 index 0000000..f37709f --- /dev/null +++ b/pg_experiment/load_csv.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""csv/nodes, csv/edges 데이터를 pg_experiment Postgres에 적재한다. + +neo4j import용 CSV(csv/nodes/*.csv, csv/edges/*.csv)를 그대로 읽어 +schema.sql로 만든 테이블에 넣는다. Neo4j import와 동일한 원본을 쓰므로 +두 DB가 정확히 같은 데이터를 갖는다. +""" +import csv +import os +from pathlib import Path + +import psycopg + +ROOT = Path(__file__).resolve().parent.parent +NODES = ROOT / "csv" / "nodes" +EDGES = ROOT / "csv" / "edges" + +DSN = os.environ.get( + "PG_BENCH_DSN", + "postgresql://bench_user:bench_pass@localhost:5433/graphdb_bench", +) + + +def read_rows(path: Path) -> list[dict]: + with path.open(encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def parse_array(value: str) -> list[str] | None: + if not value: + return None + return value.split(";") + + +def load(conn: psycopg.Connection) -> None: + with conn.cursor() as cur: + products = read_rows(NODES / "product.csv") + cur.executemany( + "INSERT INTO product (product_id, product_name, brand, category, goods_no) " + "VALUES (%s, %s, %s, %s, %s)", + [ + (r["product_id:ID(Product)"], r["product_name"], r["brand"], r["category"], r["goods_no"]) + for r in products + ], + ) + print(f"product: {len(products)}") + + ingredients = read_rows(NODES / "ingredient.csv") + cur.executemany( + "INSERT INTO ingredient (inci_name, kor_name, cosing_functions) VALUES (%s, %s, %s)", + [ + (r["ingredient_id:ID(Ingredient)"], r["kor_name"], parse_array(r["cosing_functions:string[]"])) + for r in ingredients + ], + ) + print(f"ingredient: {len(ingredients)}") + + effects = read_rows(NODES / "effect.csv") + cur.executemany( + "INSERT INTO effect (effect_code, effect_name_en) VALUES (%s, %s)", + [(r["effect_code:ID(Effect)"], r["effect_name_en"]) for r in effects], + ) + print(f"effect: {len(effects)}") + + concerns = read_rows(NODES / "concern.csv") + cur.executemany( + "INSERT INTO concern (concern_code, concern_name_ko) VALUES (%s, %s)", + [(r["concern_code:ID(Concern)"], r["concern_name_ko"]) for r in concerns], + ) + print(f"concern: {len(concerns)}") + + contains = read_rows(EDGES / "contains.csv") + cur.executemany( + "INSERT INTO contains (product_id, inci_name) VALUES (%s, %s)", + [(r[":START_ID(Product)"], r[":END_ID(Ingredient)"]) for r in contains], + ) + print(f"contains: {len(contains)}") + + affects = read_rows(EDGES / "affects.csv") + cur.executemany( + "INSERT INTO affects (inci_name, effect_code, type, evidence_type, graph_score, paper_count) " + "VALUES (%s, %s, %s, %s, %s, %s)", + [ + ( + r[":START_ID(Ingredient)"], + r[":END_ID(Effect)"], + r["type"], + r["evidence_type"], + float(r["graph_score:float"]) if r["graph_score:float"] else None, + int(r["paper_count:int"]) if r["paper_count:int"] else None, + ) + for r in affects + ], + ) + print(f"affects: {len(affects)}") + + relates_to = read_rows(EDGES / "relates_to.csv") + cur.executemany( + "INSERT INTO relates_to (effect_code, concern_code) VALUES (%s, %s)", + [(r[":START_ID(Effect)"], r[":END_ID(Concern)"]) for r in relates_to], + ) + print(f"relates_to: {len(relates_to)}") + + conn.commit() + + +def main() -> None: + with psycopg.connect(DSN) as conn: + load(conn) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/pg_experiment/queries.py b/pg_experiment/queries.py new file mode 100644 index 0000000..a23ae37 --- /dev/null +++ b/pg_experiment/queries.py @@ -0,0 +1,183 @@ +"""프로덕션 Cypher 쿼리 3종(4EVR0-Server/app/clients/neo4j_client.py)과 +동등한 결과를 내는 SQL을 나란히 둔다. Cypher 쪽은 원본을 그대로 복사했고 +(로직을 바꾸면 벤치마크 의미가 없어지므로), SQL은 같은 schema.sql 테이블 기준으로 +동일한 필터/정렬/LIMIT를 재현한 것. + +원본이 바뀌면 이 파일도 다시 맞춰야 한다. +""" + +# ── 1. query_products_by_ingredients ──────────────────────────────────── +# 핵심 성분을 가장 많이 포함한 제품 top 5. +# Neo4j: product_name으로 dedup(head(collect())) 후 matched_count DESC, product_name 정렬. + +CYPHER_PRODUCTS_BY_INGREDIENTS = """ +UNWIND $ingredient_names AS ing_name +MATCH (prod:Product)-[:CONTAINS]->(i:Ingredient {inci_name: ing_name}) +WHERE prod.category IN $appropriate_categories +WITH prod, + COUNT(DISTINCT i.inci_name) AS matched_count, + COLLECT(DISTINCT i.inci_name) AS matched_ingredients +ORDER BY matched_count DESC, prod.product_name +WITH prod.product_name AS product_name, + head(collect(prod)) AS prod, + head(collect(matched_count)) AS matched_count, + head(collect(matched_ingredients)) AS matched_ingredients +RETURN + prod.product_id AS product_id, + product_name AS product_name, + prod.brand AS brand, + prod.category AS category, + matched_count AS matched_count, + matched_ingredients AS matched_ingredients +ORDER BY matched_count DESC, product_name +LIMIT 5 +""" + +SQL_PRODUCTS_BY_INGREDIENTS = """ +WITH matched AS ( + SELECT c.product_id, + COUNT(DISTINCT c.inci_name) AS matched_count, + ARRAY_AGG(DISTINCT c.inci_name) AS matched_ingredients + FROM contains c + JOIN product p ON p.product_id = c.product_id + WHERE c.inci_name = ANY(%(ingredient_names)s) + AND p.category = ANY(%(appropriate_categories)s) + GROUP BY c.product_id +), +ranked AS ( + SELECT p.product_id, p.product_name, p.brand, p.category, + m.matched_count, m.matched_ingredients, + ROW_NUMBER() OVER ( + PARTITION BY p.product_name + ORDER BY m.matched_count DESC, p.product_id + ) AS rn + FROM matched m + JOIN product p ON p.product_id = m.product_id +) +SELECT product_id, product_name, brand, category, matched_count, matched_ingredients +FROM ranked +WHERE rn = 1 +ORDER BY matched_count DESC, product_name COLLATE "C" +LIMIT 5 +""" + + +# ── 2. query_ingredients_by_effects ───────────────────────────────────── +# 주어진 effect들에 대해, 성분별로 가장 강한 근거(pubmed_evidence 우선, graph_score DESC) +# 1건만 남긴 뒤 상위 20개 성분. + +CYPHER_INGREDIENTS_BY_EFFECTS = """ +UNWIND $effects AS effect_code +MATCH (i:Ingredient)-[r:AFFECTS]->(e:Effect {effect_code: effect_code}) +WITH i, e, r, + CASE r.evidence_type WHEN 'pubmed_evidence' THEN 0 ELSE 1 END AS ev_rank +ORDER BY ev_rank, r.graph_score DESC +WITH i, + head(collect({ + claim: e.effect_name_en, + eligibility_tier: r.evidence_type, + paper_ref: toString(r.paper_count), + graph_score: r.graph_score, + ev_rank: ev_rank + })) AS best +RETURN + i.inci_name AS name, + i.kor_name AS kor_name, + best.claim AS claim, + best.eligibility_tier AS eligibility_tier, + best.paper_ref AS paper_ref, + best.graph_score AS graph_score +ORDER BY best.ev_rank, best.graph_score DESC, i.inci_name +LIMIT 20 +""" + +SQL_INGREDIENTS_BY_EFFECTS = """ +WITH candidates AS ( + SELECT a.inci_name, a.evidence_type, a.graph_score, a.paper_count, + e.effect_name_en, + CASE WHEN a.evidence_type = 'pubmed_evidence' THEN 0 ELSE 1 END AS ev_rank, + ROW_NUMBER() OVER ( + PARTITION BY a.inci_name + ORDER BY (CASE WHEN a.evidence_type = 'pubmed_evidence' THEN 0 ELSE 1 END) ASC, + a.graph_score DESC + ) AS rn + FROM affects a + JOIN effect e ON e.effect_code = a.effect_code + WHERE a.effect_code = ANY(%(effects)s) +) +SELECT i.inci_name AS name, + i.kor_name AS kor_name, + c.effect_name_en AS claim, + c.evidence_type AS eligibility_tier, + c.paper_count::text AS paper_ref, + c.graph_score AS graph_score +FROM candidates c +JOIN ingredient i ON i.inci_name = c.inci_name +WHERE c.rn = 1 +ORDER BY c.ev_rank, c.graph_score DESC, i.inci_name COLLATE "C" +LIMIT 20 +""" + + +# ── 3. query_path_by_effects ──────────────────────────────────────────── +# Effect -> Ingredient -> Product 3-hop 경로, graph_score DESC top 10. + +CYPHER_PATH_BY_EFFECTS = """ +UNWIND $effects AS effect_code +MATCH (prod:Product)-[:CONTAINS]->(i:Ingredient)-[r:AFFECTS]->(e:Effect {effect_code: effect_code}) +RETURN + e.effect_code AS effect_code, + e.effect_name_en AS effect_name, + i.inci_name AS ingredient, + i.kor_name AS ingredient_kor, + r.evidence_type AS evidence_type, + r.graph_score AS graph_score, + prod.product_name AS product_name, + prod.brand AS brand +ORDER BY r.graph_score DESC +LIMIT 10 +""" + +SQL_PATH_BY_EFFECTS = """ +SELECT e.effect_code AS effect_code, + e.effect_name_en AS effect_name, + i.inci_name AS ingredient, + i.kor_name AS ingredient_kor, + a.evidence_type AS evidence_type, + a.graph_score AS graph_score, + p.product_name AS product_name, + p.brand AS brand +FROM contains c +JOIN product p ON p.product_id = c.product_id +JOIN ingredient i ON i.inci_name = c.inci_name +JOIN affects a ON a.inci_name = i.inci_name +JOIN effect e ON e.effect_code = a.effect_code +WHERE a.effect_code = ANY(%(effects)s) +ORDER BY a.graph_score DESC +LIMIT 10 +""" + + +# ── 4. query_products_by_concern (프로덕션 미사용, hop-scaling 비교용) ────── +# README에 문서화된 전체 경로 Product-CONTAINS->Ingredient-AFFECTS->Effect-RELATES_TO->Concern. +# 3개 프로덕션 쿼리는 전부 1~2-hop인데, 이 쿼리는 4-hop이라 "hop 수가 늘어나면 +# 그래프DB가 유리해지는 지점이 있는가"를 보려고 추가함. 실서비스에서는 안 씀 +# (eval/RESULTS.md: 4EVR0-Server는 RELATES_TO 대신 하드코딩된 CONCERN_EFFECT_MAP을 씀). + +CYPHER_PRODUCTS_BY_CONCERN = """ +MATCH (prod:Product)-[:CONTAINS]->(i:Ingredient)-[:AFFECTS]->(e:Effect)-[:RELATES_TO]->(c:Concern {concern_code: $concern_code}) +RETURN DISTINCT prod.product_id AS product_id, prod.product_name AS product_name +LIMIT 10 +""" + +SQL_PRODUCTS_BY_CONCERN = """ +SELECT DISTINCT p.product_id AS product_id, p.product_name AS product_name +FROM contains c +JOIN product p ON p.product_id = c.product_id +JOIN ingredient i ON i.inci_name = c.inci_name +JOIN affects a ON a.inci_name = i.inci_name +JOIN effect e ON e.effect_code = a.effect_code +JOIN relates_to rt ON rt.effect_code = e.effect_code +WHERE rt.concern_code = %(concern_code)s +LIMIT 10 +""" diff --git a/pg_experiment/results/latencies.json b/pg_experiment/results/latencies.json new file mode 100644 index 0000000..de15312 --- /dev/null +++ b/pg_experiment/results/latencies.json @@ -0,0 +1,87 @@ +{ + "warmup": 20, + "iterations": 200, + "seed": 42, + "results": { + "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 + } + } + } +} \ No newline at end of file diff --git a/pg_experiment/schema.sql b/pg_experiment/schema.sql new file mode 100644 index 0000000..f79bdb6 --- /dev/null +++ b/pg_experiment/schema.sql @@ -0,0 +1,70 @@ +-- Neo4j 그래프(Product-CONTAINS->Ingredient-AFFECTS->Effect-RELATES_TO->Concern)와 +-- 동일한 개체/관계를 정규화된 관계형 스키마로 옮긴 것. +-- 원본: csv/nodes/*.csv, csv/edges/*.csv (README.md 그래프 구조 참고) + +DROP TABLE IF EXISTS relates_to; +DROP TABLE IF EXISTS affects; +DROP TABLE IF EXISTS contains; +DROP TABLE IF EXISTS concern; +DROP TABLE IF EXISTS effect; +DROP TABLE IF EXISTS ingredient; +DROP TABLE IF EXISTS product; + +CREATE TABLE product ( + product_id UUID PRIMARY KEY, + product_name TEXT NOT NULL, + brand TEXT, + category TEXT, + goods_no TEXT +); + +CREATE TABLE ingredient ( + inci_name TEXT PRIMARY KEY, + kor_name TEXT, + cosing_functions TEXT[] +); + +CREATE TABLE effect ( + effect_code TEXT PRIMARY KEY, + effect_name_en TEXT +); + +CREATE TABLE concern ( + concern_code TEXT PRIMARY KEY, + concern_name_ko TEXT +); + +-- (Product)-[:CONTAINS]->(Ingredient) +CREATE TABLE contains ( + product_id UUID NOT NULL REFERENCES product(product_id), + inci_name TEXT NOT NULL REFERENCES ingredient(inci_name), + PRIMARY KEY (product_id, inci_name) +); + +-- (Ingredient)-[:AFFECTS]->(Effect) +-- Neo4j는 멀티그래프라 같은 (ingredient, effect) 쌍에 관계가 여러 개 있을 수 있음 +-- (실제로 affects.csv에 58쌍 중복 존재) -> PK를 (inci_name, effect_code)로 걸면 +-- 원본보다 적은 행을 갖게 되어 벤치마크가 불공정해짐. surrogate PK로 전체 행 유지. +CREATE TABLE affects ( + id BIGSERIAL PRIMARY KEY, + inci_name TEXT NOT NULL REFERENCES ingredient(inci_name), + effect_code TEXT NOT NULL REFERENCES effect(effect_code), + type TEXT, + evidence_type TEXT, + graph_score DOUBLE PRECISION, + paper_count INT +); + +-- (Effect)-[:RELATES_TO]->(Concern) +CREATE TABLE relates_to ( + effect_code TEXT NOT NULL REFERENCES effect(effect_code), + concern_code TEXT NOT NULL REFERENCES concern(concern_code), + PRIMARY KEY (effect_code, concern_code) +); + +-- 프로덕션 3개 쿼리(neo4j_client.py)가 실제로 타는 조인/필터 컬럼 기준 인덱스. +-- contains.product_id, affects.inci_name은 각 PK의 첫 컬럼이라 이미 인덱스로 커버됨. +CREATE INDEX idx_contains_inci_name ON contains (inci_name); +CREATE INDEX idx_product_category ON product (category); +CREATE INDEX idx_affects_effect_code ON affects (effect_code); +CREATE INDEX idx_affects_inci_name ON affects (inci_name); diff --git a/pg_experiment/verify_parity.py b/pg_experiment/verify_parity.py new file mode 100644 index 0000000..830a609 --- /dev/null +++ b/pg_experiment/verify_parity.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Neo4j 결과와 SQL 결과가 실제로 일치하는지 확인하는 1회성 검증 스크립트. +벤치마크를 신뢰하려면 두 쪽이 같은 결과를 내야 한다. +""" +import os + +import psycopg +from neo4j import GraphDatabase + +from queries import ( + CYPHER_INGREDIENTS_BY_EFFECTS, + CYPHER_PATH_BY_EFFECTS, + CYPHER_PRODUCTS_BY_INGREDIENTS, + SQL_INGREDIENTS_BY_EFFECTS, + SQL_PATH_BY_EFFECTS, + 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"] + + +def run_cypher(driver, query, **params): + with driver.session() as session: + return [dict(r) for r in session.run(query, **params)] + + +def run_sql(conn, query, params): + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(query, params) + return cur.fetchall() + + +def compare(name, cypher_rows, sql_rows, key_fields): + cypher_keys = [tuple(str(r[k]) for k in key_fields) for r in cypher_rows] + sql_keys = [tuple(str(r[k]) for k in key_fields) for r in sql_rows] + match = cypher_keys == sql_keys + print(f"[{'OK' if match else 'MISMATCH'}] {name}: cypher={len(cypher_rows)} rows, sql={len(sql_rows)} rows") + if not match: + print(" cypher:", cypher_keys) + print(" sql :", sql_keys) + + +def main(): + driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)) + with psycopg.connect(PG_DSN) as conn: + # 1. query_products_by_ingredients + ing_names = ["GLYCERIN", "1,2-HEXANEDIOL", "BUTYLENE GLYCOL"] + categories = ["로션", "세럼", "앰플", "크림", "기타"] + c_rows = run_cypher( + driver, CYPHER_PRODUCTS_BY_INGREDIENTS, + ingredient_names=ing_names, appropriate_categories=categories, + ) + s_rows = run_sql( + conn, SQL_PRODUCTS_BY_INGREDIENTS, + {"ingredient_names": ing_names, "appropriate_categories": categories}, + ) + compare("query_products_by_ingredients", c_rows, s_rows, ["product_id"]) + + # 2. query_ingredients_by_effects + effects = ["ANTI_INFLAMMATORY", "SOOTHING", "HYDRATING"] + c_rows = run_cypher(driver, CYPHER_INGREDIENTS_BY_EFFECTS, effects=effects) + s_rows = run_sql(conn, SQL_INGREDIENTS_BY_EFFECTS, {"effects": effects}) + compare("query_ingredients_by_effects", c_rows, s_rows, ["name"]) + + # 3. query_path_by_effects + # 원본 Cypher가 ORDER BY graph_score DESC 하나뿐이라 동점(graph_score 같은 행)이 + # 많으면 어느 행이 LIMIT 10 안에 들지는 원본 쿼리 자체가 이미 비결정적이다. + # (Neo4j도 재실행하면 동점 구간에서 다른 행이 나올 수 있음.) + # 그래서 신원(product_name)이 아니라 graph_score 분포가 같은지만 비교한다. + c_rows = run_cypher(driver, CYPHER_PATH_BY_EFFECTS, effects=effects) + s_rows = run_sql(conn, SQL_PATH_BY_EFFECTS, {"effects": effects}) + compare( + "query_path_by_effects (graph_score만 비교, 동점 구간 비결정적)", + c_rows, s_rows, ["graph_score"], + ) + + driver.close() + + +if __name__ == "__main__": + main()