From 72b642359456dca84c96edcd93701daaa800d51b Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 06:25:50 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(pg=5Fexperiment):=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=EC=A0=84=EC=B2=B4=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=8D=A4=ED=94=84=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_parity.py/benchmark.py는 신원 또는 latency 숫자만 비교해서, 4개 쿼리가 실제로 뭘 반환하는지 잘리지 않은 전체 결과를 별도로 남김. 이 과정에서 ingredients_by_effects에도 동점 비결정성이 있는 걸 추가로 발견(RETINAL이 HYDRATING/SOOTHING에 동점으로 걸림). --- pg_experiment/dump_full_results.py | 103 +++++++++++++++++ pg_experiment/results/full_query_dump.md | 135 +++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 pg_experiment/dump_full_results.py create mode 100644 pg_experiment/results/full_query_dump.md diff --git a/pg_experiment/dump_full_results.py b/pg_experiment/dump_full_results.py new file mode 100644 index 0000000..7c45f45 --- /dev/null +++ b/pg_experiment/dump_full_results.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""4개 쿼리를 고정 파라미터로 1회씩 실행해서 Neo4j/Postgres 결과를 잘리지 않은 +전체 raw 형태로 markdown 파일에 남긴다. RESULTS.md에 "요약 없이 원본 그대로" +붙여넣기 위한 용도. +""" +import os + +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"] + +PARAMS = { + "products_by_ingredients": { + "cypher": {"ingredient_names": ["GLYCERIN", "1,2-HEXANEDIOL", "BUTYLENE GLYCOL"], + "appropriate_categories": ["로션", "세럼", "앰플", "크림", "기타"]}, + "sql": {"ingredient_names": ["GLYCERIN", "1,2-HEXANEDIOL", "BUTYLENE GLYCOL"], + "appropriate_categories": ["로션", "세럼", "앰플", "크림", "기타"]}, + }, + "ingredients_by_effects": { + "cypher": {"effects": ["ANTI_INFLAMMATORY", "SOOTHING", "HYDRATING"]}, + "sql": {"effects": ["ANTI_INFLAMMATORY", "SOOTHING", "HYDRATING"]}, + }, + "path_by_effects": { + "cypher": {"effects": ["ANTI_INFLAMMATORY", "SOOTHING", "HYDRATING"]}, + "sql": {"effects": ["ANTI_INFLAMMATORY", "SOOTHING", "HYDRATING"]}, + }, + "products_by_concern": { + "cypher": {"concern_code": "ACNE"}, + "sql": {"concern_code": "ACNE"}, + }, +} + +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), +} + + +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 fmt_rows(rows): + if not rows: + return "(결과 없음)\n" + lines = [] + for i, row in enumerate(rows, 1): + lines.append(f"{i}. " + ", ".join(f"{k}={v!r}" for k, v in row.items())) + return "\n".join(lines) + "\n" + + +def main(): + driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)) + pg_conn = psycopg.connect(PG_DSN) + + out = ["# 쿼리별 전체 원본 결과 (파라미터 고정 1회 실행)\n"] + for name, (cypher, sql) in QUERY_PAIRS.items(): + c_rows = run_cypher(driver, cypher, PARAMS[name]["cypher"]) + s_rows = run_sql(pg_conn, sql, PARAMS[name]["sql"]) + + out.append(f"\n## {name}\n") + out.append(f"파라미터: `{PARAMS[name]['cypher']}`\n") + out.append(f"\n### Neo4j 결과 ({len(c_rows)}건)\n```\n{fmt_rows(c_rows)}```\n") + out.append(f"\n### Postgres 결과 ({len(s_rows)}건)\n```\n{fmt_rows(s_rows)}```\n") + + driver.close() + pg_conn.close() + + text = "".join(out) + print(text) + with open("results/full_query_dump.md", "w", encoding="utf-8") as f: + f.write(text) + + +if __name__ == "__main__": + main() diff --git a/pg_experiment/results/full_query_dump.md b/pg_experiment/results/full_query_dump.md new file mode 100644 index 0000000..6a5eb79 --- /dev/null +++ b/pg_experiment/results/full_query_dump.md @@ -0,0 +1,135 @@ +# 쿼리별 전체 원본 결과 (파라미터 고정 1회 실행) + +## products_by_ingredients +파라미터: `{'ingredient_names': ['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'], 'appropriate_categories': ['로션', '세럼', '앰플', '크림', '기타']}` + +### Neo4j 결과 (5건) +``` +1. product_id='b404934a-bcad-5e25-92c5-0530ce9bc76f', product_name='AHC 365 레드세럼 랩핑 모델링', brand='AHC', category='기타', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +2. product_id='476a6bf3-9f00-5af4-8df3-579240ab5a2a', product_name='AHC 에이치 멜라루트 앰플 스페셜', brand='AHC', category='앰플', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +3. product_id='dad7b7ae-79bc-5dc1-ab5f-16c5141d7267', product_name='AHC 에이치 멜라루트 크림', brand='AHC', category='크림', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +4. product_id='4529246a-125a-5e09-b642-319b4dda3b8b', product_name='AHC 온리 포맨 로션', brand='AHC', category='로션', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +5. product_id='da16bdbf-689d-5b22-bb22-2e99111155d4', product_name='AHC 유스 래스팅 리얼 아이크림 포 페이스', brand='AHC', category='크림', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +``` + +### Postgres 결과 (5건) +``` +1. product_id=UUID('b404934a-bcad-5e25-92c5-0530ce9bc76f'), product_name='AHC 365 레드세럼 랩핑 모델링', brand='AHC', category='기타', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +2. product_id=UUID('476a6bf3-9f00-5af4-8df3-579240ab5a2a'), product_name='AHC 에이치 멜라루트 앰플 스페셜', brand='AHC', category='앰플', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +3. product_id=UUID('dad7b7ae-79bc-5dc1-ab5f-16c5141d7267'), product_name='AHC 에이치 멜라루트 크림', brand='AHC', category='크림', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +4. product_id=UUID('4529246a-125a-5e09-b642-319b4dda3b8b'), product_name='AHC 온리 포맨 로션', brand='AHC', category='로션', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +5. product_id=UUID('da16bdbf-689d-5b22-bb22-2e99111155d4'), product_name='AHC 유스 래스팅 리얼 아이크림 포 페이스', brand='AHC', category='크림', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +``` + +## ingredients_by_effects +파라미터: `{'effects': ['ANTI_INFLAMMATORY', 'SOOTHING', 'HYDRATING']}` + +### Neo4j 결과 (20건) +``` +1. name='RETINOL', kor_name='레티놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.71784 +2. name='PETROLATUM', kor_name='페트롤라툼', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +3. name='SALMON EGG EXTRACT', kor_name='연어알추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +4. name='NIACINAMIDE', kor_name='나이아신아마이드', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.672944 +5. name='COLLOIDAL OATMEAL', kor_name='콜로이달오트밀', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.65752 +6. name='PANTHENOL', kor_name='덱스판테놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.64815 +7. name='LINALOOL', kor_name='리날룰', claim='Anti-inflammatory', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.574082 +8. name='FIBRONECTIN', kor_name='피브로넥틴', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.559616 +9. name='RETINAL', kor_name='레틴알', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +10. name='TROXERUTIN', kor_name='트록세루틴', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +11. name='CELLULOSE', kor_name='셀룰로오스', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +12. name='CHOLESTEROL', kor_name='콜레스테롤', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +13. name='UREA', kor_name='우레아', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.446287 +14. name='SALICYLIC ACID', kor_name='살리실릭애씨드', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.431782 +15. name='HEXYLRESORCINOL', kor_name='헥실레조시놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.421994 +16. name='PVP', kor_name='피브이피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.41871 +17. name='HAEMATOCOCCUS PLUVIALIS EXTRACT', kor_name='해마토코쿠스 플루비알리스추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.397097 +18. name='CERAMIDE NP', kor_name='세라마이드엔피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +19. name='FARNESOL', kor_name='파네솔', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +20. name='HYDROLYZED JOJOBA ESTERS', kor_name='하이드롤라이즈드호호바에스터', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.392042 +``` + +### Postgres 결과 (20건) +``` +1. name='RETINOL', kor_name='레티놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.71784 +2. name='PETROLATUM', kor_name='페트롤라툼', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +3. name='SALMON EGG EXTRACT', kor_name='연어알추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +4. name='NIACINAMIDE', kor_name='나이아신아마이드', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.672944 +5. name='COLLOIDAL OATMEAL', kor_name='콜로이달오트밀', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.65752 +6. name='PANTHENOL', kor_name='덱스판테놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.64815 +7. name='LINALOOL', kor_name='리날룰', claim='Anti-inflammatory', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.574082 +8. name='FIBRONECTIN', kor_name='피브로넥틴', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.559616 +9. name='RETINAL', kor_name='레틴알', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +10. name='TROXERUTIN', kor_name='트록세루틴', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +11. name='CELLULOSE', kor_name='셀룰로오스', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +12. name='CHOLESTEROL', kor_name='콜레스테롤', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +13. name='UREA', kor_name='우레아', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.446287 +14. name='SALICYLIC ACID', kor_name='살리실릭애씨드', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.431782 +15. name='HEXYLRESORCINOL', kor_name='헥실레조시놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.421994 +16. name='PVP', kor_name='피브이피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.41871 +17. name='HAEMATOCOCCUS PLUVIALIS EXTRACT', kor_name='해마토코쿠스 플루비알리스추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.397097 +18. name='CERAMIDE NP', kor_name='세라마이드엔피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +19. name='FARNESOL', kor_name='파네솔', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +20. name='HYDROLYZED JOJOBA ESTERS', kor_name='하이드롤라이즈드호호바에스터', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.392042 +``` + +## path_by_effects +파라미터: `{'effects': ['ANTI_INFLAMMATORY', 'SOOTHING', 'HYDRATING']}` + +### Neo4j 결과 (10건) +``` +1. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='코스메쉐프 흑당고 진액 영양 주름앰플', brand='코스메쉐프' +2. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='썸바이미 레티놀 인텐스 액션 아이크림', brand='썸바이미' +3. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='마몽드 포어 슈링커 바쿠치올 레티놀 토너', brand='마몽드' +4. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='아이디얼포맨 퍼펙트 올인원', brand='아이디얼포맨' +5. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='마미케어 바다포도 레티놀 모공앰플', brand='마미케어' +6. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='디오디너리 레티놀 0.5% 인 스쿠알란', brand='디오디너리' +7. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='마몽드 레티놀 앰플 토너', brand='마몽드' +8. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='이니스프리 레티놀 그린티 PDRN 스킨부스터 토너', brand='이니스프리' +9. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='마몽드 포어 슈링커 바쿠치올 크림', brand='마몽드' +10. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='아이오페 맨 프로 레티놀 올인원', brand='아이오페' +``` + +### Postgres 결과 (10건) +``` +1. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='토리든 셀메이징 저분자 콜라겐 탄력 아이크림', brand='토리든' +2. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='피캄 레티놀라겐 앰플샷 폼클렌저', brand='피캄' +3. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='폴라초이스 클리니컬 0.3% 레티놀 + 2% 바쿠치올 트리트먼트', brand='폴라초이스' +4. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='리얼베리어 레티니올 모공 타이트닝 세럼', brand='리얼베리어' +5. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='마몽드 포어 슈링커 바쿠치올 패드', brand='마몽드' +6. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='마미케어 그린 콜라겐 부스팅젤', brand='마미케어' +7. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='이니스프리 레티놀 그린티 PDRN 앰플', brand='이니스프리' +8. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='테라로직 레티놀 안티링클3D 모공 앰플', brand='테라로직' +9. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='아이오페 레티놀 레티젝션 세럼', brand='아이오페' +10. effect_code='SOOTHING', effect_name='Soothing', ingredient='RETINOL', ingredient_kor='레티놀', evidence_type='pubmed_evidence', graph_score=0.71784, product_name='셀퓨전씨 레이저 리쥬버네이션 크림', brand='셀퓨전씨' +``` + +## products_by_concern +파라미터: `{'concern_code': 'ACNE'}` + +### Neo4j 결과 (10건) +``` +1. product_id='6a61522f-5da0-59e8-b98f-7fafd9928fe3', product_name='설화수 맨 본윤유액' +2. product_id='407e796d-e476-5cb2-90e9-0c96ed651227', product_name='설화수 맨 본윤에센스' +3. product_id='8a60724d-25f0-56cc-b055-4803ee3e9436', product_name='닥터하우쉬카 로즈 데이 크림 오리지널' +4. product_id='fb2c7c35-47ac-56b4-8fb0-63450f707bd5', product_name='바이오더마 시카비오 포마드' +5. product_id='f59bf11a-bdc3-5e98-b081-a7a1232e105e', product_name='비오템 옴므 티쀼르 토너' +6. product_id='f350a116-d198-5533-9adc-85da14170eaa', product_name='바이오더마 세비엄 젤 무쌍' +7. product_id='dbb47f05-1532-5ff4-8bde-c23e74199296', product_name='아벤느 시칼파트 플러스 SOS 리페어 크림' +8. product_id='3e0459cc-417b-512d-8672-f4e64d5fb41c', product_name='아벤느 시칼파트 플러스 블레미쉬 크림' +9. product_id='cc9b74a2-bbfb-5789-9639-8dfecda6d747', product_name='아벤느 시칼파트+ 블레미쉬 크림' +10. product_id='2d2847d9-d3f6-5d95-9615-52bca8c4fc41', product_name='더바디샵 티트리 래피드 액션 젤' +``` + +### Postgres 결과 (10건) +``` +1. product_id=UUID('0034f80c-a7d5-5241-9b47-63e8f47aa15e'), product_name='디오디너리 멀티-펩타이드 + 카퍼 펩타이즈 1% 세럼' +2. product_id=UUID('00a86b7d-e99e-51d8-84df-6955d44a4973'), product_name='라빠레뜨 뷰티 카밍 그린 에센셜 세럼' +3. product_id=UUID('00a8e840-ea7a-5787-8410-64c3219e2195'), product_name='온그리디언츠 스킨 베리어 속광 미스트' +4. product_id=UUID('00a94711-3a01-5c38-af54-70f8cb07ee54'), product_name='토리든 밸런스풀 시카 컨트롤 세럼' +5. product_id=UUID('00af015d-2f1a-5ac3-9af3-a06f01742bc5'), product_name='아렌시아 그린 아르티장 클렌저' +6. product_id=UUID('00fdca9a-4942-5244-ac0b-d30e90323d3a'), product_name='주닥 약산성 로즈 68% 클렌징밀크' +7. product_id=UUID('01053733-9fa3-50cc-8aa9-00d0abb8853c'), product_name='라네즈옴므 블루에너지 에센스인로션' +8. product_id=UUID('010de1eb-5360-59f1-bc25-cd4e940daaeb'), product_name='스킨푸드 라이스 마스크 워시오프' +9. product_id=UUID('01373779-d394-5cfa-aadb-392885d491e4'), product_name='라끄베르 옴므 리차지 올인원 에센스' +10. product_id=UUID('01502317-68eb-5c24-9307-8d85eb67c3de'), product_name='나인위시스 pH 캄 시카 토너패드' +``` From 09d7775f7d360d9415825cd35cbdc1736a47810e Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 06:26:10 +0000 Subject: [PATCH 02/11] =?UTF-8?q?docs(pg=5Fexperiment):=20RESULTS.md?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=EC=B2=B4=20=EC=BF=BC=EB=A6=AC=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC,=20=EC=9D=B4=EC=8A=88=203,=20=EA=B2=B0=EB=A1=A0=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4개 쿼리의 전체 원본 결과(파라미터 고정 1회 실행)를 요약 없이 그대로 첨부 - ingredients_by_effects의 동점 비결정성(발견된 이슈 3) 기록 - 벤치마크 결과를 종합한 결론 섹션 추가: hop 수에 따라 유불리가 갈리므로 "그래프DB가 무조건 유리"도 "RDB로 충분"도 성급한 결론이라는 판단과 근거 --- pg_experiment/RESULTS.md | 198 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 4 deletions(-) diff --git a/pg_experiment/RESULTS.md b/pg_experiment/RESULTS.md index 69da925..3bf7678 100644 --- a/pg_experiment/RESULTS.md +++ b/pg_experiment/RESULTS.md @@ -47,7 +47,168 @@ SQL이 Cypher와 실제로 같은 결과를 내는지 먼저 확인했다. 과 [OK] query_path_by_effects (graph_score만 비교, 동점 구간 비결정적): cypher=10 rows, sql=10 rows ``` -## 3. 벤치마크 원본 출력 (`benchmark.py`) +### 발견된 이슈 3 — `query_ingredients_by_effects`도 같은 종류의 비결정성이 있음 + +`dump_full_results.py`로 전체 결과를 다시 뽑아보니(3장), 9번째 행에서 Neo4j는 +`RETINAL`의 `claim='Soothing'`, Postgres는 같은 자리에서 `claim='Hydrating'`을 반환했다 +(둘 다 `graph_score=0.470004`, `paper_ref='1'`로 값 자체는 동일). 원인을 실제 데이터에서 +확인: + +``` + inci_name | effect_code | evidence_type | graph_score | effect_name_en +-----------+-------------+-----------------+-------------+---------------- + RETINAL | HYDRATING | pubmed_evidence | 0.470004 | Hydrating + RETINAL | HYDRATING | pubmed_evidence | 0.470004 | Hydrating + RETINAL | SOOTHING | pubmed_evidence | 0.470004 | Soothing + RETINAL | SOOTHING | pubmed_evidence | 0.470004 | Soothing +``` + +`RETINAL`이 `HYDRATING`/`SOOTHING` 두 효능에 완전히 동점(`ev_rank`, `graph_score` 모두 같음)으로 +걸려 있어서, 원본 Cypher의 `ORDER BY ev_rank, r.graph_score DESC`만으로는 어느 효능이 +`head(collect())`에 남을지 결정이 안 된다. `query_path_by_effects`와 같은 계열의 +문제(프로덕션 쿼리 자체의 정렬 키 부족)이지 SQL 포팅 버그가 아니다. + +## 3. 쿼리별 전체 원본 결과 (`dump_full_results.py`, 파라미터 고정 1회 실행) + +verify_parity.py/benchmark.py는 신원(id) 또는 latency 숫자만 비교했으므로, 각 쿼리가 +실제로 어떤 행을 반환하는지 잘리지 않은 전체 결과를 별도로 뽑았다. 파라미터: +`ingredient_names=['GLYCERIN','1,2-HEXANEDIOL','BUTYLENE GLYCOL']`, +`appropriate_categories=['로션','세럼','앰플','크림','기타']`, +`effects=['ANTI_INFLAMMATORY','SOOTHING','HYDRATING']`, `concern_code='ACNE'`. + +### products_by_ingredients + +**Neo4j (5건)** +``` +1. product_id='b404934a-bcad-5e25-92c5-0530ce9bc76f', product_name='AHC 365 레드세럼 랩핑 모델링', brand='AHC', category='기타', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +2. product_id='476a6bf3-9f00-5af4-8df3-579240ab5a2a', product_name='AHC 에이치 멜라루트 앰플 스페셜', brand='AHC', category='앰플', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +3. product_id='dad7b7ae-79bc-5dc1-ab5f-16c5141d7267', product_name='AHC 에이치 멜라루트 크림', brand='AHC', category='크림', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +4. product_id='4529246a-125a-5e09-b642-319b4dda3b8b', product_name='AHC 온리 포맨 로션', brand='AHC', category='로션', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +5. product_id='da16bdbf-689d-5b22-bb22-2e99111155d4', product_name='AHC 유스 래스팅 리얼 아이크림 포 페이스', brand='AHC', category='크림', matched_count=3, matched_ingredients=['GLYCERIN', '1,2-HEXANEDIOL', 'BUTYLENE GLYCOL'] +``` + +**Postgres (5건)** — product_id/matched_ingredients 순서 표기만 다르고(UUID 타입, 배열 순서) 내용은 동일 +``` +1. product_id=UUID('b404934a-bcad-5e25-92c5-0530ce9bc76f'), product_name='AHC 365 레드세럼 랩핑 모델링', brand='AHC', category='기타', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +2. product_id=UUID('476a6bf3-9f00-5af4-8df3-579240ab5a2a'), product_name='AHC 에이치 멜라루트 앰플 스페셜', brand='AHC', category='앰플', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +3. product_id=UUID('dad7b7ae-79bc-5dc1-ab5f-16c5141d7267'), product_name='AHC 에이치 멜라루트 크림', brand='AHC', category='크림', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +4. product_id=UUID('4529246a-125a-5e09-b642-319b4dda3b8b'), product_name='AHC 온리 포맨 로션', brand='AHC', category='로션', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +5. product_id=UUID('da16bdbf-689d-5b22-bb22-2e99111155d4'), product_name='AHC 유스 래스팅 리얼 아이크림 포 페이스', brand='AHC', category='크림', matched_count=3, matched_ingredients=['1,2-HEXANEDIOL', 'BUTYLENE GLYCOL', 'GLYCERIN'] +``` + +### ingredients_by_effects + +**Neo4j (20건)** +``` +1. name='RETINOL', kor_name='레티놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.71784 +2. name='PETROLATUM', kor_name='페트롤라툼', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +3. name='SALMON EGG EXTRACT', kor_name='연어알추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +4. name='NIACINAMIDE', kor_name='나이아신아마이드', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.672944 +5. name='COLLOIDAL OATMEAL', kor_name='콜로이달오트밀', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.65752 +6. name='PANTHENOL', kor_name='덱스판테놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.64815 +7. name='LINALOOL', kor_name='리날룰', claim='Anti-inflammatory', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.574082 +8. name='FIBRONECTIN', kor_name='피브로넥틴', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.559616 +9. name='RETINAL', kor_name='레틴알', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +10. name='TROXERUTIN', kor_name='트록세루틴', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +11. name='CELLULOSE', kor_name='셀룰로오스', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +12. name='CHOLESTEROL', kor_name='콜레스테롤', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +13. name='UREA', kor_name='우레아', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.446287 +14. name='SALICYLIC ACID', kor_name='살리실릭애씨드', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.431782 +15. name='HEXYLRESORCINOL', kor_name='헥실레조시놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.421994 +16. name='PVP', kor_name='피브이피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.41871 +17. name='HAEMATOCOCCUS PLUVIALIS EXTRACT', kor_name='해마토코쿠스 플루비알리스추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.397097 +18. name='CERAMIDE NP', kor_name='세라마이드엔피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +19. name='FARNESOL', kor_name='파네솔', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +20. name='HYDROLYZED JOJOBA ESTERS', kor_name='하이드롤라이즈드호호바에스터', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.392042 +``` + +**Postgres (20건)** — 9번째 행(`RETINAL`)의 `claim`만 다름(위 "발견된 이슈 3" 참고), 나머지 19건은 완전히 동일 +``` +1. name='RETINOL', kor_name='레티놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.71784 +2. name='PETROLATUM', kor_name='페트롤라툼', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +3. name='SALMON EGG EXTRACT', kor_name='연어알추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.693147 +4. name='NIACINAMIDE', kor_name='나이아신아마이드', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.672944 +5. name='COLLOIDAL OATMEAL', kor_name='콜로이달오트밀', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.65752 +6. name='PANTHENOL', kor_name='덱스판테놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.64815 +7. name='LINALOOL', kor_name='리날룰', claim='Anti-inflammatory', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.574082 +8. name='FIBRONECTIN', kor_name='피브로넥틴', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.559616 +9. name='RETINAL', kor_name='레틴알', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +10. name='TROXERUTIN', kor_name='트록세루틴', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.470004 +11. name='CELLULOSE', kor_name='셀룰로오스', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +12. name='CHOLESTEROL', kor_name='콜레스테롤', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.446287 +13. name='UREA', kor_name='우레아', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.446287 +14. name='SALICYLIC ACID', kor_name='살리실릭애씨드', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.431782 +15. name='HEXYLRESORCINOL', kor_name='헥실레조시놀', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.421994 +16. name='PVP', kor_name='피브이피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.41871 +17. name='HAEMATOCOCCUS PLUVIALIS EXTRACT', kor_name='해마토코쿠스 플루비알리스추출물', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.397097 +18. name='CERAMIDE NP', kor_name='세라마이드엔피', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +19. name='FARNESOL', kor_name='파네솔', claim='Soothing', eligibility_tier='pubmed_evidence', paper_ref='1', graph_score=0.392042 +20. name='HYDROLYZED JOJOBA ESTERS', kor_name='하이드롤라이즈드호호바에스터', claim='Hydrating', eligibility_tier='pubmed_evidence', paper_ref='2', graph_score=0.392042 +``` + +### path_by_effects + +**Neo4j (10건)** — 전부 `RETINOL`-`SOOTHING`(graph_score=0.71784) 동점 구간, 어느 제품이 뽑히는지는 비결정적(§2 참고) +``` +1. effect_code='SOOTHING', ingredient='RETINOL', product_name='코스메쉐프 흑당고 진액 영양 주름앰플', brand='코스메쉐프', graph_score=0.71784 +2. effect_code='SOOTHING', ingredient='RETINOL', product_name='썸바이미 레티놀 인텐스 액션 아이크림', brand='썸바이미', graph_score=0.71784 +3. effect_code='SOOTHING', ingredient='RETINOL', product_name='마몽드 포어 슈링커 바쿠치올 레티놀 토너', brand='마몽드', graph_score=0.71784 +4. effect_code='SOOTHING', ingredient='RETINOL', product_name='아이디얼포맨 퍼펙트 올인원', brand='아이디얼포맨', graph_score=0.71784 +5. effect_code='SOOTHING', ingredient='RETINOL', product_name='마미케어 바다포도 레티놀 모공앰플', brand='마미케어', graph_score=0.71784 +6. effect_code='SOOTHING', ingredient='RETINOL', product_name='디오디너리 레티놀 0.5% 인 스쿠알란', brand='디오디너리', graph_score=0.71784 +7. effect_code='SOOTHING', ingredient='RETINOL', product_name='마몽드 레티놀 앰플 토너', brand='마몽드', graph_score=0.71784 +8. effect_code='SOOTHING', ingredient='RETINOL', product_name='이니스프리 레티놀 그린티 PDRN 스킨부스터 토너', brand='이니스프리', graph_score=0.71784 +9. effect_code='SOOTHING', ingredient='RETINOL', product_name='마몽드 포어 슈링커 바쿠치올 크림', brand='마몽드', graph_score=0.71784 +10. effect_code='SOOTHING', ingredient='RETINOL', product_name='아이오페 맨 프로 레티놀 올인원', brand='아이오페', graph_score=0.71784 +``` + +**Postgres (10건)** — 같은 동점 구간에서 다른 10개 제품이 뽑힘(값 분포는 동일, 신원만 다름) +``` +1. effect_code='SOOTHING', ingredient='RETINOL', product_name='토리든 셀메이징 저분자 콜라겐 탄력 아이크림', brand='토리든', graph_score=0.71784 +2. effect_code='SOOTHING', ingredient='RETINOL', product_name='피캄 레티놀라겐 앰플샷 폼클렌저', brand='피캄', graph_score=0.71784 +3. effect_code='SOOTHING', ingredient='RETINOL', product_name='폴라초이스 클리니컬 0.3% 레티놀 + 2% 바쿠치올 트리트먼트', brand='폴라초이스', graph_score=0.71784 +4. effect_code='SOOTHING', ingredient='RETINOL', product_name='리얼베리어 레티니올 모공 타이트닝 세럼', brand='리얼베리어', graph_score=0.71784 +5. effect_code='SOOTHING', ingredient='RETINOL', product_name='마몽드 포어 슈링커 바쿠치올 패드', brand='마몽드', graph_score=0.71784 +6. effect_code='SOOTHING', ingredient='RETINOL', product_name='마미케어 그린 콜라겐 부스팅젤', brand='마미케어', graph_score=0.71784 +7. effect_code='SOOTHING', ingredient='RETINOL', product_name='이니스프리 레티놀 그린티 PDRN 앰플', brand='이니스프리', graph_score=0.71784 +8. effect_code='SOOTHING', ingredient='RETINOL', product_name='테라로직 레티놀 안티링클3D 모공 앰플', brand='테라로직', graph_score=0.71784 +9. effect_code='SOOTHING', ingredient='RETINOL', product_name='아이오페 레티놀 레티젝션 세럼', brand='아이오페', graph_score=0.71784 +10. effect_code='SOOTHING', ingredient='RETINOL', product_name='셀퓨전씨 레이저 리쥬버네이션 크림', brand='셀퓨전씨', graph_score=0.71784 +``` + +### products_by_concern (concern_code='ACNE', 실험용 쿼리 — `DISTINCT`+`LIMIT`만 있고 `ORDER BY` 없어 신원 비결정적) + +**Neo4j (10건)** +``` +1. product_id='6a61522f-5da0-59e8-b98f-7fafd9928fe3', product_name='설화수 맨 본윤유액' +2. product_id='407e796d-e476-5cb2-90e9-0c96ed651227', product_name='설화수 맨 본윤에센스' +3. product_id='8a60724d-25f0-56cc-b055-4803ee3e9436', product_name='닥터하우쉬카 로즈 데이 크림 오리지널' +4. product_id='fb2c7c35-47ac-56b4-8fb0-63450f707bd5', product_name='바이오더마 시카비오 포마드' +5. product_id='f59bf11a-bdc3-5e98-b081-a7a1232e105e', product_name='비오템 옴므 티쀼르 토너' +6. product_id='f350a116-d198-5533-9adc-85da14170eaa', product_name='바이오더마 세비엄 젤 무쌍' +7. product_id='dbb47f05-1532-5ff4-8bde-c23e74199296', product_name='아벤느 시칼파트 플러스 SOS 리페어 크림' +8. product_id='3e0459cc-417b-512d-8672-f4e64d5fb41c', product_name='아벤느 시칼파트 플러스 블레미쉬 크림' +9. product_id='cc9b74a2-bbfb-5789-9639-8dfecda6d747', product_name='아벤느 시칼파트+ 블레미쉬 크림' +10. product_id='2d2847d9-d3f6-5d95-9615-52bca8c4fc41', product_name='더바디샵 티트리 래피드 액션 젤' +``` + +**Postgres (10건)** — ACNE 관련 제품이 전체 카탈로그의 93%(2,900/3,122)라 사실상 무작위 10개가 뽑힘 +``` +1. product_id=UUID('0034f80c-a7d5-5241-9b47-63e8f47aa15e'), product_name='디오디너리 멀티-펩타이드 + 카퍼 펩타이즈 1% 세럼' +2. product_id=UUID('00a86b7d-e99e-51d8-84df-6955d44a4973'), product_name='라빠레뜨 뷰티 카밍 그린 에센셜 세럼' +3. product_id=UUID('00a8e840-ea7a-5787-8410-64c3219e2195'), product_name='온그리디언츠 스킨 베리어 속광 미스트' +4. product_id=UUID('00a94711-3a01-5c38-af54-70f8cb07ee54'), product_name='토리든 밸런스풀 시카 컨트롤 세럼' +5. product_id=UUID('00af015d-2f1a-5ac3-9af3-a06f01742bc5'), product_name='아렌시아 그린 아르티장 클렌저' +6. product_id=UUID('00fdca9a-4942-5244-ac0b-d30e90323d3a'), product_name='주닥 약산성 로즈 68% 클렌징밀크' +7. product_id=UUID('01053733-9fa3-50cc-8aa9-00d0abb8853c'), product_name='라네즈옴므 블루에너지 에센스인로션' +8. product_id=UUID('010de1eb-5360-59f1-bc25-cd4e940daaeb'), product_name='스킨푸드 라이스 마스크 워시오프' +9. product_id=UUID('01373779-d394-5cfa-aadb-392885d491e4'), product_name='라끄베르 옴므 리차지 올인원 에센스' +10. product_id=UUID('01502317-68eb-5c24-9307-8d85eb67c3de'), product_name='나인위시스 pH 캄 시카 토너패드' +``` + +전체 스크립트: [`dump_full_results.py`](./dump_full_results.py), 원본 파일: [`results/full_query_dump.md`](./results/full_query_dump.md) + +## 4. 벤치마크 원본 출력 (`benchmark.py`) ``` === products_by_ingredients === @@ -68,7 +229,7 @@ SQL이 Cypher와 실제로 같은 결과를 내는지 먼저 확인했다. 과 원본 JSON: [`results/latencies.json`](./results/latencies.json) -## 4. 요약 표 (hop 수 순) +## 5. 요약 표 (hop 수 순) | 쿼리 | hop 수 | 프로덕션 사용 | Neo4j p50 | Postgres p50 | Neo4j p99 | Postgres p99 | 승자(p50) | |---|---|---|---:|---:|---:|---:|---| @@ -77,7 +238,7 @@ SQL이 Cypher와 실제로 같은 결과를 내는지 먼저 확인했다. 과 | 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. 해석 +## 6. 해석 - **1-hop, 고정 패턴에서는 Postgres가 이긴다.** 인덱스 잘 걸린 단순 JOIN + 집계는 이 데이터 규모(수천~10만 행)에서 Postgres 플래너가 Neo4j보다 빠르다. @@ -90,7 +251,7 @@ SQL이 Cypher와 실제로 같은 결과를 내는지 먼저 확인했다. 과 - **지금 프로덕션 쿼리 3개만 놓고 보면 승부는 갈린다** (1-hop 둘은 RDB 승, 2-hop 하나는 Neo4j 승). "그래프DB가 무조건 유리하다"도 "RDB로 충분하다"도 성급한 결론. -## 6. 이번 실험이 다루지 않은 것 +## 7. 이번 실험이 다루지 않은 것 - **LLM 응답 시간을 포함한 end-to-end 지연**: DB 선택과 독립적인 변수라 의도적으로 제외. 필요하면 GPU 서버(`GPU_SERVER_URL`)를 띄우고 `4EVR0-Server/load/locustfile.py`로 @@ -101,3 +262,32 @@ SQL이 Cypher와 실제로 같은 결과를 내는지 먼저 확인했다. 과 "성분 표기가 달라 매칭이 안 되는" 커버리지 부족 문제를 풀려면 이쪽 실험이 이어서 필요. - **동시 부하(동시성) 상황의 처리량**: 이번 벤치마크는 순차 실행 latency만 측정, 동시 요청 시 커넥션 풀 경합 등은 안 봄. + +## 8. 결론 + +1. **"관계가 명확하니 그래프DB가 맞다"는 가정은 틀렸다.** 실제로 지금 스키마는 hop이 + 고정된 단순 구조이고, 1-hop 쿼리 2개(`products_by_ingredients`, + `ingredients_by_effects`)에서는 오히려 **Postgres가 3~6배 빠르다** (p50 기준). + 지금 데이터 규모(수천~10만 행)에서는 인덱스 잘 걸린 RDB가 그래프DB보다 단순 조회에 + 더 효율적이라는 걸 실측으로 확인했다. +2. **그렇다고 "RDB로 갈아타면 된다"도 아니다.** hop이 하나만 늘어도(2-hop + `path_by_effects`) Neo4j가 앞서고, 4-hop에서는 p99 기준 43배까지 벌어진다. + 3개 프로덕션 쿼리만 보면 1승은 Neo4j, 2승은 Postgres로 갈리기 때문에, DB를 + 하나로 통일해서 "이겼다/졌다"를 논할 문제가 아니다 — **쿼리 패턴별로 유불리가 + 다르다는 것 자체가 결론**이다. +3. **지금 당장 마이그레이션할 근거는 없다.** 4EVR0-Server가 실제로 쓰는 쿼리 3개 + 기준 Postgres의 우위는 1-hop 두 곳뿐이고 그 절대 시간도 이미 한 자릿수~10ms대라 + 사용자 체감에 미치는 영향이 크지 않다(LLM 추론이 수백 ms~수 초로 훨씬 지배적 + — §7). 반면 2-hop 쿼리에서 Neo4j 우위를 포기하면서까지 옮길 이유는 부족하다. +4. **오히려 이번 실험에서 진짜 값진 건 벤치마크 숫자가 아니라 발견한 버그 3건**이다 + (§2, §3): Postgres collation 차이로 인한 한글 정렬 불일치, 그리고 + `ingredients_by_effects`/`path_by_effects` 두 프로덕션 쿼리에 이미 존재하던 + 동점 처리 비결정성. 이 중 collation 이슈는 RDB로 갈 경우 반드시 고쳐야 할 + 실제 버그이고, 정렬 비결정성 2건은 DB 종류와 무관하게 지금 프로덕션 Cypher + 쿼리에도 이미 있는 문제라 별도로 리포트할 가치가 있다. +5. **다음에 진짜 봐야 할 건 hop 수가 아니라 탐색 방식이다.** 대화 중 나온 + "성분 표기가 달라 매칭이 안 되는" 커버리지 부족 문제(§7의 가변 길이 경로 탐색)는 + 지금 벤치마크가 다루는 "hop 수 고정 쿼리"의 영역을 아예 벗어난다. 그래프DB를 + 유지할지 판단하려면, 지금처럼 고정 hop 성능을 비교하는 것보다 이 가변 길이 + 탐색(`SIMILAR_TO*1..N` vs 재귀 CTE)이 실제로 얼마나 필요한지가 더 결정적인 + 질문이다. From ec0bb7bb9941366f9d41c6b63174c2d6586cc895 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 06:28:38 +0000 Subject: [PATCH 03/11] =?UTF-8?q?docs(pg=5Fexperiment):=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EA=B3=BC=EC=A0=95=20=EC=84=9C=EC=88=A0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80=20+=20=ED=9B=84=EC=86=8D=20?= =?UTF-8?q?=EA=B3=BC=EC=A0=9C=202=EA=B1=B4=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROCESS.md: "필요해서 이 쿼리를 날렸고 -> 이런 결과가 나왔고 -> 그래서 이렇게 판단했다" 흐름으로 실험 전체 과정을 시간 순으로 정리. RESULTS.md §7에 후속 과제 추가: - LLM이 쿼리를 직접 생성하는 구조가 아님을 코드로 확인 (recommend_service.py는 고정 템플릿 호출, LLM은 파라미터 추출만 담당) -> "Cypher가 LLM 친화적"이라는 장점은 지금 아키텍처에서 발동하지 않음 - 그래프 구조가 추천 품질에 미치는 영향은 eval/RESULTS.md에 이미 일부 다뤄졌으나 RDB 기준 재현 여부는 미확인 --- pg_experiment/PROCESS.md | 134 +++++++++++++++++++++++++++++++++++++++ pg_experiment/RESULTS.md | 14 ++++ 2 files changed, 148 insertions(+) create mode 100644 pg_experiment/PROCESS.md diff --git a/pg_experiment/PROCESS.md b/pg_experiment/PROCESS.md new file mode 100644 index 0000000..fee5552 --- /dev/null +++ b/pg_experiment/PROCESS.md @@ -0,0 +1,134 @@ +# 실험 진행 과정 (시간 순 기록) + +"왜 이 명령을 실행했고, 실행하니 무슨 일이 있었고, 그래서 다음에 뭘 했는지"를 순서대로 +정리한 문서. 설계 논의 요약은 [`EXPERIMENT.md`](./EXPERIMENT.md), 최종 수치/결론은 +[`RESULTS.md`](./RESULTS.md) 참고. + +## 1. 왜 시작했나 + +기존 서비스(4EVR0-Server)는 Neo4j로 상품-성분-효능-피부고민 그래프를 운영 중인데, +데이터 규모가 크지 않고(제품 3천여 개) 실제 프로덕션 쿼리 3개도 hop 수가 고정된 +1~2-hop 패턴이었다. "지금 이 규모/패턴에서 RDB가 Neo4j보다 느리다는 게 실제로 맞나?"를 +직접 재보기로 했다. + +→ **필요했던 것**: 같은 데이터를 Postgres에 옮기고, 실제 프로덕션이 쓰는 쿼리를 SQL로 +포팅해서 같은 조건으로 latency를 비교하는 것. + +## 2. 스키마 설계 + +`csv/nodes/*.csv`, `csv/edges/*.csv`(Neo4j import 원본)를 읽어 `Product`, `Ingredient`, +`Effect`, `Concern` 4개 엔티티 테이블 + `contains`, `affects`, `relates_to` 3개 +연결(junction) 테이블로 설계했다 (`schema.sql`). + +→ **왜 연결 테이블을 따로 뒀나**: `Product`-`Ingredient` 등은 전부 다대다 관계라 +RDB 정규화 규칙상 연결 테이블 없이는 표현 자체가 불가능하다(그래프 흉내가 아니라 +표준 RDB 설계). `affects`는 관계 자체에 `graph_score` 등 속성이 있어서 더더욱 +그렇다. + +→ **결과**: `pg_experiment/docker-compose.yml`로 벤치마크 전용 Postgres 컨테이너를 +5433 포트에 띄우고(4EVR0-Server의 세션용 Postgres 5432와 분리), `schema.sql`을 적용. +문제없이 테이블 7개 생성됨. + +## 3. CSV 적재 — 첫 시도에서 실패, 원인 파악 후 스키마 수정 + +`load_csv.py`로 CSV를 그대로 적재하다가 다음 에러로 중단됨: + +``` +psycopg.errors.UniqueViolation: duplicate key value violates unique constraint "affects_pkey" +DETAIL: Key (inci_name, effect_code)=(COLLOIDAL OATMEAL, SOOTHING) already exists. +``` + +→ **왜 이런 일이 있었나 확인**: `affects.csv`를 직접 세어보니 (성분, 효능) 조합이 +중복인 행이 58쌍 있었다. Neo4j는 멀티그래프라 같은 두 노드 사이에 관계가 여러 개 +있어도 되지만, `affects` 테이블 PK를 `(inci_name, effect_code)`로 걸어놔서 이 중복이 +적재 시 거부된 것. + +→ **판단**: 이 중복을 그냥 버리면 Postgres가 원본보다 적은 행을 갖게 되어 벤치마크가 +Postgres에 유리하게 왜곡된다. 그래서 PK를 `BIGSERIAL` surrogate id로 바꿔 전체 행을 +유지하도록 `schema.sql` 수정 → 스키마 재적용 → 재실행. + +→ **결과**: 전 테이블 정상 적재 확인 (product 3,122 / ingredient 3,221 / effect 15 / +concern 15 / contains 112,966 / affects 5,386 / relates_to 24 — Neo4j import 원본과 +정확히 일치). + +## 4. Cypher → SQL 포팅 + +프로덕션이 실제로 쓰는 Cypher 쿼리 3개(`4EVR0-Server/app/clients/neo4j_client.py`의 +`query_products_by_ingredients`, `query_ingredients_by_effects`, +`query_path_by_effects`)를 그대로 가져와 동등한 SQL을 작성했다 (`queries.py`). +"hop이 늘어나면 그래프DB가 유리해지는 지점이 있을 것"이라는 가설을 확인하려고, +프로덕션엔 없지만 README에 문서화된 전체 4-hop 경로 +(`Product-CONTAINS->Ingredient-AFFECTS->Effect-RELATES_TO->Concern`) 쿼리도 +`products_by_concern`이라는 이름으로 하나 더 추가했다. + +→ **다음 질문**: "SQL이 Cypher랑 진짜 같은 결과를 내는가?"를 확인 안 하면 벤치마크 +자체가 무의미하다. → 5번으로 이어짐. + +## 5. 결과 정합성 검증 — 실제 버그 2건 발견 + +`verify_parity.py`로 같은 파라미터를 Neo4j/Postgres 양쪽에 실행해서 결과를 비교했다. + +**1차 실행 결과**: `query_products_by_ingredients`와 `query_path_by_effects`에서 +mismatch 발생 — 반환된 상품 자체가 서로 완전히 다름. + +→ **원인 조사**: `query_products_by_ingredients`는 상품명(한글) 기준 동점 처리가 +있는데, Postgres DB collation을 확인해보니 `en_US.utf8`이었다. Neo4j(유니코드 +코드포인트 기준 정렬)와 비교해보니 한글 상품명 정렬 순서 자체가 달랐다 +(`ORDER BY product_name` 결과가 서로 다름 — 실제로 Neo4j는 +`16년연속... → AHC 365... → ...`, Postgres `en_US.utf8`은 `스웨덴에그팩 → ...`처럼 +전혀 다른 순서). → `ORDER BY product_name COLLATE "C"`로 수정하니 Neo4j와 순서 +완전 일치. + +→ `query_path_by_effects`는 원인이 달랐다: 원본 Cypher가 `ORDER BY graph_score DESC` +하나뿐이라, 동점(같은 score) 행이 많으면 LIMIT 안에 뭐가 들지가 **원본 쿼리 자체부터 +비결정적**이었다. 이건 SQL 포팅 버그가 아니라서 고치지 않고, 검증 기준을 +"신원 비교"에서 "graph_score 분포 비교"로 바꿨다. + +**재검증 결과**: 3개 쿼리 전부 `[OK]`. + +→ **추가로 발견**: 나중에 4-1단계(전체 결과 덤프)에서 `ingredients_by_effects`에도 +같은 계열 문제가 있는 걸 하나 더 찾았다 — `RETINAL`이 `HYDRATING`/`SOOTHING` 두 +효능에 완전히 동점(graph_score 같음)으로 걸려 있어서 어느 효능이 뽑히는지가 +비결정적이었다. 자세한 내용/실제 데이터는 `RESULTS.md` §2 참고. + +## 6. 벤치마크 실행 + +정합성이 확인된 후 `benchmark.py`로 4개 쿼리 × (워밍업 20회 + 측정 200회)를 양쪽 +엔진에 동일 파라미터 시퀀스(seed 고정)로 실행했다. 세션/커넥션은 미리 열어 재사용해서 +순수 쿼리 실행 시간만 쟀다. + +→ **결과** (p50 기준, 전체 수치는 `RESULTS.md` §5 참고): + +| 쿼리 | hop | 결과 | +|---|---|---| +| products_by_ingredients | 1 | Postgres가 6배 빠름 (2.0ms vs 12.9ms) | +| ingredients_by_effects | 1 | Postgres가 2배 빠름 (3.9ms vs 7.3ms) | +| path_by_effects | 2 | Neo4j가 2배 빠름 (25.1ms vs 49.8ms) | +| products_by_concern | 4 (실험용) | Neo4j가 6배 빠름, **p99는 43배** (247.7ms vs 5.8ms) | + +→ **판단**: 가설이 맞았다 — hop이 늘어날수록 Neo4j가 유리해지고 격차도 커진다. +1-hop에서는 Postgres가, 2-hop 이상부터는 Neo4j가 이긴다. + +## 7. 전체 원본 결과 덤프 + +벤치마크는 숫자만 남기고 실제 반환된 행은 기록하지 않길래, `dump_full_results.py`로 +4개 쿼리 각각 고정 파라미터 1세트를 다시 실행해서 전체 행을 원본 그대로 남겼다 +(`results/full_query_dump.md`, `RESULTS.md` §3에도 전문 포함). 이 과정에서 위 5번의 +`ingredients_by_effects` 동점 이슈를 실제로 눈으로 확인했다. + +## 8. 결과 보고서 작성 → 브랜치 정리 → PR + +`RESULTS.md`에 검증 로그, 벤치마크 원본 출력, 전체 쿼리 결과, 요약 표, 해석, 결론까지 +정리했다. 이 과정에서 커밋을 전부 `main`에 직접 올리는 실수를 했다는 걸 깨닫고, +`feat/pg-vs-neo4j-benchmark` 브랜치를 새로 만들어 커밋들을 옮기고 `main`은 실험 시작 +전 상태로 되돌렸다 (`git branch -f main <실험 시작 전 커밋>` — working tree를 건드리지 +않는 방식으로; 처음엔 `git reset --hard`를 시도했다가 `neo4j/import/`가 컨테이너 +전용 권한(uid 7474, 소유자만 접근 가능)이라 도중에 실패해서 방식을 바꿨다. 실제 파일 +손실은 없었음, `sudo`로 확인함). 이후 브랜치를 push하고 PR을 준비했다. + +## 최종 결론 + +`RESULTS.md` §8 참고. 요약하면: **"관계가 명확하니 그래프DB가 맞다"는 가정은 +틀렸고, 반대로 "그러니 RDB로 갈아타면 된다"도 성급하다.** 쿼리 패턴(hop 수)에 따라 +유불리가 갈리며, 지금 데이터 규모에서는 1-hop은 RDB가, 2-hop 이상은 그래프DB가 +우세하다는 게 실측 결론이다. diff --git a/pg_experiment/RESULTS.md b/pg_experiment/RESULTS.md index 3bf7678..3089f76 100644 --- a/pg_experiment/RESULTS.md +++ b/pg_experiment/RESULTS.md @@ -262,6 +262,20 @@ verify_parity.py/benchmark.py는 신원(id) 또는 latency 숫자만 비교했 "성분 표기가 달라 매칭이 안 되는" 커버리지 부족 문제를 풀려면 이쪽 실험이 이어서 필요. - **동시 부하(동시성) 상황의 처리량**: 이번 벤치마크는 순차 실행 latency만 측정, 동시 요청 시 커넥션 풀 경합 등은 안 봄. +- **"LLM이 쿼리를 직접 생성하기 쉬운가" (text-to-Cypher vs text-to-SQL)**: 확인해보니 + 지금 4EVR0-Server는 LLM이 Cypher를 직접 안 쓴다 — `recommend_service.py`가 + `neo4j_client.py`의 고정 파라미터화 템플릿을 호출할 뿐이고, LLM은 자연어를 + `effect_names`/`ingredient_names` 같은 구조화된 파라미터로 추출하는 역할만 한다 + (text-to-parameter + 고정 템플릿, text-to-query 아님). 그래서 "Cypher가 LLM이 + 쓰기 쉽다"는 그래프DB의 장점은 지금 아키텍처에서는 발동하지 않는다. 이 장점을 + 검증하려면 실제로 LLM이 자연어 질문에서 Cypher/SQL을 직접 생성하는 구조로 바꾼 + 뒤 생성 성공률/문법 정확도를 재는 별도 실험이 필요 — 이번 latency 벤치마크와는 + 성격이 다른 실험. +- **그래프 구조가 추천 품질 자체에 미치는 영향**: `eval/RESULTS.md`에 이미 GraphRAG + 경로 탐색 vs 빈도 베이스라인의 Precision/Recall/NDCG 평가가 있어 부분적으로는 + 다뤄진 주제. 다만 RDB로 옮겼을 때 같은 방식(경로 탐색 기반 추천)이 동일한 품질로 + 재현되는지는 별도 확인이 필요 — latency 비교와 무관한 품질 축이라 이번 실험 + 범위에는 포함하지 않음. ## 8. 결론 From cbed2749a863c2d9af9acbc07045ed0d0826fcfb Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 06:37:53 +0000 Subject: [PATCH 04/11] =?UTF-8?q?docs(pg=5Fexperiment):=20LLM=20=EA=B0=80?= =?UTF-8?q?=EB=B3=80=20=EC=BF=BC=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B3=84=ED=9A=8D=20+=20=ED=92=88=EC=A7=88/hop=20?= =?UTF-8?q?=EB=85=BC=EC=9D=98=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM_QUERY_GENERATION.md: 고정 템플릿(text-to-parameter) -> LLM이 매번 Cypher/SQL을 직접 생성하는 구조(text-to-query)로 바꾸려면 필요한 것들을 정리 - 아키텍처 변경, 가드레일(read-only 권한/LIMIT 강제/EXPLAIN 검증/ 재시도 루프), 기존 pg_experiment 인프라를 재사용한 평가 방법. §5로 추천 품질 비교(eval/RESULTS.md 방법론 재사용) + hop 분포 관찰 항목 추가: "자유도를 주면 hop이 늘어나서 그래프DB 우위가 더 명확해질 것"이라는 가설을 실제로 검증하려면 무엇을 같이 재야 하는지 정리. DISCUSSION_quality_vs_hop.md: 위 가설이 나온 대화 원문 보존 (아직 실험 결과가 아니라 논의/가설 단계임을 분명히 하기 위해 별도 문서로 분리). --- pg_experiment/DISCUSSION_quality_vs_hop.md | 47 ++++++++ pg_experiment/LLM_QUERY_GENERATION.md | 126 +++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 pg_experiment/DISCUSSION_quality_vs_hop.md create mode 100644 pg_experiment/LLM_QUERY_GENERATION.md diff --git a/pg_experiment/DISCUSSION_quality_vs_hop.md b/pg_experiment/DISCUSSION_quality_vs_hop.md new file mode 100644 index 0000000..538f604 --- /dev/null +++ b/pg_experiment/DISCUSSION_quality_vs_hop.md @@ -0,0 +1,47 @@ +# 논의: 추천 품질 비교 실험 + "자유도가 늘면 그래프DB 이유가 명확해지는가" + +`LLM_QUERY_GENERATION.md` §5의 근거가 된 대화 기록. 실험 결과가 아니라 논의/가설 +단계라는 걸 분명히 하려고 별도 문서로 남긴다. + +--- + +**Q1. 쿼리 실행 속도 측면에서 지금 RDB랑 GraphDB 비교하는 실험을 한 거잖아. 그럼 +추천 품질 측면에서 지금 고정 쿼리랑 LLM이 가변적으로 쿼리를 만들었을 때의 품질을 +비교해보는 실험을 해볼 수도 있는 건가?** + +A. 네, 완전히 다른 축이라 해볼 가치 있다. 지금 고정 템플릿은 엔지니어가 미리 +정해둔 단일 순회 경로(Product→Ingredient→Effect, 항상 이 모양)인 반면, LLM에게 +스키마를 자유롭게 주면 질문마다 다른 경로를 스스로 짤 수 있다 — 어떤 질문엔 +브랜드 필터를 추가하거나, 어떤 질문엔 성분 간 관계를 더 타고 들어갈 수도 있다. +이게 고정 템플릿보다 나을 수도, LLM이 스키마를 잘못 이해해서 더 나쁠 수도 있는데 +— 이건 실행 성공 여부(문법)가 아니라 **결과의 관련성**을 재는 문제라, +`eval/RESULTS.md`에 이미 있는 Precision/Recall/NDCG 평가 방식을 LLM 생성 쿼리 +결과에도 그대로 적용해서 고정 쿼리와 비교하면 된다. + +**Q2. 근데 가변으로 바꿨을 때 hop 수가 늘어날 수 있으니까 그래프DB를 사용하는 +이유가 더 명확해지려나?** + +A. 이 연결고리가 꽤 날카로운 지적이다. 맞다 — 지금 고정 템플릿이 hop을 1~2개로 +낮게 묶어둔 건 **엔지니어가 미리 성능을 고려해서 억지로 제한한 것**이지, 원래 이 +도메인 질문들이 다 1~2-hop이라서가 아니다. LLM이 자유롭게 쿼리를 짜면 질문의 +의미에 맞춰 자연스럽게 hop이 늘어나거나(3~4-hop), 심지어 +`LLM_QUERY_GENERATION.md`에서 짚었던 가변 길이 탐색(`*1..N`)까지 필요해질 +가능성이 있다. 그리고 이번 벤치마크(`RESULTS.md`)가 보여준 게 정확히 "hop이 +늘수록 그래프DB 우위가 커진다"는 거였으니, **자유도를 주는 방향으로 갈수록 +그래프DB를 쓸 이유가 강해지는 쪽으로 결과가 수렴할 가능성이 높다.** + +다만 이건 아직 가설이다 — "LLM이 실제로 더 긴/복잡한 경로를 짜는지"와 "그게 +실제로 품질을 높이는지"는 둘 다 직접 재보기 전엔 모른다. 그냥 "자유도 주면 +그래프DB가 유리해질 것"이라고 가정하고 결론 내리면 안 되고, 실제 실험(질문 세트 +→ 생성 → gold reference 비교)에서 hop 수 분포까지 같이 기록해야 이 가설을 +검증할 수 있다. + +--- + +## 결론 (이 논의에서 확정된 것) + +- 품질 비교 실험은 별도로 할 가치가 있고, 기존 `eval/RESULTS.md` 평가 방법론을 + 재사용하면 됨 (새로 만들 필요 없음). +- "자유도 증가 → hop 증가 → 그래프DB 유리"는 **논리적으로 그럴듯한 가설**이지, + 아직 검증된 사실이 아님. `LLM_QUERY_GENERATION.md` §5에 이 가설을 검증하기 위한 + 구체적 측정 항목(hop 분포 기록 + 품질 지표 동시 관찰)을 추가해둠. diff --git a/pg_experiment/LLM_QUERY_GENERATION.md b/pg_experiment/LLM_QUERY_GENERATION.md new file mode 100644 index 0000000..2bce313 --- /dev/null +++ b/pg_experiment/LLM_QUERY_GENERATION.md @@ -0,0 +1,126 @@ +# LLM이 가변 쿼리를 직접 생성하는 구조 — 실행 계획 + +`RESULTS.md` §7에 남겨둔 후속 과제 중 "LLM이 쿼리를 직접 만들기 쉬운가"를 실제로 +검증하려면 무엇이 필요한지 정리한 문서. 아직 구현 전, 계획 단계. + +## 1. 지금 상태 → 목표 + +- **지금**: `recommend_service.py`가 `neo4j_client.py`의 고정 파라미터화 Cypher + 템플릿 3개를 호출한다. LLM은 자연어를 `effect_names`, `ingredient_names` 같은 + 구조화된 파라미터로 추출하는 역할만 함 (text-to-parameter). +- **목표**: 미리 정해진 템플릿 없이, LLM이 매 자연어 질문마다 Cypher(또는 SQL)를 + 즉석에서 생성해서 실행하는 구조 (text-to-query). + +## 2. 왜 필요한가 + +hop-scaling 벤치마크(`RESULTS.md`)로 "실행 성능"은 쟀지만, "쿼리 생성 난이도"는 +전혀 다른 축이라 안 쟀다. 흔히 말하는 "Cypher가 LLM 친화적"이라는 주장의 근거는: + +- SQL: hop이 하나 늘 때마다 JOIN을 하나 더 정확히 이어 붙여야 함 (테이블 별칭, + FK 컬럼명을 전부 정확히 기억해야 하고, 순서를 틀리면 결과가 조용히 틀어짐) +- Cypher: hop이 늘어도 `(a)-[:REL]->(b)-[:REL2]->(c)` 패턴만 이어 붙이면 됨, + 구조가 자연어의 "A가 B를 통해 C로 이어진다"는 서술과 더 가까움 + +**가설**: hop 수가 늘어날수록 SQL 생성 정확도가 Cypher보다 더 가파르게 떨어진다. +이번 latency 벤치마크가 "hop이 늘수록 그래프DB가 실행은 빠르다"를 보여준 것처럼, +생성 정확도 쪽도 같은 패턴(hop 늘수록 그래프DB 유리)이 나올지 확인 가치가 있다. + +## 3. 아키텍처에 필요한 변경 + +- **스키마를 프롬프트에 노출**: 그래프는 노드/관계 타입 설명, RDB는 DDL을 그대로 + 프롬프트에 넣는다. `pg_experiment/schema.sql`과 `README.md`의 그래프 구조 설명을 + 그대로 재사용 가능. +- **별도 서비스 계층으로 분리**: 기존 `neo4j_client.py`의 고정 템플릿 경로는 + 건드리지 않고, 예를 들어 `app/services/dynamic_query_service.py` 같은 새 경로를 + 실험용으로 추가. 프로덕션 안정성에 영향 없이 A/B 가능. +- **가드레일 (필수)**: + - **읽기 전용 강제**: DB 유저 권한 자체를 read-only로 제한하는 게 애플리케이션 + 레벨 키워드 필터보다 안전함 (Cypher `CREATE`/`MERGE`/`DELETE`/`SET`, SQL + `INSERT`/`UPDATE`/`DELETE`/`DROP` 등을 문자열로 막는 방식은 우회 가능). + - **LIMIT 강제 삽입**: LLM이 생성한 쿼리에 LIMIT이 없으면 서버 쪽에서 강제로 + 붙임 (전체 스캔 방지). + - **실행 전 검증**: `EXPLAIN`(Cypher/SQL 둘 다 지원)으로 문법·플랜을 먼저 확인 + 하고, 실패 시 바로 재시도 루프로. + - **타임아웃**: 쿼리 1건당 상한 설정. + - **재시도(self-correction) 루프**: 실행 에러나 빈 결과가 나오면 에러 메시지를 + LLM에게 다시 보여주고 재생성시킴. text-to-SQL 연구에서 흔히 쓰는 패턴이고 + 보통 1~2회 재시도로 성공률이 크게 오른다. + +## 4. 평가 방법 — 지금 `pg_experiment` 인프라를 그대로 재사용 + +이미 Neo4j + Postgres에 동일한 데이터가 떠 있어서(schema.sql, load_csv.py로 적재 +완료) 새로 인프라를 만들 필요가 없다. + +1. **자연어 질문 세트 작성**: hop 수별로 5~10개씩 (1-hop ~ 4-hop), 지금 + `benchmark.py`의 4개 쿼리 카테고리와 대응시켜서 만든다. 예: + - 1-hop: "글리세린 들어간 로션 뭐 있어?" + - 2-hop: "진정 효능 있는 성분 들어간 제품 추천해줘" + - 4-hop: "여드름 고민에 맞는 제품 찾아줘" +2. **스키마 프롬프트 준비**: 그래프 스키마 설명 vs `schema.sql` DDL, 두 버전. +3. **생성 → 검증 → 실행 → 채점 파이프라인**: 질문마다 Cypher/SQL 각각 생성시켜서 + - 문법 유효율 (파싱 에러 없이 EXPLAIN 통과하는 비율) + - 실행 성공률 (에러 없이 결과 반환) + - 의미적 정확도: 이미 만들어둔 `queries.py`의 고정 쿼리를 gold reference로 + 써서, LLM이 생성한 쿼리의 결과를 gold와 비교 + - **hop 수에 따른 정확도 하락 곡선** — 이게 핵심 가설 검증 지점 +4. 재시도 포함/미포함 두 버전으로 측정해서 self-correction 효과도 같이 확인. + +## 5. 추가 측정 항목 — 추천 품질 비교 + hop 분포 관찰 + +기존 4번이 "생성 성공률"을 재는 거였다면, 이 항목은 **결과의 질**과 **자유도가 실제로 +어떤 쿼리 모양을 유도하는지**를 본다. 대화에서 나온 두 가지 질문에서 출발한다. + +### 5-1. 추천 품질: 고정 템플릿 vs LLM 가변 쿼리 + +지금 고정 템플릿은 엔지니어가 미리 정해둔 단일 순회 경로 +(`Product→Ingredient→Effect`)만 쓴다. LLM에게 스키마를 자유롭게 주면 질문마다 +다른 경로를 스스로 구성할 수 있다 — 브랜드 필터를 추가하거나, 성분-성분 관계를 +더 타고 들어가거나. 이게 고정 템플릿보다 관련성 높은 결과를 낼 수도, LLM이 +스키마를 잘못 이해해서 더 나쁜 결과를 낼 수도 있다 — **실행 성공 여부(문법)와는 +완전히 다른 축**이라 별도로 재야 한다. + +- 평가 방법은 새로 만들 필요 없이 `eval/RESULTS.md`에 이미 있는 방식(Precision@20, + Recall@20, NDCG@20, gold label 비교)을 LLM 생성 쿼리 결과에 그대로 적용하면 됨. +- 비교 대상: (a) 지금 고정 템플릿 결과 (b) LLM이 생성한 쿼리 결과 — 같은 gold label + 기준으로 나란히 채점. + +### 5-2. hop 분포 관찰 — "자유도가 늘면 그래프DB를 쓸 이유가 강해지는가" + +지금 고정 템플릿이 hop을 1~2개로 낮게 묶어둔 건 **이 도메인 질문들이 원래 다 +1~2-hop이라서가 아니라, 엔지니어가 성능을 고려해 미리 제한한 것**이다. LLM이 +자유롭게 쿼리를 짜면 질문의 의미에 맞춰 자연스럽게 hop이 늘어나거나(3~4-hop), +`SIMILAR_TO*1..N` 같은 가변 길이 탐색까지 필요해질 가능성이 있다. + +이번 latency 벤치마크(`RESULTS.md`)가 보여준 건 "hop이 늘수록 그래프DB 우위가 +커진다"는 것이었으므로, **자유도를 주는 방향으로 갈수록 그래프DB를 쓸 이유가 +강해지는 쪽으로 수렴할 가능성**이 있다. 다만 이건 지금은 가설일 뿐이고, 두 가지를 +실제로 재보기 전엔 확인된 게 아니다: + +1. LLM이 실제로 더 긴/복잡한 경로를 짜는지 (자유도를 줬다고 반드시 hop이 늘어나는 + 건 아닐 수 있음 — 짧은 경로로도 충분하다고 판단할 수 있음) +2. hop이 늘어난 게 실제로 품질을 높이는지 (hop이 늘었지만 관련 없는 노이즈만 + 추가됐을 수도 있음) + +→ 5-1의 생성/채점 파이프라인을 돌릴 때 **각 생성 쿼리의 hop 수(MATCH/JOIN 개수)를 +같이 기록**해서, "고정 템플릿 대비 LLM 생성 쿼리의 hop 분포가 실제로 늘어나는지", +그리고 "hop이 늘어난 케이스가 품질(Precision/Recall/NDCG)에도 실제로 도움이 +됐는지"를 함께 봐야 이 가설을 검증할 수 있다. + +## 6. 리스크/주의점 + +- **모델 의존성**: 이 프로젝트가 실제로 쓰는 모델(`GPU_SERVER_URL`, Qwen3.5-9B)로 + 테스트해야 의미가 있다. 범용 대형 모델(GPT-4급 등)로 테스트하면 실제 프로덕션과 + 무관한 결과가 나올 수 있음 — 작은 모델일수록 Cypher/SQL 생성 난이도 차이가 더 + 크게 벌어질 가능성이 높다. +- **프로덕션에 그대로 못 붙임**: 지금의 고정 템플릿 방식이 보안·안정성 면에서 + 훨씬 안전하다. 이 실험은 "만약 구조를 바꾼다면 승산이 있는가"를 사전에 + 가늠해보는 조사이지, 당장의 마이그레이션 제안이 아니다. +- **레이턴시 트레이드오프**: 쿼리 생성 자체가 LLM 호출을 최소 1회(재시도 포함 시 + 그 이상) 추가한다. 이번 latency 벤치마크에서는 "LLM 시간은 DB 선택과 무관한 + 변수라 의도적으로 제외"했지만, 이 실험에서는 오히려 **LLM의 쿼리 생성 시간 + 자체가 핵심 측정 대상**이 된다 — 목적이 다르므로 방법론도 반대로 가져가야 함. + +## 7. 진행 여부 + +계획 단계 문서. 실제 구현(질문 세트 작성, 생성/채점 파이프라인 스크립트 등)은 +착수 전 확인 후 진행. From 4bcf9e413b951085e6c1d740c480cbd5e4ffdc33 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 06:45:45 +0000 Subject: [PATCH 05/11] =?UTF-8?q?docs(pg=5Fexperiment):=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20docs/=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=ED=8F=B4=EB=8D=94=EB=A1=9C=20=EB=AA=A8=EC=9C=BC?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *.md 문서 5개(EXPERIMENT/RESULTS/PROCESS/LLM_QUERY_GENERATION/ DISCUSSION_quality_vs_hop)를 pg_experiment/docs/로 이동. 실행 스크립트(schema.sql, load_csv.py, queries.py, verify_parity.py, benchmark.py, dump_full_results.py)와 결과 데이터(results/)는 pg_experiment/ 루트에 그대로 유지. 문서 간 상대 링크와 EXPERIMENT.md의 파일 트리 설명을 새 경로에 맞게 수정. --- .../{ => docs}/DISCUSSION_quality_vs_hop.md | 0 pg_experiment/{ => docs}/EXPERIMENT.md | 33 ++++++++++++++----- .../{ => docs}/LLM_QUERY_GENERATION.md | 0 pg_experiment/{ => docs}/PROCESS.md | 0 pg_experiment/{ => docs}/RESULTS.md | 4 +-- 5 files changed, 27 insertions(+), 10 deletions(-) rename pg_experiment/{ => docs}/DISCUSSION_quality_vs_hop.md (100%) rename pg_experiment/{ => docs}/EXPERIMENT.md (74%) rename pg_experiment/{ => docs}/LLM_QUERY_GENERATION.md (100%) rename pg_experiment/{ => docs}/PROCESS.md (100%) rename pg_experiment/{ => docs}/RESULTS.md (99%) diff --git a/pg_experiment/DISCUSSION_quality_vs_hop.md b/pg_experiment/docs/DISCUSSION_quality_vs_hop.md similarity index 100% rename from pg_experiment/DISCUSSION_quality_vs_hop.md rename to pg_experiment/docs/DISCUSSION_quality_vs_hop.md diff --git a/pg_experiment/EXPERIMENT.md b/pg_experiment/docs/EXPERIMENT.md similarity index 74% rename from pg_experiment/EXPERIMENT.md rename to pg_experiment/docs/EXPERIMENT.md index 85a7eb4..ae95ea9 100644 --- a/pg_experiment/EXPERIMENT.md +++ b/pg_experiment/docs/EXPERIMENT.md @@ -65,17 +65,34 @@ Neo4j의 property graph를 복사하는 게 아니라, 같은 개체·관계 모 graph_score) 행이 많으면 LIMIT 10 안에 어느 행이 들지가 **원본 쿼리 자체가 이미 비결정적**. SQL 포팅 버그가 아니라 프로덕션 쿼리의 기존 특성이라 "고치지" 않고 그대로 반영, 검증도 신원이 아닌 graph_score 분포로만 비교. -- [ ] 벤치마크 하네스 작성 (반복 실행, p50/p95/p99, hop 수 확장 시나리오) -- [ ] 벤치마크 실행 및 결과 정리 +- [x] 벤치마크 하네스 작성 (반복 실행, p50/p95/p99, hop 수 확장 시나리오) — `benchmark.py` +- [x] 벤치마크 실행 및 결과 정리 — 결과/해석/결론은 [`RESULTS.md`](./RESULTS.md), + 전체 진행 과정 서술은 [`PROCESS.md`](./PROCESS.md) 참고 +- [x] 후속 과제 정리 — LLM이 쿼리를 직접 생성하는 구조로 갈 때 필요한 것은 + [`LLM_QUERY_GENERATION.md`](./LLM_QUERY_GENERATION.md), 관련 논의는 + [`DISCUSSION_quality_vs_hop.md`](./DISCUSSION_quality_vs_hop.md) 참고 ## 파일 구성 +설명 문서(`*.md`)는 `docs/`에, 실행 스크립트/설정/결과 데이터는 `pg_experiment/` +루트에 둔다. + ``` 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 비교 +├── docker-compose.yml # 벤치마크 전용 Postgres 컨테이너 (5433 포트) +├── schema.sql # RDB 스키마 +├── load_csv.py # CSV -> Postgres 적재 +├── queries.py # Cypher 원본 + 포팅한 SQL +├── verify_parity.py # Cypher/SQL 결과 일치 검증 +├── benchmark.py # Neo4j vs Postgres latency 벤치마크 +├── dump_full_results.py # 쿼리별 전체 원본 결과 덤프 +├── results/ +│ ├── latencies.json # 벤치마크 원본 수치 +│ └── full_query_dump.md # 쿼리별 전체 원본 결과 +└── docs/ + ├── EXPERIMENT.md # 이 문서 — 설계 논의/진행 단계 + ├── RESULTS.md # 벤치마크 결과 + 결론 + ├── PROCESS.md # 시간순 진행 과정 서술 + ├── LLM_QUERY_GENERATION.md # 후속 과제: LLM 가변 쿼리 생성 실행 계획 + └── DISCUSSION_quality_vs_hop.md # 관련 논의 원문 ``` diff --git a/pg_experiment/LLM_QUERY_GENERATION.md b/pg_experiment/docs/LLM_QUERY_GENERATION.md similarity index 100% rename from pg_experiment/LLM_QUERY_GENERATION.md rename to pg_experiment/docs/LLM_QUERY_GENERATION.md diff --git a/pg_experiment/PROCESS.md b/pg_experiment/docs/PROCESS.md similarity index 100% rename from pg_experiment/PROCESS.md rename to pg_experiment/docs/PROCESS.md diff --git a/pg_experiment/RESULTS.md b/pg_experiment/docs/RESULTS.md similarity index 99% rename from pg_experiment/RESULTS.md rename to pg_experiment/docs/RESULTS.md index 3089f76..ae9bb50 100644 --- a/pg_experiment/RESULTS.md +++ b/pg_experiment/docs/RESULTS.md @@ -206,7 +206,7 @@ verify_parity.py/benchmark.py는 신원(id) 또는 latency 숫자만 비교했 10. product_id=UUID('01502317-68eb-5c24-9307-8d85eb67c3de'), product_name='나인위시스 pH 캄 시카 토너패드' ``` -전체 스크립트: [`dump_full_results.py`](./dump_full_results.py), 원본 파일: [`results/full_query_dump.md`](./results/full_query_dump.md) +전체 스크립트: [`dump_full_results.py`](../dump_full_results.py), 원본 파일: [`results/full_query_dump.md`](../results/full_query_dump.md) ## 4. 벤치마크 원본 출력 (`benchmark.py`) @@ -227,7 +227,7 @@ verify_parity.py/benchmark.py는 신원(id) 또는 latency 숫자만 비교했 결과 저장: pg_experiment/results/latencies.json ``` -원본 JSON: [`results/latencies.json`](./results/latencies.json) +원본 JSON: [`results/latencies.json`](../results/latencies.json) ## 5. 요약 표 (hop 수 순) From e4aeb836b12ad55a917f215bafe2504a0347f333 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 06:54:57 +0000 Subject: [PATCH 06/11] =?UTF-8?q?docs(pg=5Fexperiment):=202x2=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=EB=A7=A4=ED=8A=B8=EB=A6=AD=EC=8A=A4(A/B/C/D)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=98=EC=82=AC=EA=B2=B0=EC=A0=95=20=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=9E=84=EC=9B=8C=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphDB/RDB x 고정/가변 쿼리 4칸을 정의하고, 이미 측정된 것(A vs C 속도 - hop-의존적, A vs C 품질 - verify_parity.py로 델타 없음 확인됨)과 새로 측정해야 할 것(A vs B, C vs D, B vs D 품질 + B vs D, C vs D 속도)을 구분. C vs D(RDB 안에서 고정 vs 가변)를 신규 비교 항목으로 추가해 4칸을 전부 커버하도록 확장. 결론을 미리 정하지 않고 6개 비교 결과를 실측 후 표로 채워서 종합 판단하는 프레임워크 제시 - A vs C 속도가 "일괄 승리"가 아니라 hop-의존적으로 나온 것처럼, 나머지도 그렇게 나올 가능성을 열어둠. --- pg_experiment/docs/LLM_QUERY_GENERATION.md | 71 +++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/pg_experiment/docs/LLM_QUERY_GENERATION.md b/pg_experiment/docs/LLM_QUERY_GENERATION.md index 2bce313..9d35923 100644 --- a/pg_experiment/docs/LLM_QUERY_GENERATION.md +++ b/pg_experiment/docs/LLM_QUERY_GENERATION.md @@ -106,7 +106,74 @@ hop-scaling 벤치마크(`RESULTS.md`)로 "실행 성능"은 쟀지만, "쿼리 그리고 "hop이 늘어난 케이스가 품질(Precision/Recall/NDCG)에도 실제로 도움이 됐는지"를 함께 봐야 이 가설을 검증할 수 있다. -## 6. 리스크/주의점 +## 6. 전체 2×2 비교 매트릭스 및 의사결정 프레임워크 + +지금까지 나온 비교를 전부 모으면 "엔진(GraphDB/RDB) × 쿼리 방식(고정/가변)" 2×2가 +된다. 4칸을 다 채워야 "결국 뭘 써야 하나"에 완전한 답을 할 수 있다. + +| | 고정 쿼리 | LLM 가변 쿼리 | +|---|---|---| +| **GraphDB** | **A** (지금 프로덕션) | **B** | +| **RDB** | **C** (`pg_experiment`) | **D** | + +### 이미 측정된 것 + +- **A vs C, 속도**: `RESULTS.md`에서 실측 완료. **주의**: "A가 일괄적으로 이겼다"가 + 아니라 **hop 수에 따라 갈렸다** — 1-hop 쿼리 2개(`products_by_ingredients`, + `ingredients_by_effects`)는 오히려 C(Postgres)가 3~6배 빠르고, 2-hop 이상 + (`path_by_effects`, `products_by_concern`)부터 A(Neo4j)가 앞선다. 이 hop-의존성 + 자체가 이번 실험의 핵심 결론이라, 최종 판단에도 "A가 낫다"가 아니라 이 곡선을 + 그대로 반영해야 한다. +- **A vs C, 품질**: 별도 실험 불필요. `verify_parity.py`로 고정 Cypher와 고정 SQL이 + 동일 결과(또는 알려진 동점 구간만 다른 동일 분포)를 낸다는 걸 이미 확인했다 — + 즉 고정 쿼리끼리는 **품질 델타가 없다(A≈C)**. + +### 새로 측정해야 할 것 + +- **A vs B, 품질** (§5-1): GraphDB 안에서 고정 vs 가변. "자유도를 주면 GraphDB + 안에서 품질이 오르는가." +- **C vs D, 품질** (신규): RDB 안에서 고정 vs 가변. §5-1과 동일한 방법론 + (`eval/RESULTS.md`의 gold label 채점)을 SQL 생성 쪽에도 그대로 적용. "자유도를 + 주면 RDB 안에서도 품질이 오르는가, 아니면 SQL은 JOIN이 복잡해질수록 오히려 + 틀린 쿼리가 늘어 품질이 떨어지는가" — 이게 §5-2에서 이미 짚은 "hop 늘수록 SQL + 생성이 더 어려워질 것"이라는 가설과 직결된다. +- **B vs D, 품질**: 가변 상황에서 GraphDB vs RDB. §5-1/C-vs-D와 같은 채점 파이프라인을 + 한 번 더 돌려서 비교하면 됨 (같은 자연어 질문 세트, 같은 gold label). +- **B vs D, 속도**: A vs C 속도 벤치마크를 그대로 재사용할 수 없다 — LLM이 생성한 + 쿼리는 고정 템플릿과 다른 hop 수/필터/실행 계획을 가질 수 있어서(§5-2), "가변 + 쿼리 상태에서" 다시 latency를 재야 한다. `benchmark.py`와 같은 방식으로, 단 + 파라미터가 아니라 **LLM이 실제로 생성한 쿼리문 자체**를 양쪽 엔진에 실행해서 잰다. +- **C vs D, 속도** (완전성을 위한 선택 사항): RDB 안에서 고정 대비 가변 쿼리가 얼마나 + 느려지는지. A vs C의 hop-scaling 데이터로 어느 정도 짐작은 가능하지만(hop 수가 + 같으면 속도도 비슷할 것), LLM이 생성한 실제 SQL은 사람이 짠 `queries.py`보다 + 비효율적인 JOIN 순서/불필요한 서브쿼리를 쓸 수 있어서 별도 확인 가치가 있다. + +### 의사결정 프레임워크 + +4칸을 다 채운 뒤에는 아래 표처럼 **속도와 품질을 분리해서 채운 다음** 종합한다. +결론을 미리 "B가 최고"로 정해두지 않고, 실측된 6개 비교(A-C 속도/품질, +A-B 품질, C-D 품질, C-D 속도, B-D 속도/품질)를 그대로 모아서 판단한다: + +| 비교 | 축 | 결과(실측 후 채움) | +|---|---|---| +| A vs C | 속도 | hop-의존적 (1-hop: C 승 / 2-hop+: A 승) — 확정 | +| A vs C | 품질 | 델타 없음(A≈C) — 확정 | +| A vs B | 품질 | ? | +| C vs D | 품질 | ? | +| C vs D | 속도 | ? | +| B vs D | 품질 | ? | +| B vs D | 속도 | ? | + +예를 들어 (전부 가정) A vs B에서 가변이 품질을 크게 올리고, C vs D에서는 가변이 +품질을 별로 못 올리거나 오히려 떨어뜨리고, B vs D에서 품질·속도 둘 다 B가 +이긴다면 → "가변으로 갈 거면 GraphDB(B)가 맞다"는 결론이 이 표에서 자연스럽게 +나온다. 반대로 C vs D 품질이 A vs B 품질만큼 오른다면(즉 가변 전환 효과가 +엔진과 무관하다면) → 최종 선택은 B/D 사이의 순수 속도·비용 비교로 좁혀진다. +**핵심은 표를 다 채우기 전엔 결론을 정하지 않는 것** — 지금 A vs C 속도가 +"일괄적으로 어느 한쪽이 이긴다"가 아니라 hop-의존적이었던 것처럼, 나머지 칸도 +그렇게 나올 가능성을 열어둬야 한다. + +## 7. 리스크/주의점 - **모델 의존성**: 이 프로젝트가 실제로 쓰는 모델(`GPU_SERVER_URL`, Qwen3.5-9B)로 테스트해야 의미가 있다. 범용 대형 모델(GPT-4급 등)로 테스트하면 실제 프로덕션과 @@ -120,7 +187,7 @@ hop-scaling 벤치마크(`RESULTS.md`)로 "실행 성능"은 쟀지만, "쿼리 변수라 의도적으로 제외"했지만, 이 실험에서는 오히려 **LLM의 쿼리 생성 시간 자체가 핵심 측정 대상**이 된다 — 목적이 다르므로 방법론도 반대로 가져가야 함. -## 7. 진행 여부 +## 8. 진행 여부 계획 단계 문서. 실제 구현(질문 세트 작성, 생성/채점 파이프라인 스크립트 등)은 착수 전 확인 후 진행. From 0bc284f4db2da1bace001443695f036cfadf3cf9 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 07:12:08 +0000 Subject: [PATCH 07/11] =?UTF-8?q?docs(pg=5Fexperiment):=20A=20vs=20C=20?= =?UTF-8?q?=ED=92=88=EC=A7=88=20=EB=B9=84=EA=B5=90=EB=A5=BC=20=EB=A7=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=EC=8A=A4=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_parity.py/dump_full_results.py 재확인 결과 A vs C는 매칭 로직만 동일할 뿐 tie-break 비결정성 때문에 프로덕션 쿼리 3개 중 2개는 정확히 같은 결과가 아님 - "델타 없음"이라 단정했던 이전 서술을 정정. 다만 A는 이미 프로덕션 운영 중인 고정 기준선이라 A vs C 품질을 재도 앞으로의 선택(가변 전환 여부/엔진)에 영향이 없어 의사결정 프레임워크에서는 아예 빼기로 함 - 표를 6개에서 5개 비교로 축소. --- pg_experiment/docs/LLM_QUERY_GENERATION.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pg_experiment/docs/LLM_QUERY_GENERATION.md b/pg_experiment/docs/LLM_QUERY_GENERATION.md index 9d35923..b7ceb05 100644 --- a/pg_experiment/docs/LLM_QUERY_GENERATION.md +++ b/pg_experiment/docs/LLM_QUERY_GENERATION.md @@ -124,9 +124,14 @@ hop-scaling 벤치마크(`RESULTS.md`)로 "실행 성능"은 쟀지만, "쿼리 (`path_by_effects`, `products_by_concern`)부터 A(Neo4j)가 앞선다. 이 hop-의존성 자체가 이번 실험의 핵심 결론이라, 최종 판단에도 "A가 낫다"가 아니라 이 곡선을 그대로 반영해야 한다. -- **A vs C, 품질**: 별도 실험 불필요. `verify_parity.py`로 고정 Cypher와 고정 SQL이 - 동일 결과(또는 알려진 동점 구간만 다른 동일 분포)를 낸다는 걸 이미 확인했다 — - 즉 고정 쿼리끼리는 **품질 델타가 없다(A≈C)**. +- **A vs C, 품질**: **이 매트릭스에서는 재지 않는다.** `verify_parity.py`/ + `dump_full_results.py`로 다시 보니 매칭 *로직*은 두 엔진이 동일하게 구현됐지만 + (프로덕션 쿼리 3개 중 `query_products_by_ingredients`만 완전 동일, 나머지 2개는 + 프로덕션 Cypher 자체의 기존 tie-break 비결정성 때문에 정확히 같은 결과는 아님 + — 자세한 내용은 `RESULTS.md` §2), 이 축은 지금 프로덕션이 이미 GraphDB 고정 + 쿼리(A)로 운영 중이라 "A vs C 품질"을 재도 앞으로의 선택(가변으로 갈지, 간다면 + 어느 엔진으로 갈지)에 영향을 주지 않는다. 그래서 의사결정 프레임워크에서는 + 뺀다 — 아래 표에도 이 행은 없음. ### 새로 측정해야 할 것 @@ -151,13 +156,13 @@ hop-scaling 벤치마크(`RESULTS.md`)로 "실행 성능"은 쟀지만, "쿼리 ### 의사결정 프레임워크 4칸을 다 채운 뒤에는 아래 표처럼 **속도와 품질을 분리해서 채운 다음** 종합한다. -결론을 미리 "B가 최고"로 정해두지 않고, 실측된 6개 비교(A-C 속도/품질, -A-B 품질, C-D 품질, C-D 속도, B-D 속도/품질)를 그대로 모아서 판단한다: +결론을 미리 "B가 최고"로 정해두지 않고, 실측된 5개 비교(A-C 속도, A-B 품질, +C-D 품질, C-D 속도, B-D 속도/품질)를 그대로 모아서 판단한다. A-C 품질은 위에서 +설명한 이유로 이 프레임워크에서 재지 않는다. | 비교 | 축 | 결과(실측 후 채움) | |---|---|---| | A vs C | 속도 | hop-의존적 (1-hop: C 승 / 2-hop+: A 승) — 확정 | -| A vs C | 품질 | 델타 없음(A≈C) — 확정 | | A vs B | 품질 | ? | | C vs D | 품질 | ? | | C vs D | 속도 | ? | From 8c1c1faa968f37158cf010c7bd468ae1f415209d Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 07:16:09 +0000 Subject: [PATCH 08/11] =?UTF-8?q?docs(pg=5Fexperiment):=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=ED=94=84=EB=A0=88=EC=9E=84=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EB=A5=BC=20A=20vs=20B,=20B=20vs=20D=20=ED=92=88=EC=A7=88?= =?UTF-8?q?=EB=A7=8C=EC=9C=BC=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C(RDB 고정)가 관여하는 비교(A vs C 품질, C vs D 전부)는 프로덕션 실제 선택지에 영향을 주지 않아 제외. B vs D도 속도는 별도로 재지 않기로 함 - (1) hop 수만 알면 기존 A-C hop-scaling 곡선으로 속도 추정 가능, (2) 가변 쿼리 시나리오의 진짜 병목은 실행 시간이 아니라 LLM 생성 시간이라 실행 시간 델타는 실질적 의미가 작음. 최종적으로 A vs B(가변 전환 가치) -> B vs D(전환한다면 어느 엔진) 2단계 품질 비교로 정리. --- pg_experiment/docs/LLM_QUERY_GENERATION.md | 103 ++++++++------------- 1 file changed, 39 insertions(+), 64 deletions(-) diff --git a/pg_experiment/docs/LLM_QUERY_GENERATION.md b/pg_experiment/docs/LLM_QUERY_GENERATION.md index b7ceb05..22a5a8b 100644 --- a/pg_experiment/docs/LLM_QUERY_GENERATION.md +++ b/pg_experiment/docs/LLM_QUERY_GENERATION.md @@ -106,77 +106,52 @@ hop-scaling 벤치마크(`RESULTS.md`)로 "실행 성능"은 쟀지만, "쿼리 그리고 "hop이 늘어난 케이스가 품질(Precision/Recall/NDCG)에도 실제로 도움이 됐는지"를 함께 봐야 이 가설을 검증할 수 있다. -## 6. 전체 2×2 비교 매트릭스 및 의사결정 프레임워크 +## 6. 비교 프레임워크 -지금까지 나온 비교를 전부 모으면 "엔진(GraphDB/RDB) × 쿼리 방식(고정/가변)" 2×2가 -된다. 4칸을 다 채워야 "결국 뭘 써야 하나"에 완전한 답을 할 수 있다. +"엔진(GraphDB/RDB) × 쿼리 방식(고정/가변)" 2×2 중 지금 의사결정에 실제로 필요한 +칸만 남긴다. | | 고정 쿼리 | LLM 가변 쿼리 | |---|---|---| | **GraphDB** | **A** (지금 프로덕션) | **B** | | **RDB** | **C** (`pg_experiment`) | **D** | -### 이미 측정된 것 - -- **A vs C, 속도**: `RESULTS.md`에서 실측 완료. **주의**: "A가 일괄적으로 이겼다"가 - 아니라 **hop 수에 따라 갈렸다** — 1-hop 쿼리 2개(`products_by_ingredients`, - `ingredients_by_effects`)는 오히려 C(Postgres)가 3~6배 빠르고, 2-hop 이상 - (`path_by_effects`, `products_by_concern`)부터 A(Neo4j)가 앞선다. 이 hop-의존성 - 자체가 이번 실험의 핵심 결론이라, 최종 판단에도 "A가 낫다"가 아니라 이 곡선을 - 그대로 반영해야 한다. -- **A vs C, 품질**: **이 매트릭스에서는 재지 않는다.** `verify_parity.py`/ - `dump_full_results.py`로 다시 보니 매칭 *로직*은 두 엔진이 동일하게 구현됐지만 - (프로덕션 쿼리 3개 중 `query_products_by_ingredients`만 완전 동일, 나머지 2개는 - 프로덕션 Cypher 자체의 기존 tie-break 비결정성 때문에 정확히 같은 결과는 아님 - — 자세한 내용은 `RESULTS.md` §2), 이 축은 지금 프로덕션이 이미 GraphDB 고정 - 쿼리(A)로 운영 중이라 "A vs C 품질"을 재도 앞으로의 선택(가변으로 갈지, 간다면 - 어느 엔진으로 갈지)에 영향을 주지 않는다. 그래서 의사결정 프레임워크에서는 - 뺀다 — 아래 표에도 이 행은 없음. - -### 새로 측정해야 할 것 - -- **A vs B, 품질** (§5-1): GraphDB 안에서 고정 vs 가변. "자유도를 주면 GraphDB - 안에서 품질이 오르는가." -- **C vs D, 품질** (신규): RDB 안에서 고정 vs 가변. §5-1과 동일한 방법론 - (`eval/RESULTS.md`의 gold label 채점)을 SQL 생성 쪽에도 그대로 적용. "자유도를 - 주면 RDB 안에서도 품질이 오르는가, 아니면 SQL은 JOIN이 복잡해질수록 오히려 - 틀린 쿼리가 늘어 품질이 떨어지는가" — 이게 §5-2에서 이미 짚은 "hop 늘수록 SQL - 생성이 더 어려워질 것"이라는 가설과 직결된다. -- **B vs D, 품질**: 가변 상황에서 GraphDB vs RDB. §5-1/C-vs-D와 같은 채점 파이프라인을 - 한 번 더 돌려서 비교하면 됨 (같은 자연어 질문 세트, 같은 gold label). -- **B vs D, 속도**: A vs C 속도 벤치마크를 그대로 재사용할 수 없다 — LLM이 생성한 - 쿼리는 고정 템플릿과 다른 hop 수/필터/실행 계획을 가질 수 있어서(§5-2), "가변 - 쿼리 상태에서" 다시 latency를 재야 한다. `benchmark.py`와 같은 방식으로, 단 - 파라미터가 아니라 **LLM이 실제로 생성한 쿼리문 자체**를 양쪽 엔진에 실행해서 잰다. -- **C vs D, 속도** (완전성을 위한 선택 사항): RDB 안에서 고정 대비 가변 쿼리가 얼마나 - 느려지는지. A vs C의 hop-scaling 데이터로 어느 정도 짐작은 가능하지만(hop 수가 - 같으면 속도도 비슷할 것), LLM이 생성한 실제 SQL은 사람이 짠 `queries.py`보다 - 비효율적인 JOIN 순서/불필요한 서브쿼리를 쓸 수 있어서 별도 확인 가치가 있다. - -### 의사결정 프레임워크 - -4칸을 다 채운 뒤에는 아래 표처럼 **속도와 품질을 분리해서 채운 다음** 종합한다. -결론을 미리 "B가 최고"로 정해두지 않고, 실측된 5개 비교(A-C 속도, A-B 품질, -C-D 품질, C-D 속도, B-D 속도/품질)를 그대로 모아서 판단한다. A-C 품질은 위에서 -설명한 이유로 이 프레임워크에서 재지 않는다. - -| 비교 | 축 | 결과(실측 후 채움) | -|---|---|---| -| A vs C | 속도 | hop-의존적 (1-hop: C 승 / 2-hop+: A 승) — 확정 | -| A vs B | 품질 | ? | -| C vs D | 품질 | ? | -| C vs D | 속도 | ? | -| B vs D | 품질 | ? | -| B vs D | 속도 | ? | - -예를 들어 (전부 가정) A vs B에서 가변이 품질을 크게 올리고, C vs D에서는 가변이 -품질을 별로 못 올리거나 오히려 떨어뜨리고, B vs D에서 품질·속도 둘 다 B가 -이긴다면 → "가변으로 갈 거면 GraphDB(B)가 맞다"는 결론이 이 표에서 자연스럽게 -나온다. 반대로 C vs D 품질이 A vs B 품질만큼 오른다면(즉 가변 전환 효과가 -엔진과 무관하다면) → 최종 선택은 B/D 사이의 순수 속도·비용 비교로 좁혀진다. -**핵심은 표를 다 채우기 전엔 결론을 정하지 않는 것** — 지금 A vs C 속도가 -"일괄적으로 어느 한쪽이 이긴다"가 아니라 hop-의존적이었던 것처럼, 나머지 칸도 -그렇게 나올 가능성을 열어둬야 한다. +- **C(RDB 고정)와 C 관련 비교는 다루지 않는다.** C는 프로덕션에 없는 벤치마크 + 전용 구성이라, "C vs D"(RDB 안에서 고정 vs 가변)를 재도 실제 선택지("가변으로 + 갈지", "간다면 어느 엔진") 어느 쪽에도 직접 영향을 주지 않는다. C가 관여하는 + 비교는 전부 프레임워크에서 뺀다. +- **A vs C 품질도 다루지 않는다** (이유는 위와 같음 — A가 이미 운영 중인 고정 + 기준선이라 재도 선택에 영향 없음). + +남는 건 두 가지뿐이다. + +### A vs B, 품질 (§5-1) + +GraphDB 안에서 고정 vs 가변. "자유도를 주면 지금 쓰고 있는 엔진 안에서 품질이 +오르는가" — 이것만으로도 "가변으로 바꿀 이유가 있는가"에 답할 수 있다. + +### B vs D, 품질만 본다 (속도는 안 잰다) + +가변 상황에서 GraphDB vs RDB. **속도는 별도로 재지 않기로 함**, 이유: + +1. hop 수만 알면 기존 A-C hop-scaling 곡선(`RESULTS.md`)으로 속도를 추정할 수 + 있다 — LLM이 생성한 쿼리도 결국 같은 스키마·같은 관계를 도는 거라, §5-2에서 + 어차피 기록하는 "생성 쿼리의 hop 수"만 있으면 새 벤치마크 없이 대략적인 속도 + 감을 잡을 수 있다. +2. 가변 쿼리 시나리오에서 실제로 지배적인 비용은 실행 시간이 아니라 **LLM이 + 쿼리를 생성하는 시간**이다(호출 1회, 재시도 포함 시 그 이상 — 수백 ms~수 초). + 실행 시간 몇 ms~몇십 ms 차이를 따로 재는 건, 이미 훨씬 큰 생성 시간에 묻혀서 + 실질적 의미가 작다. + +### 종합 판단 + +A vs B와 B vs D 둘 다 품질 축이므로, 결론은 단순하게 정리된다: +- A vs B에서 가변이 품질을 뚜렷이 올리지 못하면 → 지금 고정 템플릿(A) 유지가 + 맞다. 가변으로 바꿀 이유가 애초에 없음. +- A vs B에서 가변이 품질을 올린다면 → B vs D로 넘어가 "가변으로 갈 때 어느 + 엔진이 나은가"를 본다. B가 이기면 지금 엔진(GraphDB) 유지 + 가변 전환, + D가 이기면 엔진 전환까지 고려 대상이 된다(단 이 경우 hop 분포를 봐서 + §5-2의 hop-scaling 곡선상 실행 비용이 감당 가능한 수준인지 같이 확인). ## 7. 리스크/주의점 From 155a5e4419306fb10e20260b82da1d9f1bda2999 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 07:17:53 +0000 Subject: [PATCH 09/11] =?UTF-8?q?docs(pg=5Fexperiment):=20=EA=B0=80?= =?UTF-8?q?=EB=B3=80=20=EC=BF=BC=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DYNAMIC_QUERY_IMPLEMENTATION.md: LLM_QUERY_GENERATION.md의 "왜/무엇을 잴지"에 이어 "어떻게 코드로 짤지"를 정리. 기존 4EVR0-Server 패턴 (llm_client.py의 get_async_llm_client, app/prompts/의 load_prompt, neo4j_client.py의 _log_query/예외 처리)을 그대로 재사용하는 방향으로 설계 - 새 파일(dynamic_query_client.py, dynamic_recommend_service.py, 프롬프트 2개)을 기존 고정 템플릿 경로와 분리해서 추가하고 settings.query_mode 플래그로 라우팅. 읽기 전용 DB 계정 분리, LIMIT 강제, EXPLAIN 검증, 재시도 루프, 생성 쿼리 텍스트 로깅, 오프라인 평가 -> 섀도 모드 -> 점진적 트래픽 전환 3단계 롤아웃까지 포함. --- .../docs/DYNAMIC_QUERY_IMPLEMENTATION.md | 146 ++++++++++++++++++ pg_experiment/docs/EXPERIMENT.md | 3 +- 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 pg_experiment/docs/DYNAMIC_QUERY_IMPLEMENTATION.md diff --git a/pg_experiment/docs/DYNAMIC_QUERY_IMPLEMENTATION.md b/pg_experiment/docs/DYNAMIC_QUERY_IMPLEMENTATION.md new file mode 100644 index 0000000..9ce272e --- /dev/null +++ b/pg_experiment/docs/DYNAMIC_QUERY_IMPLEMENTATION.md @@ -0,0 +1,146 @@ +# 가변 쿼리 생성 방식 — 구현 계획 + +`LLM_QUERY_GENERATION.md`가 "왜/무엇을 재야 하는가"였다면, 이 문서는 **실제로 +코드를 어떻게 짤 것인가**다. 기존 4EVR0-Server 코드베이스의 패턴(`llm_client.py`, +`app/prompts/`, `neo4j_client.py`)을 그대로 따라간다. 아직 구현 전, 설계 단계. + +## 1. 어디에 놓을 것인가 + +기존 고정 템플릿 경로(`app/clients/neo4j_client.py`, `recommend_service.py`)는 +건드리지 않는다. 새 파일을 나란히 추가해서 기존 경로와 완전히 분리한다. + +``` +app/ +├── clients/ +│ ├── neo4j_client.py # 기존, 안 건드림 +│ └── dynamic_query_client.py # 신규 — LLM에게 쿼리 생성시키고 실행 +├── services/ +│ ├── recommend_service.py # 기존, 안 건드림 +│ └── dynamic_recommend_service.py # 신규 — dynamic_query_client 사용 +└── prompts/ + ├── cypher_generation.txt # 신규 + └── sql_generation.txt # 신규 +``` + +`app/core/config.py`에 `settings.query_mode: Literal["fixed", "dynamic"] = "fixed"` +플래그를 추가해서, `recommend_service.py`가 이 값에 따라 두 경로 중 하나를 +호출하도록 라우팅한다. 기본값은 `"fixed"`라 아무것도 안 건드리면 지금과 100% +동일하게 동작한다. + +## 2. 프롬프트 설계 — 기존 `app/prompts/` 관례 그대로 + +`profile_extraction.txt`처럼 프롬프트를 코드에 하드코딩하지 않고 별도 파일로 +분리, `load_prompt()`/`prompt_version()`(sha1 해시로 버전 추적, MLflow 연동)을 +그대로 재사용한다. + +`app/prompts/cypher_generation.txt` 내용 구성: +1. 그래프 스키마 설명 (`README.md`의 그래프 구조 + 각 노드/관계 속성 목록) +2. 읽기 전용 제약 명시 ("MATCH만 쓰고 CREATE/MERGE/DELETE/SET은 절대 쓰지 마라") +3. 출력 형식 강제: `{"cypher": "...", "params": {...}}` 형태의 JSON만 출력 + (기존 `call_llm()`이 `response_format={"type": "json_object"}`를 쓰는 것과 동일 패턴) +4. few-shot 예시 2~3개 (짧은 질문 → 정답 Cypher) + +`app/prompts/sql_generation.txt`도 구조는 동일, 스키마는 `pg_experiment/schema.sql` +DDL을 그대로 넣는다. + +## 3. 쿼리 생성 클라이언트 + +`app/clients/dynamic_query_client.py`. `llm_client.py`의 `call_llm()`과 같은 +패턴(`get_async_llm_client()`, `settings.gpu_model`, `temperature=0`)을 쓴다. + +```python +async def generate_query(question: str, engine: Literal["cypher", "sql"]) -> GeneratedQuery: + prompt_name = "cypher_generation" if engine == "cypher" else "sql_generation" + system_prompt = load_prompt(prompt_name) + client = get_async_llm_client() + + response = await client.chat.completions.create( + model=settings.gpu_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": question}, + ], + temperature=0, + response_format={"type": "json_object"}, + ) + data = json.loads(response.choices[0].message.content or "{}") + return GeneratedQuery(query=data["query"], params=data.get("params", {})) +``` + +## 4. 실행 전 가드레일 + +기존 `neo4j_client.py`가 문자열 f-string으로 파라미터 없이 쿼리를 실행하는 +것과 달리(쿼리 자체가 고정 템플릿이라 안전했음), 여기서는 **쿼리 텍스트 자체가 +LLM 출력**이라 방어 계층이 필수다. + +1. **DB 계정 자체를 읽기 전용으로 분리** (가장 중요, 애플리케이션 레벨 필터보다 + 우선): + - Neo4j: `CREATE ROLE dynamic_query_reader; GRANT MATCH {*} ON GRAPH * NODES *, RELATIONSHIPS * TO dynamic_query_reader;` (쓰기 권한 부여 자체를 안 함) + - Postgres: `CREATE ROLE dynamic_query_reader NOLOGIN; GRANT SELECT ON ALL TABLES IN SCHEMA public TO dynamic_query_reader;` + - 고정 템플릿 경로(`neo4j_client.py`, 기존 Postgres 연결)는 지금 쓰는 계정 + 그대로 두고, `dynamic_query_client.py`만 이 읽기 전용 계정으로 연결한다 — + 별도 `settings.neo4j_readonly_uri` / `settings.postgres_readonly_dsn` 추가. +2. **LIMIT 강제**: 생성된 쿼리 문자열에 `LIMIT`이 없으면 서버에서 `LIMIT 20`을 + 덧붙인다 (정규식으로 끝에 `LIMIT \d+`가 있는지 확인 후 없으면 추가). +3. **실행 전 `EXPLAIN`**: Cypher는 `EXPLAIN `, SQL은 `EXPLAIN `를 + 먼저 실행해서 문법 오류를 여기서 걸러낸다. 통과 못 하면 바로 5번 재시도로. +4. **타임아웃**: Neo4j는 세션에 `session.run(query, timeout=5)`, Postgres는 + `SET statement_timeout = '5s'`를 커넥션 시작 시 설정. + +## 5. 재시도(self-correction) 루프 + +```python +async def execute_with_retry(question: str, engine: str, max_retries: int = 2): + error_context = "" + for attempt in range(max_retries + 1): + generated = await generate_query(question + error_context, engine) + try: + validate_via_explain(generated) # 4-3 + rows = await execute(generated) # 4-1 read-only 계정, 4-2 LIMIT, 4-4 timeout + return rows, generated, attempt + except (SyntaxError, ExecutionError) as e: + error_context = f"\n\n[이전 시도 실패: {e}] 이 오류를 피해서 다시 생성하세요." + return [], None, max_retries # 전부 실패 시 빈 결과 (기존 neo4j_client.py의 except 패턴과 동일) +``` + +기존 `neo4j_client.py`의 모든 함수가 `except Exception: return []`로 실패를 +조용히 삼키는 것과 같은 원칙 — 이 경로도 실패 시 추천 자체를 막지 않고 빈 +결과로 폴백한다. + +## 6. 로깅 — 생성된 쿼리 텍스트 자체를 남겨야 함 + +기존 `_log_query()`(`neo4j_client.py`)는 `func_name`/`params`/`duration_ms`/ +`result_count`만 남긴다. 고정 템플릿이라 쿼리 텍스트 자체는 코드만 봐도 알 수 +있어서 안 남겨도 됐다. 가변 쿼리는 **매번 다른 텍스트가 생성**되므로, 디버깅/ +사후 분석을 위해 생성된 쿼리 원문과 재시도 횟수를 반드시 로그에 남겨야 한다: + +```python +logger.info( + "event=dynamic_query func=%s engine=%s attempt=%d duration_ms=%.2f " + "result_count=%d generated_query=%s", + "generate_query", engine, attempt, duration_ms, len(rows), generated.query, +) +``` + +## 7. 단계적 롤아웃 + +1. **오프라인 평가만** (`LLM_QUERY_GENERATION.md` §4/§5): 이 단계에서는 프로덕션 + 코드를 전혀 건드리지 않고 `pg_experiment`에서 질문 세트 → 생성 → 채점만 + 돌린다. 여기서 A vs B, B vs D 품질 비교(§6)가 나온다. +2. **섀도 모드**: 1번 결과가 가변으로 갈 가치가 있다고 나오면, `dynamic_recommend_service.py`를 + 추가하되 **응답에는 안 쓰고 로그만 남기는 모드**로 실제 트래픽에 붙인다 + (`generate_traffic.sh`로 흘려보내며 검증 가능). 실사용자 질문 분포에서도 + 오프라인 평가와 비슷한 품질/hop 분포가 나오는지 확인. +3. **점진적 트래픽 전환**: 2번이 안정적이면 `settings.query_mode`를 트래픽의 + 일부(예: 5% → 20% → 50%)에만 `"dynamic"`으로 켜서 실제 사용자 반응까지 + 확인한 뒤 전체 전환 여부를 결정. + +프로덕션에 한 번에 붙이지 않는 이유는 `LLM_QUERY_GENERATION.md` §7에 이미 정리한 +리스크(모델 의존성, 안정성, 레이턴시 트레이드오프) 때문 — 이 계획은 그 리스크를 +단계적으로 확인하면서 진행하기 위한 것이다. + +## 8. 진행 여부 + +설계 단계 문서. 실제 코드(프롬프트 파일, `dynamic_query_client.py` 등) 작성은 +`LLM_QUERY_GENERATION.md`의 오프라인 평가(1단계)에서 유의미한 품질 개선이 +확인된 뒤 착수. \ No newline at end of file diff --git a/pg_experiment/docs/EXPERIMENT.md b/pg_experiment/docs/EXPERIMENT.md index ae95ea9..13fd83c 100644 --- a/pg_experiment/docs/EXPERIMENT.md +++ b/pg_experiment/docs/EXPERIMENT.md @@ -94,5 +94,6 @@ pg_experiment/ ├── RESULTS.md # 벤치마크 결과 + 결론 ├── PROCESS.md # 시간순 진행 과정 서술 ├── LLM_QUERY_GENERATION.md # 후속 과제: LLM 가변 쿼리 생성 실행 계획 - └── DISCUSSION_quality_vs_hop.md # 관련 논의 원문 + ├── DISCUSSION_quality_vs_hop.md # 관련 논의 원문 + └── DYNAMIC_QUERY_IMPLEMENTATION.md # 가변 쿼리 생성 구현 설계 ``` From 9eff3cc6e7a078210afeb23355ff2fea0ca30cb8 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 07:26:19 +0000 Subject: [PATCH 10/11] =?UTF-8?q?docs(pg=5Fexperiment):=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=B0=A9=EC=88=98=20=EC=8B=9C=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eval/gold_labels.py(concern->effect 정답 매핑)와 eval/graphrag_ranking_eval.py(Precision@K/NDCG@K 채점)가 이미 있어서 새로 안 만들어도 됨을 명시. A vs B를 먼저 실행하고(Cypher 생성 파이프라인만 필요), 결과가 긍정적일 때만 SQL 생성 파이프라인을 추가해 B vs D로 넘어가는 게이트형 실행 순서를 정리 - D 관련 작업을 조건부로 미뤄 불필요한 리소스 낭비를 막음. --- pg_experiment/docs/LLM_QUERY_GENERATION.md | 49 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/pg_experiment/docs/LLM_QUERY_GENERATION.md b/pg_experiment/docs/LLM_QUERY_GENERATION.md index 22a5a8b..bf292f5 100644 --- a/pg_experiment/docs/LLM_QUERY_GENERATION.md +++ b/pg_experiment/docs/LLM_QUERY_GENERATION.md @@ -153,7 +153,52 @@ A vs B와 B vs D 둘 다 품질 축이므로, 결론은 단순하게 정리된 D가 이기면 엔진 전환까지 고려 대상이 된다(단 이 경우 hop 분포를 봐서 §5-2의 hop-scaling 곡선상 실행 비용이 감당 가능한 수준인지 같이 확인). -## 7. 리스크/주의점 +## 7. 실제 착수 시 실행 순서 + +§4/§5/§6을 실제로 실행하려면 뭘 새로 만들고 뭘 그대로 재사용할지 정리. + +### 이미 있어서 그대로 쓰면 되는 것 + +- `eval/gold_labels.py`: 26개 concern → effect 정답 매핑 + (`PRODUCTION_CONCERN_EFFECT_MAP`), 그래프에서 gold 성분/제품을 뽑는 함수까지 + 이미 있음. +- `eval/graphrag_ranking_eval.py`: Precision@K, NDCG@K 채점 로직 이미 있음 — + `eval/RESULTS.md`에 쓰인 방법론 그대로. "품질을 어떻게 채점할지"는 새로 만들 + 필요 없다. + +### 공통으로 한 번만 만들면 되는 것 + +- **자연어 질문 세트**: concern 26개 중 hop별로 대표 질문을 뽑아서 기존 gold + label과 바로 매칭되게 구성 (`gold_labels.py`의 concern 코드를 그대로 키로 씀). + +### A vs B에 필요한 것 + +- A(고정)는 이미 있음 — `neo4j_client.py`/`pg_experiment/queries.py`의 `CYPHER_*` + 그대로. +- B(가변)는 신규 — `DYNAMIC_QUERY_IMPLEMENTATION.md` §2/§3의 + `cypher_generation.txt` 프롬프트 + `generate_query()` 함수. **오프라인 평가 + 단계라 §4의 read-only 계정/LIMIT 강제까지는 필요 없고, EXPLAIN 검증 정도만 + 있으면 됨** (운영 DB에 붙는 게 아니므로 가드레일을 최소화해도 안전). +- A 결과·B 결과를 각각 `graphrag_ranking_eval.py` 채점 로직에 넣어서 비교. + +### B vs D에 필요한 것 + +- B는 위에서 이미 확보. +- D(가변, RDB)는 신규 — `sql_generation.txt` 프롬프트 + Postgres용 생성/실행 + 경로 (`pg_experiment`의 Postgres 그대로 재사용). +- B·D 결과를 같은 gold label로 채점해서 비교. + +### 실행 순서 + +**A vs B부터 먼저 한다.** Cypher 생성 파이프라인만 있으면 되고 RDB 쪽은 아직 +안 건드려도 된다. + +- A vs B에서 가변이 품질을 뚜렷이 올리지 **못하면** → 여기서 멈춘다. D 쪽 + (SQL 생성 파이프라인)은 아예 만들 필요가 없다 — 리소스 낭비 방지. +- A vs B에서 가변이 품질을 **올린다면** → 그때만 SQL 생성 파이프라인을 추가로 + 만들어서 B vs D로 넘어간다. + +## 8. 리스크/주의점 - **모델 의존성**: 이 프로젝트가 실제로 쓰는 모델(`GPU_SERVER_URL`, Qwen3.5-9B)로 테스트해야 의미가 있다. 범용 대형 모델(GPT-4급 등)로 테스트하면 실제 프로덕션과 @@ -167,7 +212,7 @@ A vs B와 B vs D 둘 다 품질 축이므로, 결론은 단순하게 정리된 변수라 의도적으로 제외"했지만, 이 실험에서는 오히려 **LLM의 쿼리 생성 시간 자체가 핵심 측정 대상**이 된다 — 목적이 다르므로 방법론도 반대로 가져가야 함. -## 8. 진행 여부 +## 9. 진행 여부 계획 단계 문서. 실제 구현(질문 세트 작성, 생성/채점 파이프라인 스크립트 등)은 착수 전 확인 후 진행. From 9615daa51adcf3e7085ee146f5b1594b9d928940 Mon Sep 17 00:00:00 2001 From: withya16 Date: Thu, 16 Jul 2026 07:29:07 +0000 Subject: [PATCH 11/11] =?UTF-8?q?docs(pg=5Fexperiment):=20A=20vs=20B=20/?= =?UTF-8?q?=20B=20vs=20D=20=EC=8B=A4=ED=96=89=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=EA=B3=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=EB=90=9C=20=EB=B3=84=EB=8F=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EB=A1=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AB_BD_EXECUTION_PLAN.md: 프로덕션 코드/가드레일/롤아웃(그건 DYNAMIC_QUERY_IMPLEMENTATION.md)과 무관하게, 오프라인 실험을 실제로 어떻게 돌릴지만 다룸 - 재사용할 기존 인프라(eval/gold_labels.py, eval/graphrag_ranking_eval.py), 신규 스크립트 목록/위치 (pg_experiment/llm_eval/questions.py, generate.py, run_ab.py, run_bd.py), 게이트형 실행 순서. LLM_QUERY_GENERATION.md §7은 새 문서로의 포인터로 축소해 중복 제거. --- pg_experiment/docs/AB_BD_EXECUTION_PLAN.md | 108 +++++++++++++++++++++ pg_experiment/docs/EXPERIMENT.md | 3 +- pg_experiment/docs/LLM_QUERY_GENERATION.md | 48 ++------- 3 files changed, 116 insertions(+), 43 deletions(-) create mode 100644 pg_experiment/docs/AB_BD_EXECUTION_PLAN.md diff --git a/pg_experiment/docs/AB_BD_EXECUTION_PLAN.md b/pg_experiment/docs/AB_BD_EXECUTION_PLAN.md new file mode 100644 index 0000000..7f938f6 --- /dev/null +++ b/pg_experiment/docs/AB_BD_EXECUTION_PLAN.md @@ -0,0 +1,108 @@ +# A vs B, B vs D 실행 계획 + +`LLM_QUERY_GENERATION.md`에서 설계한 두 비교(§6)를 **실제로 어떻게 돌릴지**만 +다루는 문서. 프로덕션 코드/배포/가드레일은 다루지 않는다(그건 +`DYNAMIC_QUERY_IMPLEMENTATION.md`) — 여기는 순수하게 오프라인 실험 스크립트를 +어떤 순서로, 뭘 재사용해서, 어디에 만들지에 대한 계획이다. + +| | 고정 쿼리 | LLM 가변 쿼리 | +|---|---|---| +| **GraphDB** | **A** (기존 쿼리) | **B** (신규) | +| **RDB** | ~~C~~ (안 씀) | **D** (신규, 조건부) | + +## 1. 이미 있어서 그대로 재사용하는 것 + +- **gold label**: `eval/gold_labels.py`의 `PRODUCTION_CONCERN_EFFECT_MAP` + (26개 concern → effect 정답 매핑) + 그래프에서 gold 성분/제품을 뽑는 함수. +- **채점 로직**: `eval/graphrag_ranking_eval.py`의 Precision@K / NDCG@K 계산. + `eval/RESULTS.md`에 쓰인 방법론 그대로 재사용. +- **A(고정 쿼리) 실행**: `pg_experiment/queries.py`의 `CYPHER_*` 상수를 그대로 + Neo4j에 실행하면 됨 — 새로 만들 게 없음. +- **DB/데이터**: `pg_experiment`에 이미 떠 있는 Neo4j(기존 컨테이너)와 + Postgres(`pg_experiment/docker-compose.yml`, 5433) 그대로 사용. + +## 2. 새로 만들어야 하는 것 + +전부 `pg_experiment/llm_eval/` 아래 새 폴더를 만들어서 넣는다(오프라인 실험용 +스크립트라 프로덕션 `app/` 디렉터리와 분리). + +``` +pg_experiment/llm_eval/ +├── questions.py # 자연어 질문 세트 (concern별, gold_labels.py와 매칭) +├── prompts/ +│ ├── cypher_generation.txt +│ └── sql_generation.txt # B vs D 단계에서만 필요 +├── generate.py # LLM 호출 -> Cypher/SQL 생성 (+ EXPLAIN 검증만) +├── run_ab.py # A vs B 실행 + 채점 +└── run_bd.py # B vs D 실행 + 채점 (조건부) +``` + +### 2-1. `questions.py` — 질문 세트 + +`gold_labels.py`의 concern 코드 26개를 키로 써서, 각 concern당 자연어 질문 +1~2개를 매핑한다. + +```python +QUESTIONS = { + "ACNE": ["여드름 때문에 고민이에요, 어떤 성분이 좋을까요?"], + "DRY_SKIN": ["피부가 너무 건조해요", ...], + ... # 26개 전부 +} +``` + +### 2-2. `prompts/cypher_generation.txt` + +- 그래프 스키마 설명 (`README.md`의 구조 그대로) +- 출력 형식: `{"cypher": "...", "params": {...}}` JSON만 +- few-shot 예시 2~3개 +- **읽기 전용 안내만** (`MATCH만 써라`) — 실제 DB 권한 제한은 안 함, 실험용 + 스크립트가 직접 실행 전 `EXPLAIN`으로 한 번 더 거르기 때문에 이 단계에서는 + 텍스트 안내만으로 충분함. + +### 2-3. `generate.py` + +```python +async def generate_cypher(question: str) -> dict: + ... # llm_client.py의 call_llm()과 같은 패턴, get_async_llm_client() 재사용 + return {"cypher": ..., "params": ...} + +def validate(cypher: str, driver) -> bool: + with driver.session() as s: + s.run(f"EXPLAIN {cypher}") # 문법만 확인, 결과는 안 씀 + return True # 실패하면 예외 +``` + +재시도는 최소 구현으로 — 문법 오류 시 1회만 재생성 (프로덕션처럼 여러 겹 +가드레일 필요 없음, 실험 스크립트가 로컬에서 도는 것뿐). + +### 2-4. `run_ab.py` + +1. `questions.py`의 26개 concern 질문을 순회 +2. 각 질문마다: + - A: `pg_experiment/queries.py`의 `CYPHER_INGREDIENTS_BY_EFFECTS` 등을 + 해당 concern의 gold effect로 실행 + - B: `generate.py`로 Cypher 생성 → 검증 → 실행 +3. A 결과, B 결과 각각 `graphrag_ranking_eval.py`의 Precision@K/NDCG@K로 채점 +4. concern별 표 + 평균 요약 출력 (`RESULTS.md`와 비슷한 포맷) + +### 2-5. `run_bd.py` (조건부) + +`run_ab.py` 결과에서 B가 A보다 품질이 뚜렷이 높을 때만 착수. + +1. `prompts/sql_generation.txt` 작성 (스키마는 `pg_experiment/schema.sql` DDL) +2. `generate.py`에 `generate_sql()` 추가, Postgres에 `EXPLAIN`으로 검증 +3. B(이미 있음)와 D(신규 생성) 결과를 같은 gold label로 채점해 비교 + +## 3. 실행 순서 (요약) + +1. `questions.py` 작성 +2. `prompts/cypher_generation.txt` + `generate.py`(Cypher 부분) 작성 +3. `run_ab.py` 실행 → A vs B 결과 확인 +4. **여기서 판단**: B가 A보다 품질이 안 오르면 종료, 보고서만 작성 +5. B가 A보다 품질이 오르면 → `prompts/sql_generation.txt` + `generate.py`(SQL + 부분) 추가 작성 → `run_bd.py` 실행 → B vs D 결과 확인 +6. 두 결과 종합해서 `pg_experiment/docs/RESULTS_llm_query.md`(가칭)로 정리 + +## 4. 진행 여부 + +계획 단계. 실제 스크립트 작성은 착수 확인 후 진행. diff --git a/pg_experiment/docs/EXPERIMENT.md b/pg_experiment/docs/EXPERIMENT.md index 13fd83c..7de9754 100644 --- a/pg_experiment/docs/EXPERIMENT.md +++ b/pg_experiment/docs/EXPERIMENT.md @@ -95,5 +95,6 @@ pg_experiment/ ├── PROCESS.md # 시간순 진행 과정 서술 ├── LLM_QUERY_GENERATION.md # 후속 과제: LLM 가변 쿼리 생성 실행 계획 ├── DISCUSSION_quality_vs_hop.md # 관련 논의 원문 - └── DYNAMIC_QUERY_IMPLEMENTATION.md # 가변 쿼리 생성 구현 설계 + ├── AB_BD_EXECUTION_PLAN.md # A vs B, B vs D 실험을 실제로 어떻게 돌릴지 + └── DYNAMIC_QUERY_IMPLEMENTATION.md # (실험 이후) 프로덕션 구현 설계 ``` diff --git a/pg_experiment/docs/LLM_QUERY_GENERATION.md b/pg_experiment/docs/LLM_QUERY_GENERATION.md index bf292f5..4adb75d 100644 --- a/pg_experiment/docs/LLM_QUERY_GENERATION.md +++ b/pg_experiment/docs/LLM_QUERY_GENERATION.md @@ -155,48 +155,12 @@ A vs B와 B vs D 둘 다 품질 축이므로, 결론은 단순하게 정리된 ## 7. 실제 착수 시 실행 순서 -§4/§5/§6을 실제로 실행하려면 뭘 새로 만들고 뭘 그대로 재사용할지 정리. - -### 이미 있어서 그대로 쓰면 되는 것 - -- `eval/gold_labels.py`: 26개 concern → effect 정답 매핑 - (`PRODUCTION_CONCERN_EFFECT_MAP`), 그래프에서 gold 성분/제품을 뽑는 함수까지 - 이미 있음. -- `eval/graphrag_ranking_eval.py`: Precision@K, NDCG@K 채점 로직 이미 있음 — - `eval/RESULTS.md`에 쓰인 방법론 그대로. "품질을 어떻게 채점할지"는 새로 만들 - 필요 없다. - -### 공통으로 한 번만 만들면 되는 것 - -- **자연어 질문 세트**: concern 26개 중 hop별로 대표 질문을 뽑아서 기존 gold - label과 바로 매칭되게 구성 (`gold_labels.py`의 concern 코드를 그대로 키로 씀). - -### A vs B에 필요한 것 - -- A(고정)는 이미 있음 — `neo4j_client.py`/`pg_experiment/queries.py`의 `CYPHER_*` - 그대로. -- B(가변)는 신규 — `DYNAMIC_QUERY_IMPLEMENTATION.md` §2/§3의 - `cypher_generation.txt` 프롬프트 + `generate_query()` 함수. **오프라인 평가 - 단계라 §4의 read-only 계정/LIMIT 강제까지는 필요 없고, EXPLAIN 검증 정도만 - 있으면 됨** (운영 DB에 붙는 게 아니므로 가드레일을 최소화해도 안전). -- A 결과·B 결과를 각각 `graphrag_ranking_eval.py` 채점 로직에 넣어서 비교. - -### B vs D에 필요한 것 - -- B는 위에서 이미 확보. -- D(가변, RDB)는 신규 — `sql_generation.txt` 프롬프트 + Postgres용 생성/실행 - 경로 (`pg_experiment`의 Postgres 그대로 재사용). -- B·D 결과를 같은 gold label로 채점해서 비교. - -### 실행 순서 - -**A vs B부터 먼저 한다.** Cypher 생성 파이프라인만 있으면 되고 RDB 쪽은 아직 -안 건드려도 된다. - -- A vs B에서 가변이 품질을 뚜렷이 올리지 **못하면** → 여기서 멈춘다. D 쪽 - (SQL 생성 파이프라인)은 아예 만들 필요가 없다 — 리소스 낭비 방지. -- A vs B에서 가변이 품질을 **올린다면** → 그때만 SQL 생성 파이프라인을 추가로 - 만들어서 B vs D로 넘어간다. +프로덕션 코드/가드레일과 무관하게, A vs B / B vs D를 **어떻게 실행할지**에 +대한 구체적 계획은 별도 문서로 분리했다 — 재사용할 기존 인프라 +(`eval/gold_labels.py`, `eval/graphrag_ranking_eval.py`), 새로 만들 스크립트 +목록과 위치(`pg_experiment/llm_eval/`), 단계별 실행 순서(A vs B 먼저 → 결과 +좋을 때만 B vs D)까지 전부 [`AB_BD_EXECUTION_PLAN.md`](./AB_BD_EXECUTION_PLAN.md) +참고. ## 8. 리스크/주의점