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;
}
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论