Files
tf_docs/search.js
T

286 lines
11 KiB
JavaScript
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.
"use strict";
/**
* Модуль лексического (BM25-подобного) поиска по search_index.json
* для AI-ассистента документации Nubes.
*
* Node.js >= 18, без внешних зависимостей.
* Экспорты: loadIndex, tokenize, search, stripHtml, extractResourceTypes.
*/
const HTML_TAG_RE = /<[^>]+>/g;
/** Имена nubes_* ресурсов: nubes_postgres, nubes_vc_vdc, nubes_s3_bucket и т.п. */
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",
]);
/** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */
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-сущности.
*
* @param {string} text - Исходный текст, возможно с HTML-тегами.
* @returns {string} - Текст без тегов.
*/
function stripHtml(text) {
if (typeof text !== "string") return "";
return text.replace(HTML_TAG_RE, " ");
}
/**
* Токенизация: lowercase, удаление HTML-тегов, разбиение на токены
* (латиница/кириллица/цифры/подчёркивание), отбрасывание стоп-слов.
*
* @param {string} 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) {
let t = m[0];
if (STOP_WORDS.has(t)) continue;
if (TRANSLIT[t]) t = TRANSLIT[t]; // транслитерация кириллических терминов
tokens.push(t);
}
return tokens;
}
/**
* Извлекает Set уникальных имён ресурсов вида nubes_* из текста всех записей индекса.
*
* @param {object} index - Распарсенный индекс (как из loadIndex).
* @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());
}
}
return set;
}
/**
* Set имён страниц ресурсов: первый сегмент location (до "/").
* Исключаются: пустой сегмент, якоря (начинающиеся с "#"),
* страницы-руководства (оканчиваются на "_manual") и служебные curated/30_registry.
*
* @param {object} 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;
}
/**
* Парсит строку JSON индекса MkDocs (search_index.json) и возвращает объект
* с исходными записями и утилитами по ресурсам.
*
* @param {string} rawJson - Строка JSON индекса.
* @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 : [] };
}
const docs = (Array.isArray(index.docs) ? index.docs : []).map((doc) => ({
location: typeof doc.location === 'string' ? doc.location : '',
title: typeof doc.title === 'string' ? doc.title : '',
text: typeof doc.text === 'string' ? doc.text : '',
// Предрасчёт: токены и сегмент считаются ОДИН раз при загрузке,
// а не на каждый поисковый запрос (экономия ~1 с на 823 доках).
_seg: (typeof doc.location === 'string' ? doc.location : '').split('/')[0].trim(),
_titleTokens: tokenize(doc.title),
_textTokens: tokenize(doc.text),
}));
// Предрасчёт: списки ресурсов считаются ОДИН раз при загрузке индекса,
// а не на каждый запрос (экономия CPU на regex по всем докам).
const resourceTypes = extractResourceTypes({ docs });
const pages = resourcePages({ docs });
return {
docs,
listResourceTypes() {
return resourceTypes;
},
resourcePages() {
return pages;
},
};
}
/**
* Классифицирует первый сегмент 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;
}
/**
* BM25-подобный лексический поиск по индексу.
*
* @param {object} index - Объект, возвращённый loadIndex (или {docs: [...]}).
* @param {string} query - Поисковый запрос.
* @param {{topK?: number, titleWeight?: number}} [opts]
* @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 docs = index && Array.isArray(index.docs) ? index.docs : [];
const qTokens = tokenize(query);
if (qTokens.length === 0 || docs.length === 0) return [];
// Взвешивание по типу страницы: ресурсы важнее руководств/служебных.
const KIND_BOOST = { resource: 2.0, manual: 0.6, service: 0.4, other: 1.0 };
const results = [];
for (const doc of docs) {
if (!doc) continue;
const loc = typeof doc.location === "string" ? doc.location : "";
const seg = doc._seg !== undefined ? doc._seg : loc.split("/")[0].trim();
const boost = KIND_BOOST[pageKind(seg)] || 1.0;
const titleTokens = doc._titleTokens || tokenize(doc.title);
const textTokens = doc._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,
});
}
}
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,
pageKind,
resourceBaseSegment,
};