배포용 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:
parent
ac0b12b04c
commit
8322bd45cb
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,3 +3,6 @@ dist/
|
|||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# 배포 덤프 — 13MB, 재생성 가능 (npm run db:dump)
|
||||||
|
data/*.sql.gz
|
||||||
|
|||||||
40
README.md
40
README.md
@ -64,6 +64,46 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
|||||||
"고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고,
|
"고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고,
|
||||||
실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다.
|
실제 값(원도심 신흥동, 독채 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건**.
|
`data/전국_펜션_SEO_AEO_키워드.xlsx` — 전국 54개 펜션 수요 지역 × **7,138건**.
|
||||||
|
|||||||
BIN
data/배포용_키워드_DB덤프.xlsx
Normal file
BIN
data/배포용_키워드_DB덤프.xlsx
Normal file
Binary file not shown.
@ -19,7 +19,9 @@
|
|||||||
"dataset:purge": "tsx scripts/purge-nondataset.ts",
|
"dataset:purge": "tsx scripts/purge-nondataset.ts",
|
||||||
"dataset:import-related": "tsx scripts/import-related.ts",
|
"dataset:import-related": "tsx scripts/import-related.ts",
|
||||||
"dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py",
|
"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": {
|
"dependencies": {
|
||||||
"@huggingface/transformers": "^4.2.0",
|
"@huggingface/transformers": "^4.2.0",
|
||||||
|
|||||||
10
scripts/db-dump.sh
Executable file
10
scripts/db-dump.sh
Executable 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
131
scripts/export-db-xlsx.py
Normal 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()) + ')')
|
||||||
Loading…
Reference in New Issue
Block a user