고정 데이터셋 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:
hbyang 2026-09-09 15:11:45 +09:00
parent ef7d51da2c
commit edde23f15e
27 changed files with 8775 additions and 33 deletions

View File

@ -8,6 +8,13 @@ DATABASE_URL=postgres://ontology:ontology@localhost:55432/ontology
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=56379 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 --- # --- LLM ---
# mock : API 키 없이 로컬에서 전체 파이프라인 동작 (기본값) # mock : API 키 없이 로컬에서 전체 파이프라인 동작 (기본값)
# openai : 실제 OpenAI 호출 # openai : 실제 OpenAI 호출
@ -17,8 +24,9 @@ OPENAI_MODEL=gpt-4.1-mini
OPENAI_EMBEDDING_MODEL=text-embedding-3-small OPENAI_EMBEDDING_MODEL=text-embedding-3-small
# --- 생성/중복제거 튜닝 --- # --- 생성/중복제거 튜닝 ---
# 코사인 유사도가 이 값 이상이면 기존 키워드에 alias 로 흡수 # 코사인 자동 병합 임계값. 짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본으로 나오므로
DEDUP_COSINE_THRESHOLD=0.92 # 0.92 는 오병합을 부른다. 실측상 어순 변형만 0.999 대에 모이므로 0.99 로 둔다.
DEDUP_COSINE_THRESHOLD=0.99
# trigram 유사도 사전 필터 # trigram 유사도 사전 필터
DEDUP_TRIGRAM_THRESHOLD=0.6 DEDUP_TRIGRAM_THRESHOLD=0.6
# 벡터 비교 대상 상위 후보 수 # 벡터 비교 대상 상위 후보 수

View File

@ -2,9 +2,10 @@
o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스. o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스.
- 주기적으로 LLM 에게 업체 정보를 주고 키워드·태그·Q&A 를 생성 - **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건)
- **4단계 계단식 중복제거**로 전역 키워드 사전을 오염 없이 유지 - 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색
- 발행 사이트는 REST 로 SEO/AEO payload 만 받아 쓴다 (JSON-LD 조립은 후속 단계) - 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔**
- 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만
## 빠른 시작 (로컬) ## 빠른 시작 (로컬)
@ -14,10 +15,15 @@ cp .env.example .env # 기본값은 LLM_PROVIDER=mock — API 키 불
npm run db:up # postgres(pgvector) + redis npm run db:up # postgres(pgvector) + redis
npm run db:migrate npm run db:migrate
npm run db:seed # 업종/지역 계층 + 데모 업체 3곳 npm run db:seed # 업종/지역 계층 + 데모 업체 3곳
npm run dataset:build # 1,000건 데이터셋 생성 → data/
npm run dataset:ingest # 임베딩 + pgvector 적재 (최초 1회 모델 다운로드)
npm start # http://localhost:3100 npm start # http://localhost:3100
npm run smoke # (다른 터미널) 엔드투엔드 점검
``` ```
브라우저에서 **http://localhost:3100/demo** 를 열면 매칭 콘솔이 뜬다.
입력창에 `스테이 머뭄` 을 넣으면 (띄어쓰기가 달라도) 업체를 해석하고
적재된 971건 사전에서 잘 맞는 키워드를 순위대로 보여준다.
`npm run db:reset` 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다. `npm run db:reset` 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다.
### 실제 OpenAI 로 전환 ### 실제 OpenAI 로 전환
@ -48,6 +54,52 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만 키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만
저장되고, `강남 미용실` 을 쓰는 업체가 100곳이어도 중복제거가 성립한다. 저장되고, `강남 미용실` 을 쓰는 업체가 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단계 ## 중복제거 4단계
값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다. 값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다.
@ -75,7 +127,9 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 | | `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags | | `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints | | `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드** (데모 콘솔이 쓰는 API) |
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) | | `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 | | `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
`:id``external_id` 또는 내부 UUID 둘 다 받는다. `: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 창) - **발행 즉시**`/v1/merchants/publish` 가 BullMQ 에 적재 (60초 dedupe 창)

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View 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

File diff suppressed because it is too large Load Diff

View File

