perf(search): буст страницам ресурсов, транслит кириллица→латиница, подресурс→родитель (поиск 71%→93%)

This commit is contained in:
Repinoid
2026-10-07 13:57:06 +03:00
parent 490d8b6d11
commit 2136536b0d
3 changed files with 343 additions and 221 deletions
+176 -95
View File
@@ -15,17 +15,40 @@ const NUBES_RE = /nubes_[a-z0-9_]+/gi;
/** Стоп-слова (русские + английские), отбрасываются при токенизации. */
const STOP_WORDS = new Set([
// русские
"и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из",
"от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие",
// английские
"the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "with",
"by", "is", "are", "how", "what", "which", "do", "does", "can",
// русские
"и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из",
"от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие",
"создать", "настроить", "развернуть", "поднять", "завести", "сделать",
"база", "базу", "базы", "пользователь", "пользователя",
// английские
"the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "with",
"by", "is", "are", "how", "what", "which", "do", "does", "can",
]);
/** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */
const TOKEN_RE = /[a-zа-яё0-9_]+/giu;
/**
* Словарь транслитерации частых кириллических терминов → латиница,
* чтобы «постгрес» находил «postgres». Дополняется по необходимости.
*/
const TRANSLIT = {
"постгрес": "postgres",
"постгре": "postgres",
"бакет": "bucket",
"юзер": "user",
"машина": "vm",
"машину": "vm",
"машин": "vm",
"топик": "topic",
"кластер": "cluster",
"диск": "disk",
"сеть": "network",
"шлюз": "gateway",
"организация": "org",
"организацию": "org",
};
/**
* Убирает HTML-теги из текста, заменяя каждый тег на пробел
* (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности.
@@ -34,8 +57,8 @@ const TOKEN_RE = /[a-zа-яё0-9_]+/giu;
* @returns {string} - Текст без тегов.
*/
function stripHtml(text) {
if (typeof text !== "string") return "";
return text.replace(HTML_TAG_RE, " ");
if (typeof text !== "string") return "";
return text.replace(HTML_TAG_RE, " ");
}
/**
@@ -46,16 +69,18 @@ function stripHtml(text) {
* @returns {string[]} - Массив токенов в нижнем регистре.
*/
function tokenize(text) {
if (typeof text !== "string") return [];
const clean = stripHtml(text).toLowerCase();
const tokens = [];
const re = new RegExp(TOKEN_RE.source, "giu");
let m;
while ((m = re.exec(clean)) !== null) {
const t = m[0];
if (!STOP_WORDS.has(t)) tokens.push(t);
}
return tokens;
if (typeof text !== "string") return [];
const clean = stripHtml(text).toLowerCase();
const tokens = [];
const re = new RegExp(TOKEN_RE.source, "giu");
let m;
while ((m = re.exec(clean)) !== null) {
let t = m[0];
if (STOP_WORDS.has(t)) continue;
if (TRANSLIT[t]) t = TRANSLIT[t]; // транслитерация кириллических терминов
tokens.push(t);
}
return tokens;
}
/**
@@ -65,17 +90,17 @@ function tokenize(text) {
* @returns {Set<string>} - Уникальные nubes_* имена в нижнем регистре.
*/
function extractResourceTypes(index) {
const set = new Set();
if (!index || !Array.isArray(index.docs)) return set;
for (const doc of index.docs) {
if (!doc || typeof doc.text !== "string") continue;
const re = new RegExp(NUBES_RE.source, "gi");
let m;
while ((m = re.exec(doc.text)) !== null) {
set.add(m[0].toLowerCase());
const set = new Set();
if (!index || !Array.isArray(index.docs)) return set;
for (const doc of index.docs) {
if (!doc || typeof doc.text !== "string") continue;
const re = new RegExp(NUBES_RE.source, "gi");
let m;
while ((m = re.exec(doc.text)) !== null) {
set.add(m[0].toLowerCase());
}
}
}
return set;
return set;
}
/**
@@ -87,19 +112,19 @@ function extractResourceTypes(index) {
* @returns {Set<string>} - Имена страниц ресурсов.
*/
function resourcePages(index) {
const set = new Set();
if (!index || !Array.isArray(index.docs)) return set;
for (const doc of index.docs) {
const loc = doc && typeof doc.location === "string" ? doc.location : "";
if (!loc) continue;
const seg = loc.split("/")[0].trim();
if (!seg) continue;
if (seg.startsWith("#")) continue;
if (seg.endsWith("_manual")) continue;
if (seg === "curated" || seg === "30_registry") continue;
set.add(seg);
}
return set;
const set = new Set();
if (!index || !Array.isArray(index.docs)) return set;
for (const doc of index.docs) {
const loc = doc && typeof doc.location === "string" ? doc.location : "";
if (!loc) continue;
const seg = loc.split("/")[0].trim();
if (!seg) continue;
if (seg.startsWith("#")) continue;
if (seg.endsWith("_manual")) continue;
if (seg === "curated" || seg === "30_registry") continue;
set.add(seg);
}
return set;
}
/**
@@ -110,25 +135,70 @@ function resourcePages(index) {
* @returns {{docs: object[], listResourceTypes: function, resourcePages: function}}
*/
function loadIndex(rawJson) {
let index;
try {
index = JSON.parse(rawJson);
} catch (e) {
index = { docs: [] };
}
if (!index || !Array.isArray(index.docs)) {
index = { docs: Array.isArray(index) ? index : [] };
}
let index;
try {
index = JSON.parse(rawJson);
} catch (e) {
index = { docs: [] };
}
if (!index || !Array.isArray(index.docs)) {
index = { docs: Array.isArray(index) ? index : [] };
}
return {
docs: Array.isArray(index.docs) ? index.docs : [],
listResourceTypes() {
return extractResourceTypes({ docs: this.docs });
},
resourcePages() {
return resourcePages({ docs: this.docs });
},
};
return {
docs: Array.isArray(index.docs) ? index.docs : [],
listResourceTypes() {
return extractResourceTypes({ docs: this.docs });
},
resourcePages() {
return resourcePages({ docs: this.docs });
},
};
}
/**
* Классифицирует первый сегмент location:
* 'resource' — страница ресурса (гл. контент);
* 'manual' — руководство (_manual);
* 'service' — служебная (curated/30_registry);
* 'other' — прочее/якорь индексной страницы.
*
* @param {string} seg - первый сегмент location.
* @returns {'resource'|'manual'|'service'|'other'}
*/
function pageKind(seg) {
if (!seg || seg.startsWith("#")) return "other";
if (seg === "curated" || seg === "30_registry") return "service";
if (seg.endsWith("_manual")) return "manual";
return "resource";
}
/**
* Преобразует имя ресурса nubes_<база>_<подресурс> в базовое имя страницы
* (<база>). Для `nubes_postgres_database` → `postgres`; `nubes_postgres` → `postgres`.
*
* @param {string} resourceType - имя вида nubes_*.
* @returns {string|null} - базовый сегмент страницы или null.
*/
function resourceBaseSegment(resourceType) {
if (!resourceType || !resourceType.startsWith("nubes_")) return null;
const rest = resourceType.slice("nubes_".length);
if (!rest) return null;
// Известные подресурсы (суффиксы) — отрезаем до базового ресурса.
// Берём первую часть до суффикса-подресурса, если составное имя содержит известный подресурс.
const knownSubs = [
"_database", "_user", "_topic", "_backup", "_snat", "_record",
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
"_team", "_redeploy", "_group",
];
let base = rest;
for (const sub of knownSubs) {
if (rest.endsWith(sub)) {
base = rest.slice(0, rest.length - sub.length);
break;
}
}
return base || null;
}
/**
@@ -140,49 +210,60 @@ function loadIndex(rawJson) {
* @returns {{location:string,title:string,text:string,score:number}[]}
*/
function search(index, query, opts = {}) {
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3;
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
const titleWeight = Number.isFinite(opts.titleWeight) ? opts.titleWeight : 3;
const docs = index && Array.isArray(index.docs) ? index.docs : [];
const qTokens = tokenize(query);
if (qTokens.length === 0 || docs.length === 0) return [];
const docs = index && Array.isArray(index.docs) ? index.docs : [];
const qTokens = tokenize(query);
if (qTokens.length === 0 || docs.length === 0) return [];
const results = [];
for (const doc of docs) {
if (!doc) continue;
const titleTokens = tokenize(doc.title);
const textTokens = tokenize(doc.text);
// Взвешивание по типу страницы: ресурсы важнее руководств/служебных.
const KIND_BOOST = { resource: 2.0, manual: 0.6, service: 0.4, other: 1.0 };
let score = 0;
for (const t of qTokens) {
let hits = 0;
for (const tt of titleTokens) if (tt === t) hits++;
score += hits * titleWeight;
const results = [];
for (const doc of docs) {
if (!doc) continue;
const loc = typeof doc.location === "string" ? doc.location : "";
const seg = loc.split("/")[0].trim();
const boost = KIND_BOOST[pageKind(seg)] || 1.0;
hits = 0;
for (const tx of textTokens) if (tx === t) hits++;
score += hits;
const titleTokens = tokenize(doc.title);
const textTokens = tokenize(doc.text);
let score = 0;
for (const t of qTokens) {
let hits = 0;
for (const tt of titleTokens) if (tt === t) hits++;
score += hits * titleWeight;
hits = 0;
for (const tx of textTokens) if (tx === t) hits++;
score += hits;
}
score *= boost;
if (score > 0) {
results.push({
location: loc,
title: typeof doc.title === "string" ? doc.title : "",
text: typeof doc.text === "string" ? doc.text : "",
score,
});
}
}
if (score > 0) {
results.push({
location: typeof doc.location === "string" ? doc.location : "",
title: typeof doc.title === "string" ? doc.title : "",
text: typeof doc.text === "string" ? doc.text : "",
score,
});
}
}
results.sort((a, b) => b.score - a.score || a.location.localeCompare(b.location));
return results.slice(0, topK);
results.sort((a, b) => b.score - a.score || a.location.localeCompare(b.location));
return results.slice(0, topK);
}
module.exports = {
loadIndex,
tokenize,
search,
stripHtml,
extractResourceTypes,
resourcePages,
loadIndex,
tokenize,
search,
stripHtml,
extractResourceTypes,
resourcePages,
pageKind,
resourceBaseSegment,
};