배포용 DB 덤프 산출물 추가

엑셀이 DB 와 일치하지 않았다. 기존 엑셀은 전국 JSON 에서만 뽑아
군산 상세 데이터셋 850건이 빠져 있었다 (DB 7,093 vs 엑셀 6,243).

- scripts/export-db-xlsx.py — DB 를 기준으로 뽑는다 (JSON 아님)
  시트 6개: 키워드 7,093 / 지역 70 / 업종 9 / 업체 4 / QA 20 / 배포가이드
  임베딩은 담지 않는다 (384 float × 7천 행). 같은 모델로 재생성하면 복원된다
- scripts/db-dump.sh — 임베딩 포함 pg_dump (13MB, .gitignore)
- npm run db:export-xlsx / db:dump

복원 검증 완료: 6개 테이블 행수 일치, 임베딩 7,093/7,093 보존,
HNSW 인덱스 재생성, 복원본에서 벡터 검색 동작 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-14 10:26:05 +09:00
parent ac0b12b04c
commit 8322bd45cb
6 changed files with 187 additions and 1 deletions

3
.gitignore vendored
View File

@ -3,3 +3,6 @@ dist/
.env
*.tsbuildinfo
.DS_Store
# 배포 덤프 — 13MB, 재생성 가능 (npm run db:dump)
data/*.sql.gz

View File

@ -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건**.

Binary file not shown.

View File

@ -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",

10
scripts/db-dump.sh Executable file
View File

@ -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 확장이 설치돼 있어야 한다)"

131
scripts/export-db-xlsx.py Normal file
View File

@ -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()) + ')')