o2o-site-ontology/src/llm/mock.provider.ts
hbyang edde23f15e 고정 데이터셋 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>
2026-09-09 15:11:45 +09:00

161 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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