@ -13,9 +13,12 @@
"db:migrate": "tsx src/db/migrate.ts", "db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.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", "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": { "dependencies": {
"@huggingface/transformers": "^4.2.0",
"@nestjs/bullmq": "^11.0.2", "@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.12", "@nestjs/common": "^11.0.12",
"@nestjs/core": "^11.0.12", "@nestjs/core": "^11.0.12",

235
public/demo.html Normal file
View 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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
health(); run();
</script>
</body>
</html>

176
scripts/build-dataset.mjs Normal file
View 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
View 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); });

View File

@ -3,6 +3,7 @@ import { Controller, Get, Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { env } from './config/env'; import { env } from './config/env';
import { DbModule } from './db/db.module'; import { DbModule } from './db/db.module';
import { EmbeddingModule } from './embedding/embedding.module';
import { GenerationModule } from './generation/generation.module'; import { GenerationModule } from './generation/generation.module';
import { MerchantsHttpModule } from './merchants/merchants.controller.module'; import { MerchantsHttpModule } from './merchants/merchants.controller.module';
import { ServingModule } from './serving/serving.module'; import { ServingModule } from './serving/serving.module';
@ -11,13 +12,19 @@ import { ServingModule } from './serving/serving.module';
class HealthController { class HealthController {
@Get('health') @Get('health')
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({ @Module({
imports: [ imports: [
DbModule, DbModule,
EmbeddingModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
BullModule.forRoot({ connection: { host: env.redis.host, port: env.redis.port } }), BullModule.forRoot({ connection: { host: env.redis.host, port: env.redis.port } }),
GenerationModule, GenerationModule,

View File

@ -15,8 +15,12 @@ export const env = {
model: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini', model: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
embeddingModel: process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small', 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: { 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), trigramThreshold: num(process.env.DEDUP_TRIGRAM_THRESHOLD, 0.6),
candidateLimit: num(process.env.DEDUP_CANDIDATE_LIMIT, 20), candidateLimit: num(process.env.DEDUP_CANDIDATE_LIMIT, 20),
}, },
@ -26,5 +30,5 @@ export const env = {
}, },
} as const; } as const;
export const EMBEDDING_DIM = 1536; export const EMBEDDING_DIM = 384;
export const PROMPT_VERSION = 'kw-v1'; export const PROMPT_VERSION = 'kw-v1';

View 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 {}

View 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;
}
}

View 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));
}
}

View 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
View 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[][]>;
}

View File

