It scores keywords, synonyms, and fuzzy matches in the user query to select the most relevant FAQ that meets the minimum score.
| /** | |
| * ───────────────────────────────────────────────────────────────────────────── | |
| * HOW IT WORKS | |
| * ───────────────────────────────────────────────────────────────────────────── | |
| * Each FAQ has a list of weighted keywords (each may have synonyms). For a | |
| * query, every keyword gets a score in [0, 1]. The FAQ score blends: | |
| * • the weight-averaged mean of all its keyword scores, and | |
| * • its single best (weight-adjusted) keyword score, | |
| * so an FAQ with many tags isn't punished when the user only mentions one. | |
| * The highest-scoring FAQ wins if it reaches MIN_MATCH_SCORE. | |
| * | |
| * Per-term scoring (a term is a keyword or one of its synonyms): | |
| * 1. Exact: the term appears in the query → 1.0 | |
| * (substring match for terms ≥ 4 chars, which also covers Turkish | |
| * suffixes like "kargo" → "kargoyu"; whole-word for shorter terms) | |
| * 2. Fuzzy: typo tolerance via Levenshtein similarity — single words are | |
| * compared to each query word; multi-word terms to every n-gram | |
| * window of the query (+0.18 phrase boost). Similarities below | |
| * FUZZY_FLOOR, and words shorter than MIN_FUZZY_LENGTH, count as 0. | |
| * 3. A synonym hit counts as SYNONYM_DISCOUNT (0.92) × its own term score. | |
| * | |
| * Text is normalized first: lowercased, Turkish dotted/dotless i folded to "i", | |
| * and diacritics stripped (NFD), so "İstanbul", "ıstanbul" and "istanbul" match. | |
| * | |
| * Keyword shorthand (string form): "text | weight ~ synonym1; synonym2" | |
| * e.g. "shipping | 2 ~ delivery; cargo" | |
| */ | |
| // ─── Types ─────────────────────────────────────────────────────────────────── | |
| export interface FAQKeyword { | |
| text: string; | |
| weight?: number; | |
| synonyms?: string[]; | |
| } | |
| export type FAQKeywordEntry = string | FAQKeyword; | |
| export interface FAQItem { | |
| id: string; | |
| answer: string; | |
| keywords: FAQKeywordEntry[]; | |
| } | |
| // ─── Tunables ──────────────────────────────────────────────────────────────── | |
| const DEFAULT_KEYWORD_WEIGHT = 1; | |
| const PHRASE_BOOST = 0.18; | |
| const SYNONYM_DISCOUNT = 0.92; | |
| const FUZZY_FLOOR = 0.7; | |
| const MIN_FUZZY_LENGTH = 4; | |
| const BEST_KEYWORD_BLEND = 0.4; | |
| export const MIN_MATCH_SCORE = 0.3; | |
| // ─── Text normalization ────────────────────────────────────────────────────── | |
| export function normalizeText(text: string): string { | |
| return text | |
| .toLowerCase() | |
| .replace(/\u0131/g, "i") // ı → i | |
| .replace(/\u0130/g, "i") // İ → i | |
| .normalize("NFD") | |
| .replace(/[\u0300-\u036f]/g, "") // strip diacritics | |
| .trim(); | |
| } | |
| export function tokenize(text: string): string[] { | |
| return normalizeText(text) | |
| .split(/[^\p{L}\p{N}]+/u) | |
| .filter(Boolean); | |
| } | |
| // ─── Keyword parsing ───────────────────────────────────────────────────────── | |
| function parseWeight(raw: string | undefined): number { | |
| if (!raw) return DEFAULT_KEYWORD_WEIGHT; | |
| const weight = Number(raw); | |
| return Number.isFinite(weight) ? weight : DEFAULT_KEYWORD_WEIGHT; | |
| } | |
| export function parseKeywordEntry(entry: FAQKeywordEntry): FAQKeyword { | |
| if (typeof entry === "string") { | |
| const [mainPart, metadata] = entry | |
| .trim() | |
| .split("~", 2) | |
| .map((part) => part.trim()); | |
| const [keywordText, weightPart] = mainPart.split("|", 2).map((part) => part.trim()); | |
| return { | |
| text: keywordText, | |
| weight: parseWeight(weightPart), | |
| synonyms: metadata | |
| ? metadata | |
| .split(";") | |
| .map((synonym) => synonym.trim()) | |
| .filter(Boolean) | |
| : [], | |
| }; | |
| } | |
| return { | |
| text: entry.text, | |
| weight: entry.weight ?? DEFAULT_KEYWORD_WEIGHT, | |
| synonyms: entry.synonyms ?? [], | |
| }; | |
| } | |
| // ─── Fuzzy matching (Levenshtein) ──────────────────────────────────────────── | |
| function levenshteinDistance(a: string, b: string): number { | |
| if (a.length < b.length) [a, b] = [b, a]; // keep the row short | |
| let prev = Array.from({ length: b.length + 1 }, (_, j) => j); | |
| for (let i = 1; i <= a.length; i += 1) { | |
| const curr = [i]; | |
| for (let j = 1; j <= b.length; j += 1) { | |
| const cost = a[i - 1] === b[j - 1] ? 0 : 1; | |
| curr[j] = Math.min( | |
| prev[j] + 1, // deletion | |
| curr[j - 1] + 1, // insertion | |
| prev[j - 1] + cost, // substitution | |
| ); | |
| } | |
| prev = curr; | |
| } | |
| return prev[b.length]; | |
| } | |
| /** Similarity in [0, 1]: 1 = identical, 0 = completely different. */ | |
| function fuzzyScore(source: string, target: string): number { | |
| if (source.length === 0 || target.length === 0) return 0; | |
| const distance = levenshteinDistance(source, target); | |
| return Math.max(0, 1 - distance / Math.max(source.length, target.length)); | |
| } | |
| // ─── Term / keyword scoring ────────────────────────────────────────────────── | |
| /** Does `queryPhrase` contain `phrase`? Short phrases must match whole words. */ | |
| function containsPhrase(queryPhrase: string, phrase: string): boolean { | |
| return phrase.length >= MIN_FUZZY_LENGTH ? queryPhrase.includes(phrase) : ` ${queryPhrase} `.includes(` ${phrase} `); | |
| } | |
| /** Score in [0, 1] for one term (keyword text or synonym) against the query. */ | |
| function scoreTerm(queryTokens: string[], term: string): number { | |
| const termTokens = tokenize(term); | |
| if (termTokens.length === 0) return 0; | |
| const phrase = termTokens.join(" "); | |
| if (containsPhrase(queryTokens.join(" "), phrase)) return 1; | |
| let best = 0; | |
| if (termTokens.length === 1) { | |
| // Single word: best fuzzy match against any (non-tiny) query word. | |
| if (phrase.length < MIN_FUZZY_LENGTH) return 0; | |
| for (const token of queryTokens) { | |
| if (token.length >= MIN_FUZZY_LENGTH) { | |
| best = Math.max(best, fuzzyScore(phrase, token)); | |
| } | |
| } | |
| return best >= FUZZY_FLOOR ? best : 0; | |
| } | |
| // Multi-word: compare against every n-gram window of the query. | |
| for (let i = 0; i <= queryTokens.length - termTokens.length; i += 1) { | |
| const window = queryTokens.slice(i, i + termTokens.length).join(" "); | |
| best = Math.max(best, fuzzyScore(phrase, window)); | |
| } | |
| return best >= FUZZY_FLOOR ? Math.min(best + PHRASE_BOOST, 0.98) : 0; | |
| } | |
| function scoreKeyword(queryTokens: string[], keyword: FAQKeyword): number { | |
| let score = scoreTerm(queryTokens, keyword.text); | |
| for (const synonym of keyword.synonyms ?? []) { | |
| score = Math.max(score, SYNONYM_DISCOUNT * scoreTerm(queryTokens, synonym)); | |
| } | |
| return score; | |
| } | |
| // ─── Public API ────────────────────────────────────────────────────────────── | |
| /** Similarity of the query to one FAQ's keyword list, in [0, 1]. */ | |
| export function calculateSimilarity(text: string, keywords: FAQKeywordEntry[]): number { | |
| if (!keywords || keywords.length === 0) return 0; | |
| const queryTokens = tokenize(text); | |
| if (queryTokens.length === 0) return 0; | |
| const parsed = keywords | |
| .map(parseKeywordEntry) | |
| .map((kw) => ({ kw, weight: kw.weight ?? DEFAULT_KEYWORD_WEIGHT })) | |
| .filter(({ weight }) => weight > 0); | |
| if (parsed.length === 0) return 0; | |
| const maxWeight = Math.max(...parsed.map(({ weight }) => weight)); | |
| let totalWeight = 0; | |
| let weightedScore = 0; | |
| let bestScore = 0; | |
| for (const { kw, weight } of parsed) { | |
| const score = scoreKeyword(queryTokens, kw); | |
| totalWeight += weight; | |
| weightedScore += score * weight; | |
| bestScore = Math.max(bestScore, score * (weight / maxWeight)); | |
| } | |
| const average = weightedScore / totalWeight; | |
| return (1 - BEST_KEYWORD_BLEND) * average + BEST_KEYWORD_BLEND * bestScore; | |
| } | |
| /** Returns the highest-scoring FAQ and its score (even if below the threshold). */ | |
| export function findBestMatchWithScore(query: string, faqs: FAQItem[]): { faq: FAQItem | null; score: number } { | |
| let bestMatch: FAQItem | null = null; | |
| let bestScore = 0; | |
| for (const faq of faqs) { | |
| const score = calculateSimilarity(query, faq.keywords); | |
| if (score > bestScore) { | |
| bestScore = score; | |
| bestMatch = faq; | |
| } | |
| } | |
| return { faq: bestMatch, score: bestScore }; | |
| } | |
| /** Returns the best FAQ only if it reaches `minScore`, otherwise null. */ | |
| export function findBestMatch(query: string, faqs: FAQItem[], minScore: number = MIN_MATCH_SCORE): FAQItem | null { | |
| const { faq, score } = findBestMatchWithScore(query, faqs); | |
| return score >= minScore ? faq : null; | |
| } |
评论
?
参与讨论