정책 변경: 주기 수집 없이 고정 데이터셋을 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>
161 lines
6.2 KiB
TypeScript
161 lines
6.2 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import { EMBEDDING_DIM } from '../config/env';
|
||
import { normalizeKeyword } from '../keywords/normalize';
|
||
import {
|
||
GenerationOutput,
|
||
KeywordCandidate,
|
||
KeywordIntent,
|
||
LlmProvider,
|
||
MerchantContext,
|
||
QaCandidate,
|
||
} from './types';
|
||
|
||
/**
|
||
* API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현.
|
||
*
|
||
* embed(): 문자 bigram 해싱 + L2 정규화.
|
||
* 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로
|
||
* 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다.
|
||
*/
|
||
@Injectable()
|
||
export class MockLlmProvider extends LlmProvider {
|
||
readonly name = 'mock';
|
||
readonly model = 'mock-keyword-v1';
|
||
|
||
|
||
async generate(ctx: MerchantContext): Promise<GenerationOutput> {
|
||
const region = ctx.regionName ?? '';
|
||
const industry = ctx.industryName ?? '업체';
|
||
const p = ctx.profile;
|
||
const services = toStringArray(p['services']);
|
||
const features = toStringArray(p['features']);
|
||
const audiences = toStringArray(p['audiences']);
|
||
const nearby = toStringArray(p['nearby']);
|
||
const seasons = toStringArray(p['seasons']);
|
||
|
||
const MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약'];
|
||
const out: Array<[string, KeywordIntent, number]> = [];
|
||
const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]);
|
||
|
||
// 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다.
|
||
push(ctx.name, 'brand', 0.99);
|
||
push(`${region} ${ctx.name}`, 'brand', 0.97);
|
||
push(`${region} ${industry}`, 'local', 0.95);
|
||
push(`${region} ${industry} 추천`, 'local', 0.93);
|
||
|
||
for (const m of MODIFIERS) {
|
||
push(`${region} ${industry} ${m}`, intentOf(m), 0.86);
|
||
}
|
||
for (const a of audiences) {
|
||
push(`${region} ${a} ${industry}`, 'local', 0.88);
|
||
for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74);
|
||
push(`${a} ${industry} 추천`, 'informational', 0.62);
|
||
}
|
||
for (const f of features) {
|
||
push(`${region} ${f} ${industry}`, 'local', 0.84);
|
||
push(`${industry} ${f}`, 'informational', 0.6);
|
||
push(`${region} ${industry} ${f}`, 'local', 0.7);
|
||
}
|
||
for (const s of services) {
|
||
push(`${region} ${s}`, 'local', 0.82);
|
||
for (const m of MODIFIERS.slice(0, 4)) push(`${s} ${m}`, intentOf(m), 0.66);
|
||
}
|
||
for (const n of nearby) {
|
||
push(`${n} 근처 ${industry}`, 'local', 0.8);
|
||
push(`${n} ${industry} 추천`, 'local', 0.76);
|
||
push(`${n} 숙소`, 'local', 0.68);
|
||
}
|
||
for (const s of seasons) {
|
||
push(`${s} ${region} ${industry}`, 'local', 0.72);
|
||
push(`${region} ${s} ${industry} 예약`, 'transactional', 0.64);
|
||
}
|
||
// 동반자 × 시설 롱테일 — 여기서부터 검색량이 급격히 얇아진다
|
||
for (const a of audiences) {
|
||
for (const f of features) push(`${region} ${a} ${f} ${industry}`, 'local', 0.42);
|
||
}
|
||
for (const a of audiences) {
|
||
for (const s of services) push(`${a} ${s}`, 'informational', 0.38);
|
||
}
|
||
// 질문형 (AEO 유입)
|
||
for (const a of audiences) push(`${region} ${a} ${industry} 어디가 좋을까요`, 'informational', 0.5);
|
||
for (const n of nearby) push(`${n} 여행 ${industry} 어디`, 'informational', 0.44);
|
||
|
||
const seen = new Set<string>();
|
||
const keywords: KeywordCandidate[] = [];
|
||
for (const [raw, intent, relevance] of out) {
|
||
const k = raw.replace(/\s+/g, ' ').trim();
|
||
if (!k || seen.has(k)) continue;
|
||
seen.add(k);
|
||
keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` });
|
||
if (keywords.length >= ctx.targetCount) break;
|
||
}
|
||
|
||
const qaPairs: QaCandidate[] = [
|
||
{
|
||
question: `${ctx.name}은(는) 어디에 있나요?`,
|
||
answer: `${ctx.name}은(는) ${region || '해당 지역'}에 위치한 ${industry}입니다.`,
|
||
},
|
||
{
|
||
question: `${ctx.name} 예약은 어떻게 하나요?`,
|
||
answer: `${ctx.name}은(는) 사이트 예약 페이지 또는 전화로 예약할 수 있습니다.`,
|
||
},
|
||
{
|
||
question: `${ctx.name}의 주요 서비스는 무엇인가요?`,
|
||
answer: services.length
|
||
? `주요 서비스는 ${services.join(', ')} 입니다.`
|
||
: `${industry} 관련 서비스를 제공합니다.`,
|
||
},
|
||
{
|
||
question: `${ctx.name} 근처에 가볼 만한 곳은 어디인가요?`,
|
||
answer: nearby.length
|
||
? `${nearby.join(', ')} 등이 가깝습니다.`
|
||
: `${region} 주요 명소가 인근에 있습니다.`,
|
||
},
|
||
{
|
||
question: `${ctx.name}에 ${audiences[0] ?? '반려동물'}도 갈 수 있나요?`,
|
||
answer: features.length
|
||
? `${features.join(', ')} 조건을 제공합니다. 예약 전 상세 조건을 확인해 주세요.`
|
||
: `예약 전 상세 조건을 확인해 주세요.`,
|
||
},
|
||
];
|
||
|
||
return { keywords, qaPairs, model: this.model, provider: this.name };
|
||
}
|
||
}
|
||
|
||
function intentOf(modifier: string): KeywordIntent {
|
||
if (modifier === '예약' || modifier === '실시간예약' || modifier === '가격') return 'transactional';
|
||
if (modifier === '후기' || modifier === '순위') return 'informational';
|
||
return 'local';
|
||
}
|
||
|
||
function toStringArray(v: unknown): string[] {
|
||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||
}
|
||
|
||
/** 문자 bigram 해싱 임베딩 (결정적, L2 정규화) */
|
||
export function hashEmbedding(text: string, dim = EMBEDDING_DIM): number[] {
|
||
const s = ` ${normalizeKeyword(text)} `;
|
||
const vec = new Float64Array(dim);
|
||
for (let i = 0; i < s.length - 1; i++) {
|
||
const gram = s.slice(i, i + 2);
|
||
const h = fnv1a(gram);
|
||
vec[h % dim] += 1;
|
||
// 부호 해싱으로 충돌 편향 완화
|
||
vec[(h >>> 8) % dim] += h & 1 ? 1 : -1;
|
||
}
|
||
let norm = 0;
|
||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||
norm = Math.sqrt(norm) || 1;
|
||
return Array.from(vec, (x) => x / norm);
|
||
}
|
||
|
||
function fnv1a(str: string): number {
|
||
let h = 0x811c9dc5;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h ^= str.charCodeAt(i);
|
||
h = Math.imul(h, 0x01000193) >>> 0;
|
||
}
|
||
return h >>> 0;
|
||
}
|