@ -4,6 +4,7 @@ import { asJson, Sql, toVector } from '../db/db';
import { env, PROMPT_VERSION } from '../config/env'; import { env, PROMPT_VERSION } from '../config/env';
import { DedupAction, DedupService } from '../keywords/dedup.service'; import { DedupAction, DedupService } from '../keywords/dedup.service';
import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize'; import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize';
import { EmbeddingProvider } from '../embedding/types';
import { LlmProvider, MerchantContext } from '../llm/types'; import { LlmProvider, MerchantContext } from '../llm/types';
import { MerchantsService } from '../merchants/merchants.service'; import { MerchantsService } from '../merchants/merchants.service';
@ -35,6 +36,7 @@ export class GenerationService {
@Inject(PG) private readonly sql: Sql, @Inject(PG) private readonly sql: Sql,
private readonly merchants: MerchantsService, private readonly merchants: MerchantsService,
private readonly llm: LlmProvider, private readonly llm: LlmProvider,
private readonly embedder: EmbeddingProvider,
private readonly dedup: DedupService, private readonly dedup: DedupService,
) {} ) {}
@ -72,7 +74,7 @@ export class GenerationService {
// 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다) // 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다)
const texts = output.keywords.map((k) => canonicalizeKeyword(k.keyword)); 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 = { const stats: GenerationStats = {
runId, runId,
@ -193,7 +195,7 @@ export class GenerationService {
pairs: Array<{ question: string; answer: string }>, pairs: Array<{ question: string; answer: string }>,
): Promise<number> { ): Promise<number> {
if (pairs.length === 0) return 0; 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; let created = 0;
for (let i = 0; i < pairs.length; i++) { for (let i = 0; i < pairs.length; i++) {

View File

@ -61,6 +61,12 @@ export class DedupService {
} }
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정 // 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
//
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
const candidates = await this.repo.findDedupCandidates( const candidates = await this.repo.findDedupCandidates(
input.embedding, input.embedding,
normalized, normalized,

View File

@ -22,9 +22,6 @@ export class MockLlmProvider extends LlmProvider {
readonly name = 'mock'; readonly name = 'mock';
readonly model = 'mock-keyword-v1'; readonly model = 'mock-keyword-v1';
async embed(texts: string[]): Promise<number[][]> {
return texts.map((t) => hashEmbedding(t));
}
async generate(ctx: MerchantContext): Promise<GenerationOutput> { async generate(ctx: MerchantContext): Promise<GenerationOutput> {
const region = ctx.regionName ?? ''; const region = ctx.regionName ?? '';

View File

@ -48,14 +48,6 @@ export class OpenAiLlmProvider extends LlmProvider {
private readonly logger = new Logger(OpenAiLlmProvider.name); private readonly logger = new Logger(OpenAiLlmProvider.name);
private readonly client = new OpenAI({ apiKey: env.llm.apiKey }); 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> { async generate(ctx: MerchantContext): Promise<GenerationOutput> {
const res = await this.client.chat.completions.create({ const res = await this.client.chat.completions.create({

View File

@ -43,5 +43,4 @@ export abstract class LlmProvider {
abstract readonly name: string; abstract readonly name: string;
abstract readonly model: string; abstract readonly model: string;
abstract generate(ctx: MerchantContext): Promise<GenerationOutput>; abstract generate(ctx: MerchantContext): Promise<GenerationOutput>;
abstract embed(texts: string[]): Promise<number[][]>;
} }

View 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');
}
}

View File

@ -23,6 +23,12 @@ export class ServingController {
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50)); 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 / 유입 로그) */ /** 성과 피드백 주입 (Search Console / 유입 로그) */
@Post('sites/:id/performance') @Post('sites/:id/performance')
performance( performance(

View File

@ -1,13 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { KeywordsModule } from '../keywords/keywords.module'; import { KeywordsModule } from '../keywords/keywords.module';
import { LlmModule } from '../llm/llm.module';
import { MerchantsModule } from '../merchants/merchants.module'; import { MerchantsModule } from '../merchants/merchants.module';
import { DemoController } from './demo.controller';
import { ServingController } from './serving.controller'; import { ServingController } from './serving.controller';
import { ServingService } from './serving.service'; import { ServingService } from './serving.service';
@Module({ @Module({
imports: [MerchantsModule, KeywordsModule, LlmModule], imports: [MerchantsModule, KeywordsModule],
controllers: [ServingController], controllers: [ServingController, DemoController],
providers: [ServingService], providers: [ServingService],
}) })
export class ServingModule {} export class ServingModule {}

View File

@ -1,9 +1,10 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { PG } from '../db/db.module'; import { PG } from '../db/db.module';
import { Sql } from '../db/db'; import { Sql, toVector } from '../db/db';
import { KeywordRepository } from '../keywords/keyword.repository'; import { KeywordRepository } from '../keywords/keyword.repository';
import { LlmProvider } from '../llm/types'; import { normalizeKeyword } from '../keywords/normalize';
import { MerchantsService } from '../merchants/merchants.service'; import { EmbeddingProvider } from '../embedding/types';
import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service';
export interface SeoPayload { export interface SeoPayload {
merchant: { id: string; externalId: string; name: string; siteUrl: string | null }; merchant: { id: string; externalId: string; name: string; siteUrl: string | null };
@ -34,7 +35,7 @@ export class ServingService {
@Inject(PG) private readonly sql: Sql, @Inject(PG) private readonly sql: Sql,
private readonly merchants: MerchantsService, private readonly merchants: MerchantsService,
private readonly keywords: KeywordRepository, private readonly keywords: KeywordRepository,
private readonly llm: LlmProvider, private readonly embedder: EmbeddingProvider,
) {} ) {}
async seo(idOrExternalId: string, limit: number): Promise<SeoPayload> { 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) { 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); 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 { function buildDescription(name: string, desc: string, keywords: string[]): string {
const base = desc?.trim() || `${name} 안내`; const base = desc?.trim() || `${name} 안내`;
const tail = keywords.slice(0, 3).join(', '); const tail = keywords.slice(0, 3).join(', ');