고정 데이터셋 1,000건 적재 + 로컬 임베딩 + 매칭 콘솔
정책 변경: 주기 수집 없이 고정 데이터셋을 1회 적재한다. - data/gunsan-pension-keywords.json — "군산 펜션" 키워드·태그 1,000건 실제 군산 지명·관광지·숙박 시설 어휘 × 로컬 검색 패턴으로 작성 - 임베딩을 LlmProvider 에서 EmbeddingProvider 로 분리 (mock | local:multilingual-e5-small | openai), e5 의 query/passage 비대칭 반영 - vector(1536) → vector(384) 마이그레이션, keyword 에 source/kind/category 추가 - scripts/ingest-dataset.ts — 어휘 중복만 자동 병합, 벡터 근접쌍은 검토 목록만 출력 - POST /v1/match — 업체명(띄어쓰기 무관) 또는 문장 → 사전에서 매칭 업체는 프로필 전체를 질의문으로 조립해 임베딩 - GET /demo — 매칭 콘솔 (public/demo.html) 실측으로 코사인 자동 병합 임계값을 0.92 → 0.99 로 정정. 짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본이라 0.92 는 오병합을 부른다. 적재 결과: 1,000건 → 어휘 중복 27 병합, 금칙어 2 차단 → 971건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef7d51da2c
commit
edde23f15e
12
.env.example
12
.env.example
@ -8,6 +8,13 @@ DATABASE_URL=postgres://ontology:ontology@localhost:55432/ontology
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=56379
|
||||
|
||||
# --- 임베딩 ---
|
||||
# local : 로컬 multilingual-e5-small (384차원, 최초 1회 모델 다운로드 후 오프라인)
|
||||
# mock : 문자 bigram 해싱 — 의미는 못 잡음
|
||||
# openai : text-embedding-3-small (dimensions=384 로 요청)
|
||||
EMBEDDING_PROVIDER=local
|
||||
EMBEDDING_LOCAL_MODEL=Xenova/multilingual-e5-small
|
||||
|
||||
# --- LLM ---
|
||||
# mock : API 키 없이 로컬에서 전체 파이프라인 동작 (기본값)
|
||||
# openai : 실제 OpenAI 호출
|
||||
@ -17,8 +24,9 @@ OPENAI_MODEL=gpt-4.1-mini
|
||||
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# --- 생성/중복제거 튜닝 ---
|
||||
# 코사인 유사도가 이 값 이상이면 기존 키워드에 alias 로 흡수
|
||||
DEDUP_COSINE_THRESHOLD=0.92
|
||||
# 코사인 자동 병합 임계값. 짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본으로 나오므로
|
||||
# 0.92 는 오병합을 부른다. 실측상 어순 변형만 0.999 대에 모이므로 0.99 로 둔다.
|
||||
DEDUP_COSINE_THRESHOLD=0.99
|
||||
# trigram 유사도 사전 필터
|
||||
DEDUP_TRIGRAM_THRESHOLD=0.6
|
||||
# 벡터 비교 대상 상위 후보 수
|
||||
|
||||
92
README.md
92
README.md
@ -2,9 +2,10 @@
|
||||
|
||||
o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스.
|
||||
|
||||
- 주기적으로 LLM 에게 업체 정보를 주고 키워드·태그·Q&A 를 생성
|
||||
- **4단계 계단식 중복제거**로 전역 키워드 사전을 오염 없이 유지
|
||||
- 발행 사이트는 REST 로 SEO/AEO payload 만 받아 쓴다 (JSON-LD 조립은 후속 단계)
|
||||
- **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건)
|
||||
- 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색
|
||||
- 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔**
|
||||
- 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만
|
||||
|
||||
## 빠른 시작 (로컬)
|
||||
|
||||
@ -14,10 +15,15 @@ cp .env.example .env # 기본값은 LLM_PROVIDER=mock — API 키 불
|
||||
npm run db:up # postgres(pgvector) + redis
|
||||
npm run db:migrate
|
||||
npm run db:seed # 업종/지역 계층 + 데모 업체 3곳
|
||||
npm run dataset:build # 1,000건 데이터셋 생성 → data/
|
||||
npm run dataset:ingest # 임베딩 + pgvector 적재 (최초 1회 모델 다운로드)
|
||||
npm start # http://localhost:3100
|
||||
npm run smoke # (다른 터미널) 엔드투엔드 점검
|
||||
```
|
||||
|
||||
브라우저에서 **http://localhost:3100/demo** 를 열면 매칭 콘솔이 뜬다.
|
||||
입력창에 `스테이 머뭄` 을 넣으면 (띄어쓰기가 달라도) 업체를 해석하고
|
||||
적재된 971건 사전에서 잘 맞는 키워드를 순위대로 보여준다.
|
||||
|
||||
`npm run db:reset` 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다.
|
||||
|
||||
### 실제 OpenAI 로 전환
|
||||
@ -48,6 +54,52 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만
|
||||
저장되고, `강남 미용실` 을 쓰는 업체가 100곳이어도 중복제거가 성립한다.
|
||||
|
||||
## 데이터셋
|
||||
|
||||
`data/gunsan-pension-keywords.json` — "군산 펜션" 주제로 직접 작성한 1,000건.
|
||||
실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과
|
||||
숙박 시설 용어를 어휘로 두고, 한국 로컬 숙박 검색에서 실제로 쓰이는 패턴만 전개했다.
|
||||
|
||||
| 카테고리 | 건수 | 예시 |
|
||||
|---|---:|---|
|
||||
| 롱테일 | 374 | 군산 커플 오션뷰 펜션 |
|
||||
| 시설 | 104 | 군산 자쿠지 펜션 |
|
||||
| 권역 | 99 | 선유도 독채펜션 |
|
||||
| 동반자 | 98 | 군산 애견동반 펜션 |
|
||||
| 시즌 | 72 | 군산 여름휴가 펜션 |
|
||||
| 관광지 | 64 | 경암동 철길마을 근처 숙소 |
|
||||
| 태그 | 63 | 오션뷰 · 불멍 · 애견운동장 |
|
||||
| 코어 | 51 | 군산 펜션 추천 |
|
||||
| 질문형 | 33 | 군산 펜션 바베큐 가능한가요 |
|
||||
| 의도 | 13 | 군산 펜션 실시간예약 |
|
||||
|
||||
적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**.
|
||||
|
||||
`npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다.
|
||||
재적재는 `ON CONFLICT (normalized, locale) DO UPDATE` 라 몇 번을 돌려도 971건을 유지한다.
|
||||
|
||||
## 임베딩 임계값 — 실측으로 정정한 부분
|
||||
|
||||
설계 초안의 코사인 자동 병합 임계값 0.92 는 **틀렸다.** 짧은 한글 키워드에서는
|
||||
같은 도메인이기만 하면 절대 코사인이 기본적으로 높게 나온다.
|
||||
|
||||
| 쌍 | 실제 관계 | cos (e5-small) |
|
||||
|---|---|---:|
|
||||
| 군산 키즈룸 펜션 ↔ 군산 펜션 키즈룸 | 중복 (어순) | **0.9995** |
|
||||
| 군산 애견동반 펜션 ↔ 군산 반려견 동반 펜션 | 중복 (동의어) | 0.9886 |
|
||||
| 선유도 펜션 ↔ 선유도 팬션 | 중복 (오타) | 0.9585 |
|
||||
| 군산 펜션 ↔ 군산 호텔 | **별개** | 0.9698 |
|
||||
| 선유도 펜션 ↔ 새만금 펜션 | **별개** | 0.9356 |
|
||||
|
||||
중복과 별개의 분포가 겹치므로 단일 임계값으로는 깨끗하게 못 가른다
|
||||
(`paraphrase-multilingual-MiniLM-L12-v2` 도 동일).
|
||||
|
||||
그래서 정책을 이렇게 바꿨다.
|
||||
|
||||
- **자동 병합의 주력은 어휘 단계(1~2)** — 공백/구두점 정규화와 `pg_trgm` 이 오타·표기 변형을 잡는다
|
||||
- **벡터 단계는 0.99 로 올려 잡는다** — 어순 변형처럼 확실한 것만 걸린다
|
||||
- **적재 시에는 벡터 병합을 아예 하지 않고 검토 목록만 출력한다** (`npm run dataset:ingest` 끝부분)
|
||||
|
||||
## 중복제거 4단계
|
||||
|
||||
값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다.
|
||||
@ -75,7 +127,9 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
|
||||
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
|
||||
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
|
||||
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드** (데모 콘솔이 쓰는 API) |
|
||||
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
|
||||
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
|
||||
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
|
||||
|
||||
`:id` 는 `external_id` 또는 내부 UUID 둘 다 받는다.
|
||||
@ -110,6 +164,36 @@ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
|
||||
}
|
||||
```
|
||||
|
||||
### 매칭 예시
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3100/v1/match \
|
||||
-H 'content-type: application/json' -d '{"query":"스테이 머뭄","limit":5}'
|
||||
```
|
||||
|
||||
업체명이면 상호만으로 임베딩하지 않고 **프로필 전체를 질의문으로 조립**한다.
|
||||
상호는 브랜드명이라 그것만으로는 매칭이 얕아지기 때문이다.
|
||||
|
||||
```
|
||||
해석: 스테이머뭄 (군산 / 펜션) ← "스테이 머뭄" 과 띄어쓰기가 달라도 해석됨
|
||||
질의문: 스테이머뭄 군산 펜션 고군산군도 초입에 자리한 독채 펜션 … 오션뷰 애견동반 …
|
||||
|
||||
0.8765 군산 독채펜션 예약 [transactional] 코어
|
||||
0.8762 군산 애견동반 독채펜션 [local] 동반자
|
||||
0.8751 고군산군도 독채펜션 [local] 권역
|
||||
0.8735 군산 바베큐 펜션 예약 [transactional] 시설
|
||||
0.8718 군산 오션뷰 펜션 예약 [transactional] 시설
|
||||
```
|
||||
|
||||
업체가 해석되지 않으면 입력 문장을 그대로 질의로 쓴다.
|
||||
|
||||
```
|
||||
"선유도 근처에서 바베큐 되는 독채"
|
||||
0.9166 선유도 독채펜션
|
||||
0.9155 선유도 바베큐 펜션
|
||||
0.9051 선유도해수욕장 근처 숙소
|
||||
```
|
||||
|
||||
## 생성 주기
|
||||
|
||||
- **발행 즉시** — `/v1/merchants/publish` 가 BullMQ 에 적재 (60초 dedupe 창)
|
||||
|
||||
7008
data/gunsan-pension-keywords.json
Normal file
7008
data/gunsan-pension-keywords.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
docs/site-ontology 아키텍쳐.pptx
Normal file
BIN
docs/site-ontology 아키텍쳐.pptx
Normal file
Binary file not shown.
22
drizzle/0001_embedding_384.sql
Normal file
22
drizzle/0001_embedding_384.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- 임베딩 모델 전환: 로컬 multilingual-e5-small (384차원)
|
||||
-- mock/openai 도 384 로 통일한다 (openai 는 dimensions 파라미터로 축소 요청).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute
|
||||
WHERE attrelid = 'keyword'::regclass AND attname = 'embedding') <> 'vector(384)' THEN
|
||||
DROP INDEX IF EXISTS keyword_embedding_hnsw;
|
||||
ALTER TABLE keyword ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384);
|
||||
CREATE INDEX keyword_embedding_hnsw ON keyword USING hnsw (embedding vector_cosine_ops);
|
||||
END IF;
|
||||
|
||||
IF (SELECT format_type(atttypid, atttypmod) FROM pg_attribute
|
||||
WHERE attrelid = 'qa_pair'::regclass AND attname = 'embedding') <> 'vector(384)' THEN
|
||||
ALTER TABLE qa_pair ALTER COLUMN embedding TYPE vector(384) USING NULL::vector(384);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 데이터셋 출처 추적 (수작업 큐레이션 / LLM 생성 구분)
|
||||
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'llm';
|
||||
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'keyword';
|
||||
ALTER TABLE keyword ADD COLUMN IF NOT EXISTS category text;
|
||||
CREATE INDEX IF NOT EXISTS keyword_kind_idx ON keyword (kind, category);
|
||||
899
package-lock.json
generated
899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,9 +13,12 @@
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed",
|
||||
"smoke": "tsx scripts/smoke.ts"
|
||||
"smoke": "tsx scripts/smoke.ts",
|
||||
"dataset:build": "node scripts/build-dataset.mjs",
|
||||
"dataset:ingest": "tsx scripts/ingest-dataset.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.12",
|
||||
"@nestjs/core": "^11.0.12",
|
||||
|
||||
235
public/demo.html
Normal file
235
public/demo.html
Normal file
@ -0,0 +1,235 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>키워드 매칭 콘솔 · o2o-site-ontology</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f4f6f5; --surface:#fff; --surface2:#eceff0; --ink:#101819; --ink-soft:#3d4c4e;
|
||||
--muted:#63757a; --line:#d5dcdb; --line-soft:#e4e9e8;
|
||||
--accent:#0d6a60; --accent-bg:#dff0ec; --warn:#8a5a06; --warn-bg:#f6ead2;
|
||||
--stop:#9d3a30; --info:#1d5b7a; --info-bg:#dceaf2;
|
||||
--sans:'Apple SD Gothic Neo',system-ui,-apple-system,'Malgun Gothic',sans-serif;
|
||||
--mono:'Menlo','SFMono-Regular',Consolas,monospace;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
|
||||
--bg:#0d1213; --surface:#141b1c; --surface2:#1b2425; --ink:#e7edeb; --ink-soft:#c2cecd;
|
||||
--muted:#8d9d9f; --line:#263130; --line-soft:#1e2728;
|
||||
--accent:#56c2b1; --accent-bg:#12312e; --warn:#d7a34a; --warn-bg:#33270f;
|
||||
--stop:#e28a80; --info:#7fb6d4; --info-bg:#142c3a;
|
||||
}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 80px}
|
||||
header{border-bottom:1px solid var(--line);padding-bottom:20px;margin-bottom:24px}
|
||||
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--accent);margin:0 0 8px}
|
||||
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.01em}
|
||||
.sub{margin:0;color:var(--muted);font-size:14px}
|
||||
.status{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
.status span{border:1px solid var(--line);background:var(--surface);border-radius:3px;padding:3px 9px}
|
||||
|
||||
form{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
|
||||
input[type=text]{flex:1;min-width:260px;padding:12px 14px;font-size:15px;font-family:var(--sans);
|
||||
background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px}
|
||||
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
|
||||
select,button{padding:12px 16px;font-size:14px;font-family:var(--sans);border-radius:5px;border:1px solid var(--line);
|
||||
background:var(--surface);color:var(--ink)}
|
||||
button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;cursor:pointer}
|
||||
button:disabled{opacity:.5;cursor:progress}
|
||||
.examples{display:flex;gap:6px;flex-wrap:wrap;margin:-8px 0 22px}
|
||||
.examples button{background:var(--surface);color:var(--muted);border:1px solid var(--line);
|
||||
font-weight:400;font-size:12px;padding:5px 11px;border-radius:20px}
|
||||
.examples button:hover{color:var(--accent);border-color:var(--accent)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:320px 1fr;gap:22px;align-items:start}
|
||||
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:18px}
|
||||
.card h2{margin:0 0 12px;font-size:14px;letter-spacing:.02em}
|
||||
.kv{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;font-size:13px}
|
||||
.kv dt{color:var(--muted)}
|
||||
.kv dd{margin:0;color:var(--ink-soft)}
|
||||
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
|
||||
.chip{font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--ink-soft);
|
||||
padding:2px 7px;border-radius:3px}
|
||||
.qtext{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);
|
||||
font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;line-height:1.7}
|
||||
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
|
||||
.toolbar .count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
|
||||
.filter{font-size:12px;font-family:var(--mono);padding:4px 10px;border-radius:20px;
|
||||
border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
|
||||
.filter[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--muted);
|
||||
text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line);font-weight:600}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:hover td{background:var(--surface2)}
|
||||
.rank{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;
|
||||
font-variant-numeric:tabular-nums}
|
||||
.kw{font-weight:500}
|
||||
.kw small{display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted);font-weight:400}
|
||||
.bar{position:relative;width:120px;height:7px;background:var(--surface2);border-radius:4px;overflow:hidden}
|
||||
.bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:4px}
|
||||
.score{font-family:var(--mono);font-size:11.5px;color:var(--ink-soft);width:48px;
|
||||
font-variant-numeric:tabular-nums;text-align:right}
|
||||
.tag{font-size:10.5px;font-family:var(--mono);padding:2px 7px;border-radius:3px;white-space:nowrap}
|
||||
.t-local{background:var(--accent-bg);color:var(--accent)}
|
||||
.t-transactional{background:var(--warn-bg);color:var(--warn)}
|
||||
.t-informational{background:var(--info-bg);color:var(--info)}
|
||||
.t-brand{background:var(--surface2);color:var(--ink-soft)}
|
||||
.cat{font-size:11px;color:var(--muted);font-family:var(--mono)}
|
||||
.linked{font-size:10.5px;font-family:var(--mono);color:var(--accent)}
|
||||
.empty{padding:40px;text-align:center;color:var(--muted);font-size:14px}
|
||||
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
|
||||
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
|
||||
.tblwrap{overflow-x:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow">O2O-SITE-ONTOLOGY</p>
|
||||
<h1>키워드 매칭 콘솔</h1>
|
||||
<p class="sub">업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.</p>
|
||||
<div class="status" id="status"><span>연결 확인 중…</span></div>
|
||||
</header>
|
||||
|
||||
<form id="f">
|
||||
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
|
||||
<select id="limit">
|
||||
<option value="30">상위 30</option>
|
||||
<option value="50" selected>상위 50</option>
|
||||
<option value="100">상위 100</option>
|
||||
</select>
|
||||
<button type="submit" id="go">매칭</button>
|
||||
</form>
|
||||
<div class="examples" id="ex"></div>
|
||||
|
||||
<div id="err"></div>
|
||||
|
||||
<div class="grid">
|
||||
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
|
||||
<section>
|
||||
<div class="toolbar" id="filters"></div>
|
||||
<div class="card" style="padding:0">
|
||||
<div class="tblwrap"><table id="tbl">
|
||||
<thead><tr><th class="rank">#</th><th>키워드</th><th>유사도</th><th>의도</th><th>카테고리</th></tr></thead>
|
||||
<tbody><tr><td colspan="5"><div class="empty">매칭 버튼을 눌러 시작하세요</div></td></tr></tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = location.origin;
|
||||
const $ = (s) => document.querySelector(s);
|
||||
let LAST = null, FILTER = null;
|
||||
|
||||
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
|
||||
'아이랑 물놀이 하기 좋은 곳', '선유도 근처에서 바베큐 되는 독채'];
|
||||
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
|
||||
$('#ex').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
$('#q').value = b.dataset.q; run();
|
||||
});
|
||||
|
||||
async function health() {
|
||||
try {
|
||||
const h = await (await fetch(API + '/health')).json();
|
||||
$('#status').innerHTML =
|
||||
`<span>API ${h.status}</span><span>임베딩 ${h.embeddingProvider}</span><span>생성 ${h.llmProvider}</span>`;
|
||||
} catch { $('#status').innerHTML = '<span>API 연결 실패 — npm start 실행 중인지 확인</span>'; }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const query = $('#q').value.trim();
|
||||
if (!query) return;
|
||||
$('#go').disabled = true; $('#err').innerHTML = '';
|
||||
try {
|
||||
const res = await fetch(API + '/v1/match', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query, limit: Number($('#limit').value) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
LAST = await res.json(); FILTER = null;
|
||||
renderSide(); renderFilters(); renderTable();
|
||||
} catch (e) {
|
||||
$('#err').innerHTML = `<div class="err">요청 실패 — ${String(e.message || e).slice(0, 300)}</div>`;
|
||||
} finally { $('#go').disabled = false; }
|
||||
}
|
||||
|
||||
function renderSide() {
|
||||
const m = LAST.resolved;
|
||||
if (!m) {
|
||||
$('#side').innerHTML = `<h2>업체 미해석</h2>
|
||||
<p style="font-size:13px;color:var(--muted);margin:0">
|
||||
일치하는 업체가 없어 입력 문장을 그대로 질의로 사용했습니다.</p>
|
||||
<div class="qtext"><b>질의문</b><br>${esc(LAST.queryText)}</div>`;
|
||||
return;
|
||||
}
|
||||
const p = m.profile || {};
|
||||
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
|
||||
const chips = (k, label) => arr(k).length
|
||||
? `<dt>${label}</dt><dd><div class="chips">${arr(k).map(v => `<span class="chip">${esc(v)}</span>`).join('')}</div></dd>` : '';
|
||||
$('#side').innerHTML = `
|
||||
<h2>해석된 업체</h2>
|
||||
<dl class="kv">
|
||||
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
|
||||
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
|
||||
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
|
||||
<dt>ID</dt><dd style="font-family:var(--mono);font-size:11.5px">${esc(m.externalId)}</dd>
|
||||
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
|
||||
${chips('services', '서비스')}${chips('features', '시설')}
|
||||
${chips('audiences', '동반자')}${chips('nearby', '인근')}
|
||||
</dl>
|
||||
<div class="qtext"><b>임베딩에 사용한 질의문</b><br>${esc(LAST.queryText)}</div>`;
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
|
||||
$('#filters').innerHTML =
|
||||
`<button class="filter" data-c="" aria-pressed="true">전체</button>` +
|
||||
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="false">${esc(c)}</button>`).join('') +
|
||||
`<span class="count">사전 ${LAST.total.toLocaleString()}건 중 상위 ${LAST.matches.length}건</span>`;
|
||||
$('#filters').onclick = (e) => {
|
||||
const b = e.target.closest('.filter'); if (!b) return;
|
||||
FILTER = b.dataset.c || null;
|
||||
[...$('#filters').querySelectorAll('.filter')]
|
||||
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
|
||||
renderTable();
|
||||
};
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const rows = LAST.matches.filter(m => !FILTER || m.category === FILTER);
|
||||
const tb = $('#tbl tbody');
|
||||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>'; return; }
|
||||
const max = Math.max(...rows.map(r => r.score)), min = Math.min(...rows.map(r => r.score));
|
||||
const norm = (s) => max === min ? 100 : 6 + 94 * (s - min) / (max - min);
|
||||
tb.innerHTML = rows.map((r, i) => `
|
||||
<tr>
|
||||
<td class="rank">${i + 1}</td>
|
||||
<td class="kw">${esc(r.canonical)}
|
||||
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
|
||||
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
|
||||
${r.aliases?.length ? `<small>흡수: ${r.aliases.map(esc).join(', ')}</small>` : ''}
|
||||
</td>
|
||||
<td><div style="display:flex;align-items:center;gap:8px">
|
||||
<div class="bar"><i style="width:${norm(r.score).toFixed(1)}%"></i></div>
|
||||
<span class="score">${r.score.toFixed(3)}</span></div></td>
|
||||
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
|
||||
<td class="cat">${esc(r.category || '-')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
|
||||
health(); run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
176
scripts/build-dataset.mjs
Normal file
176
scripts/build-dataset.mjs
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성.
|
||||
* node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json
|
||||
*
|
||||
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
|
||||
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
|
||||
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
|
||||
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
|
||||
const REGION = '군산';
|
||||
|
||||
// 고군산군도·해안 권역
|
||||
const ISLANDS = ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도'];
|
||||
// 시내·주요 권역
|
||||
const AREAS = ['새만금', '비응항', '오식도', '은파호수공원', '월명동', '나운동', '수송동', '미룡동', '옥도면'];
|
||||
// 관광지 (근처 숙소 검색의 앵커)
|
||||
const SPOTS = [
|
||||
'선유도해수욕장', '새만금방조제', '경암동 철길마을', '근대역사박물관', '초원사진관',
|
||||
'이성당', '동국사', '진포해양테마공원', '신흥동 일본식가옥', '은파호수공원',
|
||||
'월명공원', '금강하구둑', '철새조망대', '채만식문학관', '째보선창', '시간여행마을',
|
||||
];
|
||||
|
||||
// 숙소 유형
|
||||
const STAY_CORE = ['펜션', '숙소', '풀빌라', '독채펜션', '스파펜션', '애견펜션', '감성펜션'];
|
||||
const STAY_ALT = ['글램핑', '카라반', '캠핑장', '게스트하우스', '민박', '리조트', '한옥펜션', '촌집', '별장'];
|
||||
|
||||
// 검색 의도어
|
||||
const INTENT_CORE = ['추천', '예약', '가격', '후기', '순위'];
|
||||
const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약', '당일예약', '특가', '할인',
|
||||
'위치', '주차', '전화번호', '체크인시간', '조식포함', '청소상태'];
|
||||
|
||||
// 동반자
|
||||
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
|
||||
const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵',
|
||||
'회사', '태교여행', '혼자', '여자끼리', '4인', '6인', '10인', '20인'];
|
||||
|
||||
// 시설·특징
|
||||
const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장'];
|
||||
const FEAT_MORE = ['노을뷰', '일출뷰', '월풀', '온수풀', '야외수영장', '인피니티풀', '불멍', '화로대',
|
||||
'넷플릭스', '빔프로젝터', '노래방', '파티룸', '복층', '테라스', '마당', '벽난로',
|
||||
'애견운동장', '키즈룸', '트램폴린', '무료주차', '조식', '세미나실'];
|
||||
|
||||
// 시즌·행사
|
||||
const SEASON = ['여름휴가', '물놀이', '해수욕', '겨울', '연말', '크리스마스', '신정', '설날', '추석',
|
||||
'봄', '벚꽃', '가을', '단풍', '일출', '낙조', '불꽃놀이', '성수기', '비수기', '주말', '평일'];
|
||||
const OCCASION = ['생일', '기념일', '결혼기념일', '프러포즈', '100일', '가족여행', '워크샵', '단합대회'];
|
||||
|
||||
// ──────────────────────────────────────────────── 방출
|
||||
const rows = [];
|
||||
const seen = new Set();
|
||||
|
||||
const INTENT_MAP = {
|
||||
예약: 'transactional', 실시간예약: 'transactional', 당일예약: 'transactional',
|
||||
가격: 'transactional', 최저가: 'transactional', 특가: 'transactional', 할인: 'transactional',
|
||||
후기: 'informational', 순위: 'informational', 청소상태: 'informational', 체크인시간: 'informational',
|
||||
};
|
||||
const intentOf = (m) => INTENT_MAP[m] ?? 'local';
|
||||
|
||||
function add(keyword, { intent = 'local', kind = 'keyword', category, relevance }) {
|
||||
const k = keyword.replace(/\s+/g, ' ').trim();
|
||||
if (!k || seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
rows.push({ keyword: k, intent, kind, category, relevance: Math.round(relevance * 100) / 100 });
|
||||
return true;
|
||||
}
|
||||
|
||||
// T1 — 코어: 지역 × 숙소유형 × 의도
|
||||
for (const s of STAY_CORE) add(`${REGION} ${s}`, { category: '코어', relevance: 0.97 });
|
||||
for (const s of STAY_CORE) for (const m of INTENT_CORE)
|
||||
add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 });
|
||||
for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 });
|
||||
for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 });
|
||||
|
||||
// T2 — 섬·권역 × 숙소유형
|
||||
for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4))
|
||||
add(`${p} ${s}`, { category: '권역', relevance: g === ISLANDS ? 0.9 : 0.85 });
|
||||
for (const p of ISLANDS) for (const m of INTENT_CORE)
|
||||
add(`${p} 펜션 ${m}`, { intent: intentOf(m), category: '권역', relevance: 0.82 });
|
||||
|
||||
// T3 — 동반자
|
||||
for (const w of WITH_CORE) {
|
||||
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.91 });
|
||||
for (const m of INTENT_CORE) add(`${REGION} ${w} 펜션 ${m}`, { intent: intentOf(m), category: '동반자', relevance: 0.8 });
|
||||
for (const s of STAY_CORE.slice(2, 6)) add(`${REGION} ${w} ${s}`, { category: '동반자', relevance: 0.78 });
|
||||
}
|
||||
for (const w of WITH_MORE) {
|
||||
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.82 });
|
||||
add(`${REGION} ${w} 펜션 추천`, { category: '동반자', relevance: 0.75 });
|
||||
add(`${REGION} ${w} 숙소`, { category: '동반자', relevance: 0.73 });
|
||||
}
|
||||
|
||||
// T4 — 시설·특징
|
||||
for (const f of FEAT_CORE) {
|
||||
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.89 });
|
||||
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.76 });
|
||||
for (const m of INTENT_CORE.slice(0, 3)) add(`${REGION} ${f} 펜션 ${m}`, { intent: intentOf(m), category: '시설', relevance: 0.72 });
|
||||
}
|
||||
for (const f of FEAT_MORE) {
|
||||
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.79 });
|
||||
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.7 });
|
||||
}
|
||||
for (const p of ISLANDS.slice(0, 4)) for (const f of FEAT_CORE)
|
||||
add(`${p} ${f} 펜션`, { category: '시설', relevance: 0.74 });
|
||||
|
||||
// T5 — 시즌·행사
|
||||
for (const s of SEASON) {
|
||||
add(`${REGION} ${s} 펜션`, { category: '시즌', relevance: 0.8 });
|
||||
add(`${REGION} ${s} 펜션 예약`, { intent: 'transactional', category: '시즌', relevance: 0.71 });
|
||||
add(`${s} ${REGION} 숙소`, { category: '시즌', relevance: 0.68 });
|
||||
}
|
||||
for (const o of OCCASION) {
|
||||
add(`${REGION} ${o} 펜션`, { category: '시즌', relevance: 0.75 });
|
||||
add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 });
|
||||
}
|
||||
|
||||
// T6 — 관광지 앵커
|
||||
for (const sp of SPOTS) {
|
||||
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
|
||||
add(`${sp} 근처 숙소`, { category: '관광지', relevance: 0.8 });
|
||||
add(`${sp} 펜션 추천`, { category: '관광지', relevance: 0.72 });
|
||||
add(`${sp} 가까운 숙소`, { category: '관광지', relevance: 0.66 });
|
||||
}
|
||||
|
||||
// T7 — 태그 (칩 UI 용 짧은 패싯)
|
||||
const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT,
|
||||
'오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능',
|
||||
'단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷'];
|
||||
for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 });
|
||||
|
||||
// T8 — 질문형 (AEO)
|
||||
const Q = [];
|
||||
for (const w of [...WITH_CORE, '아이', '부모님']) Q.push([`${REGION} ${w} 펜션 어디가 좋아요`, 'informational', 0.7]);
|
||||
for (const f of FEAT_CORE) Q.push([`${REGION}에 ${f} 펜션 있나요`, 'informational', 0.67]);
|
||||
for (const p of ISLANDS.slice(0, 5)) {
|
||||
Q.push([`${p} 펜션 어떻게 가나요`, 'informational', 0.64]);
|
||||
Q.push([`${p} 숙소 예약 언제 해야 하나요`, 'informational', 0.6]);
|
||||
}
|
||||
Q.push([`${REGION} 펜션 1박 얼마인가요`, 'transactional', 0.72]);
|
||||
Q.push([`${REGION} 펜션 바베큐 가능한가요`, 'informational', 0.7]);
|
||||
Q.push([`${REGION} 펜션 체크인 몇시인가요`, 'informational', 0.66]);
|
||||
Q.push([`${REGION} 펜션 주차 되나요`, 'informational', 0.66]);
|
||||
Q.push([`${REGION} 애견동반 펜션 추가요금 있나요`, 'informational', 0.63]);
|
||||
Q.push([`${REGION} 펜션 성수기 언제인가요`, 'informational', 0.61]);
|
||||
Q.push([`선유도 들어가는 배 시간표`, 'informational', 0.55]);
|
||||
Q.push([`${REGION} 여행 몇박이 좋을까요`, 'informational', 0.54]);
|
||||
for (const [k, i, r] of Q) add(k, { intent: i, category: '질문형', relevance: r });
|
||||
|
||||
// T9 — 롱테일: 동반자 × 시설 / 권역 × 동반자 / 시즌 × 동반자
|
||||
const LONGTAIL = [];
|
||||
for (const w of WITH_CORE) for (const f of FEAT_CORE) LONGTAIL.push([`${REGION} ${w} ${f} 펜션`, 0.5]);
|
||||
for (const p of ISLANDS) for (const w of WITH_CORE) LONGTAIL.push([`${p} ${w} 펜션`, 0.48]);
|
||||
for (const s of SEASON) for (const w of WITH_CORE) LONGTAIL.push([`${REGION} ${s} ${w} 펜션`, 0.44]);
|
||||
for (const f of FEAT_CORE) for (const f2 of FEAT_MORE) LONGTAIL.push([`${REGION} ${f} ${f2} 펜션`, 0.4]);
|
||||
for (const a of AREAS) for (const f of FEAT_CORE) LONGTAIL.push([`${a} ${f} 펜션`, 0.42]);
|
||||
for (const [k, r] of LONGTAIL) {
|
||||
if (rows.length >= 1000) break;
|
||||
add(k, { category: '롱테일', relevance: r });
|
||||
}
|
||||
|
||||
const dataset = rows.slice(0, 1000);
|
||||
mkdirSync('data', { recursive: true });
|
||||
writeFileSync('data/gunsan-pension-keywords.json', JSON.stringify({
|
||||
topic: '군산 펜션',
|
||||
locale: 'ko-KR',
|
||||
generatedBy: 'hand-authored vocabulary × search-pattern expansion',
|
||||
count: dataset.length,
|
||||
items: dataset,
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const by = (f) => dataset.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
|
||||
console.log(`✅ data/gunsan-pension-keywords.json ${dataset.length}건`);
|
||||
console.log(' 카테고리:', by('category'));
|
||||
console.log(' 의도 :', by('intent'));
|
||||
console.log(' 종류 :', by('kind'));
|
||||
88
scripts/ingest-dataset.ts
Normal file
88
scripts/ingest-dataset.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* data/gunsan-pension-keywords.json 을 pgvector 에 적재한다.
|
||||
* npx tsx scripts/ingest-dataset.ts
|
||||
*
|
||||
* 정책: 주기 수집 없음. 고정 데이터셋 1회 적재.
|
||||
* 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고,
|
||||
* 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조)
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createSql, toVector } from '../src/db/db';
|
||||
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
|
||||
import { LocalEmbeddingProvider } from '../src/embedding/local.provider';
|
||||
import { MockEmbeddingProvider } from '../src/embedding/mock.provider';
|
||||
import { env } from '../src/config/env';
|
||||
|
||||
type Item = { keyword: string; intent: string; kind: string; category: string; relevance: number };
|
||||
|
||||
async function main() {
|
||||
const sql = createSql();
|
||||
const embedder =
|
||||
env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider();
|
||||
|
||||
const raw = JSON.parse(readFileSync('data/gunsan-pension-keywords.json', 'utf8'));
|
||||
const items: Item[] = raw.items;
|
||||
console.log(`📦 데이터셋 ${items.length}건 · 임베딩 ${embedder.name}`);
|
||||
|
||||
// 1) 파일 내 어휘 중복 정리
|
||||
const byNorm = new Map<string, { item: Item; aliases: string[] }>();
|
||||
let banned = 0;
|
||||
for (const it of items) {
|
||||
const canonical = canonicalizeKeyword(it.keyword);
|
||||
const norm = normalizeKeyword(it.keyword);
|
||||
if (!norm || isBanned(canonical)) { banned++; continue; }
|
||||
const hit = byNorm.get(norm);
|
||||
if (hit) {
|
||||
if (!hit.aliases.includes(canonical) && hit.item.keyword !== canonical) hit.aliases.push(canonical);
|
||||
if (it.relevance > hit.item.relevance) hit.item = it;
|
||||
} else {
|
||||
byNorm.set(norm, { item: { ...it, keyword: canonical }, aliases: [] });
|
||||
}
|
||||
}
|
||||
const uniq = [...byNorm.entries()];
|
||||
console.log(` 어휘 중복제거 → ${uniq.length}건 (병합 ${items.length - uniq.length - banned}, 금칙어 ${banned})`);
|
||||
|
||||
// 2) 임베딩 (배치)
|
||||
const t0 = Date.now();
|
||||
const vecs = await embedder.embed(uniq.map(([, v]) => v.item.keyword), 'passage');
|
||||
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
|
||||
|
||||
// 3) 적재
|
||||
const region = 'kr.jeonbuk.gunsan';
|
||||
const industry = 'stay.pension';
|
||||
let inserted = 0, updated = 0;
|
||||
await sql.begin(async (tx) => {
|
||||
for (let i = 0; i < uniq.length; i++) {
|
||||
const [norm, v] = uniq[i];
|
||||
const res = await tx<Array<{ inserted: boolean }>>`
|
||||
INSERT INTO keyword
|
||||
(canonical, normalized, locale, aliases, intent, kind, category, source,
|
||||
industry_id, region_id, embedding)
|
||||
VALUES (${v.item.keyword}, ${norm}, 'ko-KR', ${v.aliases},
|
||||
${v.item.intent}::keyword_intent, ${v.item.kind}, ${v.item.category}, 'dataset',
|
||||
${industry}, ${region}, ${toVector(vecs[i])}::vector)
|
||||
ON CONFLICT (normalized, locale) DO UPDATE SET
|
||||
canonical = EXCLUDED.canonical, aliases = EXCLUDED.aliases, intent = EXCLUDED.intent,
|
||||
kind = EXCLUDED.kind, category = EXCLUDED.category, source = EXCLUDED.source,
|
||||
embedding = EXCLUDED.embedding, updated_at = now()
|
||||
RETURNING (xmax = 0) AS inserted`;
|
||||
res[0]?.inserted ? inserted++ : updated++;
|
||||
if (i % 100 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`);
|
||||
}
|
||||
});
|
||||
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
||||
|
||||
// 4) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만
|
||||
const near = await sql<Array<{ a: string; b: string; sim: number }>>`
|
||||
SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim
|
||||
FROM keyword k1 JOIN keyword k2
|
||||
ON k1.id < k2.id AND k1.embedding <=> k2.embedding < 0.02
|
||||
WHERE k1.source = 'dataset' AND k2.source = 'dataset'
|
||||
ORDER BY sim DESC LIMIT 15`;
|
||||
console.log(`\n🔍 벡터 근접쌍 검토 목록 (cos ≥ 0.98, 자동 병합 안 함) — 상위 ${near.length}`);
|
||||
for (const n of near) console.log(` ${Number(n.sim).toFixed(4)} ${n.a} ↔ ${n.b}`);
|
||||
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('❌', e); process.exit(1); });
|
||||
@ -3,6 +3,7 @@ import { Controller, Get, Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { env } from './config/env';
|
||||
import { DbModule } from './db/db.module';
|
||||
import { EmbeddingModule } from './embedding/embedding.module';
|
||||
import { GenerationModule } from './generation/generation.module';
|
||||
import { MerchantsHttpModule } from './merchants/merchants.controller.module';
|
||||
import { ServingModule } from './serving/serving.module';
|
||||
@ -11,13 +12,19 @@ import { ServingModule } from './serving/serving.module';
|
||||
class HealthController {
|
||||
@Get('health')
|
||||
health() {
|
||||
return { status: 'ok', llmProvider: env.llm.provider, ts: new Date().toISOString() };
|
||||
return {
|
||||
status: 'ok',
|
||||
llmProvider: env.llm.provider,
|
||||
embeddingProvider: env.embedding.provider,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DbModule,
|
||||
EmbeddingModule,
|
||||
ScheduleModule.forRoot(),
|
||||
BullModule.forRoot({ connection: { host: env.redis.host, port: env.redis.port } }),
|
||||
GenerationModule,
|
||||
|
||||
@ -15,8 +15,12 @@ export const env = {
|
||||
model: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
|
||||
embeddingModel: process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small',
|
||||
},
|
||||
embedding: {
|
||||
provider: (process.env.EMBEDDING_PROVIDER ?? 'local') as 'mock' | 'local' | 'openai',
|
||||
localModel: process.env.EMBEDDING_LOCAL_MODEL ?? 'Xenova/multilingual-e5-small',
|
||||
},
|
||||
dedup: {
|
||||
cosineThreshold: num(process.env.DEDUP_COSINE_THRESHOLD, 0.92),
|
||||
cosineThreshold: num(process.env.DEDUP_COSINE_THRESHOLD, 0.99),
|
||||
trigramThreshold: num(process.env.DEDUP_TRIGRAM_THRESHOLD, 0.6),
|
||||
candidateLimit: num(process.env.DEDUP_CANDIDATE_LIMIT, 20),
|
||||
},
|
||||
@ -26,5 +30,5 @@ export const env = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const EMBEDDING_DIM = 1536;
|
||||
export const EMBEDDING_DIM = 384;
|
||||
export const PROMPT_VERSION = 'kw-v1';
|
||||
|
||||
19
src/embedding/embedding.module.ts
Normal file
19
src/embedding/embedding.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { env } from '../config/env';
|
||||
import { LocalEmbeddingProvider } from './local.provider';
|
||||
import { MockEmbeddingProvider } from './mock.provider';
|
||||
import { OpenAiEmbeddingProvider } from './openai.provider';
|
||||
import { EmbeddingProvider } from './types';
|
||||
|
||||
const IMPL = {
|
||||
local: LocalEmbeddingProvider,
|
||||
openai: OpenAiEmbeddingProvider,
|
||||
mock: MockEmbeddingProvider,
|
||||
} as const;
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: EmbeddingProvider, useClass: IMPL[env.embedding.provider] }],
|
||||
exports: [EmbeddingProvider],
|
||||
})
|
||||
export class EmbeddingModule {}
|
||||
48
src/embedding/local.provider.ts
Normal file
48
src/embedding/local.provider.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EMBEDDING_DIM, env } from '../config/env';
|
||||
import { EmbedKind, EmbeddingProvider } from './types';
|
||||
|
||||
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
|
||||
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
|
||||
|
||||
/**
|
||||
* 로컬 multilingual-e5-small (384차원, onnxruntime CPU).
|
||||
* 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalEmbeddingProvider extends EmbeddingProvider {
|
||||
readonly name = 'local:multilingual-e5-small';
|
||||
readonly dimensions = EMBEDDING_DIM;
|
||||
private readonly logger = new Logger(LocalEmbeddingProvider.name);
|
||||
private extractor: any | null = null;
|
||||
private loading: Promise<any> | null = null;
|
||||
|
||||
private async pipe() {
|
||||
if (this.extractor) return this.extractor;
|
||||
if (!this.loading) {
|
||||
this.loading = (async () => {
|
||||
const t0 = Date.now();
|
||||
const { pipeline } = await esmImport('@huggingface/transformers');
|
||||
const fe = await pipeline('feature-extraction', env.embedding.localModel);
|
||||
this.logger.log(`model ready: ${env.embedding.localModel} (${Date.now() - t0}ms)`);
|
||||
this.extractor = fe;
|
||||
return fe;
|
||||
})();
|
||||
}
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
async embed(texts: string[], kind: EmbedKind = 'passage'): Promise<number[][]> {
|
||||
if (texts.length === 0) return [];
|
||||
const fe = await this.pipe();
|
||||
const prefixed = texts.map((t) => `${kind}: ${t}`);
|
||||
const out: number[][] = [];
|
||||
const BATCH = 64;
|
||||
for (let i = 0; i < prefixed.length; i += BATCH) {
|
||||
const slice = prefixed.slice(i, i + BATCH);
|
||||
const res = await fe(slice, { pooling: 'mean', normalize: true });
|
||||
out.push(...(res.tolist() as number[][]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
14
src/embedding/mock.provider.ts
Normal file
14
src/embedding/mock.provider.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EMBEDDING_DIM } from '../config/env';
|
||||
import { hashEmbedding } from '../llm/mock.provider';
|
||||
import { EmbedKind, EmbeddingProvider } from './types';
|
||||
|
||||
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */
|
||||
@Injectable()
|
||||
export class MockEmbeddingProvider extends EmbeddingProvider {
|
||||
readonly name = 'mock:bigram-hash';
|
||||
readonly dimensions = EMBEDDING_DIM;
|
||||
async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise<number[][]> {
|
||||
return texts.map((t) => hashEmbedding(t, EMBEDDING_DIM));
|
||||
}
|
||||
}
|
||||
21
src/embedding/openai.provider.ts
Normal file
21
src/embedding/openai.provider.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import OpenAI from 'openai';
|
||||
import { EMBEDDING_DIM, env } from '../config/env';
|
||||
import { EmbedKind, EmbeddingProvider } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class OpenAiEmbeddingProvider extends EmbeddingProvider {
|
||||
readonly name = `openai:${env.llm.embeddingModel}`;
|
||||
readonly dimensions = EMBEDDING_DIM;
|
||||
private readonly client = new OpenAI({ apiKey: env.llm.apiKey });
|
||||
|
||||
async embed(texts: string[], _kind: EmbedKind = 'passage'): Promise<number[][]> {
|
||||
if (texts.length === 0) return [];
|
||||
const res = await this.client.embeddings.create({
|
||||
model: env.llm.embeddingModel,
|
||||
input: texts,
|
||||
dimensions: EMBEDDING_DIM, // 스키마와 차원을 맞춘다
|
||||
});
|
||||
return res.data.map((d) => d.embedding as number[]);
|
||||
}
|
||||
}
|
||||
8
src/embedding/types.ts
Normal file
8
src/embedding/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/** e5 계열은 query 와 passage 를 비대칭으로 인코딩한다 — 검색 품질에 직접 영향. */
|
||||
export type EmbedKind = 'query' | 'passage';
|
||||
|
||||
export abstract class EmbeddingProvider {
|
||||
abstract readonly name: string;
|
||||
abstract readonly dimensions: number;
|
||||
abstract embed(texts: string[], kind?: EmbedKind): Promise<number[][]>;
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { asJson, Sql, toVector } from '../db/db';
|
||||
import { env, PROMPT_VERSION } from '../config/env';
|
||||
import { DedupAction, DedupService } from '../keywords/dedup.service';
|
||||
import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize';
|
||||
import { EmbeddingProvider } from '../embedding/types';
|
||||
import { LlmProvider, MerchantContext } from '../llm/types';
|
||||
import { MerchantsService } from '../merchants/merchants.service';
|
||||
|
||||
@ -35,6 +36,7 @@ export class GenerationService {
|
||||
@Inject(PG) private readonly sql: Sql,
|
||||
private readonly merchants: MerchantsService,
|
||||
private readonly llm: LlmProvider,
|
||||
private readonly embedder: EmbeddingProvider,
|
||||
private readonly dedup: DedupService,
|
||||
) {}
|
||||
|
||||
@ -72,7 +74,7 @@ export class GenerationService {
|
||||
|
||||
// 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다)
|
||||
const texts = output.keywords.map((k) => canonicalizeKeyword(k.keyword));
|
||||
const embeddings = texts.length ? await this.llm.embed(texts) : [];
|
||||
const embeddings = texts.length ? await this.embedder.embed(texts, 'passage') : [];
|
||||
|
||||
const stats: GenerationStats = {
|
||||
runId,
|
||||
@ -193,7 +195,7 @@ export class GenerationService {
|
||||
pairs: Array<{ question: string; answer: string }>,
|
||||
): Promise<number> {
|
||||
if (pairs.length === 0) return 0;
|
||||
const embeddings = await this.llm.embed(pairs.map((p) => p.question));
|
||||
const embeddings = await this.embedder.embed(pairs.map((p) => p.question), 'passage');
|
||||
let created = 0;
|
||||
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
|
||||
@ -61,6 +61,12 @@ export class DedupService {
|
||||
}
|
||||
|
||||
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
|
||||
//
|
||||
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
|
||||
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
|
||||
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
|
||||
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
|
||||
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
|
||||
const candidates = await this.repo.findDedupCandidates(
|
||||
input.embedding,
|
||||
normalized,
|
||||
|
||||
@ -22,9 +22,6 @@ export class MockLlmProvider extends LlmProvider {
|
||||
readonly name = 'mock';
|
||||
readonly model = 'mock-keyword-v1';
|
||||
|
||||
async embed(texts: string[]): Promise<number[][]> {
|
||||
return texts.map((t) => hashEmbedding(t));
|
||||
}
|
||||
|
||||
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
|
||||
const region = ctx.regionName ?? '';
|
||||
|
||||
@ -48,14 +48,6 @@ export class OpenAiLlmProvider extends LlmProvider {
|
||||
private readonly logger = new Logger(OpenAiLlmProvider.name);
|
||||
private readonly client = new OpenAI({ apiKey: env.llm.apiKey });
|
||||
|
||||
async embed(texts: string[]): Promise<number[][]> {
|
||||
if (texts.length === 0) return [];
|
||||
const res = await this.client.embeddings.create({
|
||||
model: env.llm.embeddingModel,
|
||||
input: texts,
|
||||
});
|
||||
return res.data.map((d) => d.embedding as number[]);
|
||||
}
|
||||
|
||||
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
|
||||
const res = await this.client.chat.completions.create({
|
||||
|
||||
@ -43,5 +43,4 @@ export abstract class LlmProvider {
|
||||
abstract readonly name: string;
|
||||
abstract readonly model: string;
|
||||
abstract generate(ctx: MerchantContext): Promise<GenerationOutput>;
|
||||
abstract embed(texts: string[]): Promise<number[][]>;
|
||||
}
|
||||
|
||||
13
src/serving/demo.controller.ts
Normal file
13
src/serving/demo.controller.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Controller, Get, Header } from '@nestjs/common';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */
|
||||
@Controller()
|
||||
export class DemoController {
|
||||
@Get('demo')
|
||||
@Header('content-type', 'text/html; charset=utf-8')
|
||||
demo(): string {
|
||||
return readFileSync(join(process.cwd(), 'public', 'demo.html'), 'utf8');
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,12 @@ export class ServingController {
|
||||
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
|
||||
}
|
||||
|
||||
/** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드 */
|
||||
@Post('match')
|
||||
match(@Body() body: { query: string; limit?: number }) {
|
||||
return this.serving.match(body.query ?? '', Math.min(body.limit ?? 40, 200));
|
||||
}
|
||||
|
||||
/** 성과 피드백 주입 (Search Console / 유입 로그) */
|
||||
@Post('sites/:id/performance')
|
||||
performance(
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KeywordsModule } from '../keywords/keywords.module';
|
||||
import { LlmModule } from '../llm/llm.module';
|
||||
import { MerchantsModule } from '../merchants/merchants.module';
|
||||
import { DemoController } from './demo.controller';
|
||||
import { ServingController } from './serving.controller';
|
||||
import { ServingService } from './serving.service';
|
||||
|
||||
@Module({
|
||||
imports: [MerchantsModule, KeywordsModule, LlmModule],
|
||||
controllers: [ServingController],
|
||||
imports: [MerchantsModule, KeywordsModule],
|
||||
controllers: [ServingController, DemoController],
|
||||
providers: [ServingService],
|
||||
})
|
||||
export class ServingModule {}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PG } from '../db/db.module';
|
||||
import { Sql } from '../db/db';
|
||||
import { Sql, toVector } from '../db/db';
|
||||
import { KeywordRepository } from '../keywords/keyword.repository';
|
||||
import { LlmProvider } from '../llm/types';
|
||||
import { MerchantsService } from '../merchants/merchants.service';
|
||||
import { normalizeKeyword } from '../keywords/normalize';
|
||||
import { EmbeddingProvider } from '../embedding/types';
|
||||
import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service';
|
||||
|
||||
export interface SeoPayload {
|
||||
merchant: { id: string; externalId: string; name: string; siteUrl: string | null };
|
||||
@ -34,7 +35,7 @@ export class ServingService {
|
||||
@Inject(PG) private readonly sql: Sql,
|
||||
private readonly merchants: MerchantsService,
|
||||
private readonly keywords: KeywordRepository,
|
||||
private readonly llm: LlmProvider,
|
||||
private readonly embedder: EmbeddingProvider,
|
||||
) {}
|
||||
|
||||
async seo(idOrExternalId: string, limit: number): Promise<SeoPayload> {
|
||||
@ -95,8 +96,88 @@ export class ServingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다.
|
||||
* 업체명이면 먼저 업체를 해석해 프로필 전체를 질의문으로 쓴다 —
|
||||
* 상호만으로 임베딩하면 브랜드명 하나로 검색하는 것과 같아 매칭이 얕아진다.
|
||||
*/
|
||||
async match(rawQuery: string, limit: number) {
|
||||
const query = rawQuery.trim();
|
||||
const merchant = await this.resolveMerchant(query);
|
||||
|
||||
const queryText = merchant
|
||||
? [
|
||||
merchant.name,
|
||||
merchant.region_name,
|
||||
merchant.industry_name,
|
||||
merchant.description,
|
||||
...toStrings(merchant.profile?.['services']),
|
||||
...toStrings(merchant.profile?.['features']),
|
||||
...toStrings(merchant.profile?.['audiences']),
|
||||
...toStrings(merchant.profile?.['nearby']),
|
||||
].filter(Boolean).join(' ')
|
||||
: query;
|
||||
|
||||
const [embedding] = await this.embedder.embed([queryText], 'query');
|
||||
const rows = await this.sql<Array<{
|
||||
id: string; canonical: string; intent: string; kind: string; category: string | null;
|
||||
aliases: string[]; usage_count: number; score: number; linked: boolean;
|
||||
}>>`
|
||||
SELECT k.id, k.canonical, k.intent, k.kind, k.category, k.aliases, k.usage_count,
|
||||
1 - (k.embedding <=> ${toVector(embedding)}::vector) AS score,
|
||||
(mk.merchant_id IS NOT NULL) AS linked
|
||||
FROM keyword k
|
||||
LEFT JOIN merchant_keyword mk
|
||||
ON mk.keyword_id = k.id AND mk.merchant_id = ${merchant?.id ?? null}
|
||||
WHERE k.embedding IS NOT NULL
|
||||
ORDER BY k.embedding <=> ${toVector(embedding)}::vector
|
||||
LIMIT ${limit}`;
|
||||
|
||||
return {
|
||||
input: query,
|
||||
resolved: merchant
|
||||
? {
|
||||
id: merchant.id, externalId: merchant.external_id, name: merchant.name,
|
||||
region: merchant.region_name, industry: merchant.industry_name,
|
||||
description: merchant.description, siteUrl: merchant.site_url,
|
||||
profile: merchant.profile ?? {},
|
||||
}
|
||||
: null,
|
||||
queryText,
|
||||
embeddingProvider: this.embedder.name,
|
||||
total: await this.dictionarySize(),
|
||||
matches: rows.map((r) => ({ ...r, score: Number(r.score) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 상호는 띄어쓰기가 흔들린다 — 정규화 일치 → trigram 순으로 해석 */
|
||||
private async resolveMerchant(query: string) {
|
||||
const norm = normalizeKeyword(query);
|
||||
if (!norm) return null;
|
||||
const rows = await this.sql<Array<MerchantWithTaxonomy & { sim: number }>>`
|
||||
SELECT m.*,
|
||||
i.name AS industry_name, i.path::text AS industry_path,
|
||||
r.name AS region_name, r.path::text AS region_path,
|
||||
similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim
|
||||
FROM merchant m
|
||||
LEFT JOIN industry i ON i.id = m.industry_id
|
||||
LEFT JOIN region r ON r.id = m.region_id
|
||||
WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm}
|
||||
OR m.external_id = ${query}
|
||||
OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45
|
||||
ORDER BY sim DESC NULLS LAST
|
||||
LIMIT 1`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async dictionarySize() {
|
||||
const [row] = await this.sql<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`;
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
async searchKeywords(query: string, limit: number) {
|
||||
const [embedding] = await this.llm.embed([query]);
|
||||
const [embedding] = await this.embedder.embed([query], 'query');
|
||||
return this.keywords.searchByVector(embedding, 'ko-KR', limit);
|
||||
}
|
||||
|
||||
@ -136,6 +217,10 @@ export class ServingService {
|
||||
}
|
||||
}
|
||||
|
||||
function toStrings(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
}
|
||||
|
||||
function buildDescription(name: string, desc: string, keywords: string[]): string {
|
||||
const base = desc?.trim() || `${name} 안내`;
|
||||
const tail = keywords.slice(0, 3).join(', ');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user