diff --git a/.gitignore b/.gitignore index d369c55..318da79 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ .env *.tsbuildinfo .DS_Store + +# 배포 덤프 — 13MB, 재생성 가능 (npm run db:dump) +data/*.sql.gz diff --git a/README.md b/README.md index 95a890f..2853fe9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,46 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small "고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고, 실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다. +## 배포 + +DB 가 기준이다. 데이터셋 JSON 은 생성 원본일 뿐 적재분과 완전히 같지 않다 +(지역 간 중복 태그가 한 행으로 합쳐지므로 7,129 → 6,243). + +| 산출물 | 명령 | 용도 | +|---|---|---| +| `data/배포용_키워드_DB덤프.xlsx` | `npm run db:export-xlsx` | 사람이 읽고 검수하는 용도. 6시트 | +| `data/ontology-dump.sql.gz` | `npm run db:dump` | **임베딩 포함 그대로 복원**. 13MB, git 제외 | + +### A. pg_dump 복원 (권장) + +```bash +npm run db:dump +gunzip -c data/ontology-dump.sql.gz | psql "$TARGET_DATABASE_URL" +``` + +대상 DB 에 `vector` · `ltree` · `pg_trgm` 확장이 있어야 한다. 재임베딩이 없어 즉시 뜬다. +복원 검증 완료 — 6개 테이블 행수 일치, 임베딩 7,093/7,093 보존, HNSW 인덱스 재생성, 벡터 검색 동작. + +### B. 재적재 + +```bash +npm run db:migrate && npm run db:seed +npm run dataset:ingest && npm run dataset:ingest-nationwide +``` + +텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초. +같은 모델이면 값이 동일하게 나오므로 A 와 결과가 같다. + +### 현재 적재 내용 + +| 출처 | 건수 | 내용 | +|---|---|---| +| `nationwide` | 6,243 | 전국 54개 지역 | +| `dataset` | 850 | 군산 상세 (매칭 엔진 개발용) | +| **합계** | **7,093** | 전부 임베딩 보유 | + +⚠ 검색량은 아직 비어 있다. 실서비스 전에 키워드도구로 채우고 월 10 미만을 걷어내야 한다. + ## 전국 지역별 데이터셋 (기획 변경분) `data/전국_펜션_SEO_AEO_키워드.xlsx` — 전국 54개 펜션 수요 지역 × **7,138건**. diff --git a/data/배포용_키워드_DB덤프.xlsx b/data/배포용_키워드_DB덤프.xlsx new file mode 100644 index 0000000..0d51a7e Binary files /dev/null and b/data/배포용_키워드_DB덤프.xlsx differ diff --git a/package.json b/package.json index 7e28996..382ca4c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "dataset:purge": "tsx scripts/purge-nondataset.ts", "dataset:import-related": "tsx scripts/import-related.ts", "dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py", - "dataset:ingest-nationwide": "tsx scripts/ingest-nationwide.ts" + "dataset:ingest-nationwide": "tsx scripts/ingest-nationwide.ts", + "db:dump": "bash scripts/db-dump.sh", + "db:export-xlsx": "python3 scripts/export-db-xlsx.py" }, "dependencies": { "@huggingface/transformers": "^4.2.0", diff --git a/scripts/db-dump.sh b/scripts/db-dump.sh new file mode 100755 index 0000000..dc28f74 --- /dev/null +++ b/scripts/db-dump.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# 임베딩 포함 전체 덤프 — 배포 대상에서 재임베딩 없이 그대로 복원된다. +set -euo pipefail +OUT="${1:-data/ontology-dump.sql.gz}" +mkdir -p "$(dirname "$OUT")" +docker exec -i ontology-postgres pg_dump -U ontology -d ontology \ + --no-owner --no-privileges --clean --if-exists | gzip -9 > "$OUT" +echo "✅ $OUT ($(du -h "$OUT" | cut -f1))" +echo " 복원: gunzip -c $OUT | psql \"\$TARGET_DATABASE_URL\"" +echo " (대상 DB 에 vector · ltree · pg_trgm 확장이 설치돼 있어야 한다)" diff --git a/scripts/export-db-xlsx.py b/scripts/export-db-xlsx.py new file mode 100644 index 0000000..4bd65b9 --- /dev/null +++ b/scripts/export-db-xlsx.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용). + python3 scripts/export-db-xlsx.py +데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 — +384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다.""" +import csv, io, subprocess, collections +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +OUT = 'data/배포용_키워드_DB덤프.xlsx' +CONT = 'ontology-postgres' +DB = ['psql', '-U', 'ontology', '-d', 'ontology', '-t', '-A', '--csv', '-c'] + +INK = '1F2A2B' +HEAD = PatternFill('solid', fgColor='0D6A60') +THIN = Side(style='thin', color='D5DCDB') +BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) +SRC_FILL = {'dataset': 'DFF0EC', 'nationwide': 'FFFFFF', 'manual': 'F6EAD2'} + + +def query(sql: str): + out = subprocess.run(['docker', 'exec', '-i', CONT, *DB, sql], + capture_output=True, text=True, check=True).stdout + return list(csv.reader(io.StringIO(out))) + + +def sheet(wb, title, header, rows, widths_, fill_col=None, first=False): + ws = wb.active if first else wb.create_sheet(title) + if first: ws.title = title + ws.append(header) + for r in rows: ws.append(r) + for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(header)): + fill = None + if fill_col is not None: + fill = PatternFill('solid', fgColor=SRC_FILL.get(row[fill_col].value, 'FFFFFF')) + for c in row: + c.font = Font(size=10, color=INK); c.border = BOX + if fill: c.fill = fill + for c in range(1, len(header) + 1): + cell = ws.cell(row=1, column=c) + cell.fill = HEAD; cell.font = Font(bold=True, color='FFFFFF', size=10) + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = BOX + ws.row_dimensions[1].height = 22 + ws.freeze_panes = 'A2' + ws.auto_filter.ref = f'A1:{get_column_letter(len(header))}{ws.max_row}' + for i, w in enumerate(widths_, start=1): + ws.column_dimensions[get_column_letter(i)].width = w + return ws + + +wb = Workbook() + +# 1) 키워드 — DB 전체 +kw = query(""" + SELECT k.id, k.canonical, k.normalized, k.locale, + array_to_string(k.aliases, ' | ') AS aliases, + k.intent, k.kind, COALESCE(k.category,'') AS category, k.source, + COALESCE(k.industry_id,'') , COALESCE(k.region_id,''), + COALESCE(r.name,''), COALESCE(sido.name,''), + k.usage_count, + (k.embedding IS NOT NULL) AS has_embedding, + to_char(k.updated_at,'YYYY-MM-DD HH24:MI') + FROM keyword k + LEFT JOIN region r ON r.id = k.region_id + LEFT JOIN region sido ON sido.id = regexp_replace(k.region_id, '\\.[^.]+$', '') + ORDER BY k.source, sido.name NULLS FIRST, r.name NULLS FIRST, k.canonical +""") +sheet(wb, '키워드', + ['id', '키워드', '정규화', 'locale', '흡수된 표기(alias)', '의도', '종류', '카테고리', + '출처', '업종ID', '지역ID', '지역', '시도', '사용업체수', '임베딩', '갱신일시'], + kw, [38, 30, 26, 8, 30, 14, 8, 10, 12, 14, 24, 12, 8, 10, 9, 17], + fill_col=8, first=True) + +# 2~5) 마스터 +sheet(wb, '지역', ['지역ID', '경로(ltree)', '지역명'], + query("SELECT id, path::text, name FROM region ORDER BY path"), [26, 26, 16]) +sheet(wb, '업종', ['업종ID', '경로(ltree)', '업종명'], + query("SELECT id, path::text, name FROM industry ORDER BY path"), [22, 22, 16]) +sheet(wb, '업체', + ['외부ID', '상호', '업종', '지역', '소개', '사이트', '프로필(JSON)'], + query("""SELECT m.external_id, m.name, COALESCE(i.name,''), COALESCE(r.name,''), + m.description, COALESCE(m.site_url,''), m.profile::text + FROM merchant m + LEFT JOIN industry i ON i.id=m.industry_id + LEFT JOIN region r ON r.id=m.region_id + ORDER BY m.external_id"""), + [14, 18, 12, 10, 50, 34, 70]) +sheet(wb, 'QA(AEO)', ['업체', '질문', '답변', '상태'], + query("""SELECT m.name, q.question, q.answer, q.status::text + FROM qa_pair q JOIN merchant m ON m.id=q.merchant_id + ORDER BY m.name, q.created_at"""), [16, 44, 70, 10]) + +# 6) 배포 가이드 +counts = collections.Counter(r[8] for r in kw) +guide = [ + ('무엇이 들어있나', ''), + ('', f"벡터 DB(keyword 테이블)에 실제 적재된 {len(kw):,}건 전부. 데이터셋 JSON 이 아니라 DB 가 기준이다."), + ('', '출처별: ' + ' · '.join(f'{k} {v:,}' for k, v in counts.most_common())), + ('', 'dataset = 군산 상세(매칭 엔진 개발용) / nationwide = 전국 54개 지역'), + ('', ''), + ('임베딩은 왜 없나', ''), + ('', '384개 float × 7천 행이라 엑셀에 담을 수 없고 담아도 못 읽는다.'), + ('', '[임베딩] 열은 DB 에 벡터가 있는지만 표시한다.'), + ('', '같은 모델(Xenova/multilingual-e5-small)로 다시 만들면 동일한 값이 나오므로'), + ('', '텍스트만 있으면 복원된다.'), + ('', ''), + ('배포 방법 2가지', ''), + ('A. pg_dump (권장)', '임베딩 포함 그대로 복원. 재임베딩 불필요.'), + ('', ' npm run db:dump → data/ontology-dump.sql.gz'), + ('', ' gunzip -c data/ontology-dump.sql.gz | psql $TARGET_URL'), + ('B. 재적재', '텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초.'), + ('', ' npm run db:migrate && npm run db:seed'), + ('', ' npm run dataset:ingest && npm run dataset:ingest-nationwide'), + ('', ''), + ('⚠ 검색량은 아직 비어있다', ''), + ('', '이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.'), + ('', '네이버 검색광고 키워드도구로 월간검색수를 채우고 월 10 미만을 걷어내야'), + ('', '실서비스에 쓸 수 있다. (npm run dataset:import-related 로 CSV 병합)'), +] +ws = sheet(wb, '배포가이드', ['항목', '내용'], guide, [22, 100]) +for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2): + if row[0].value and not row[1].value: + row[0].font = Font(size=10, bold=True, color='0D6A60') + row[1].alignment = Alignment(wrap_text=True, vertical='center') + +wb.save(OUT) +print(f'✅ {OUT}') +print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets)) +print(f' 키워드 {len(kw):,}행 (' + ', '.join(f'{k} {v:,}' for k, v in counts.most_common()) + ')')