feat(search): лексический поиск по search_index.json + эталонный набор (28 вопросов)

This commit is contained in:
2026-10-07 13:46:19 +03:00
parent e5817ff7f3
commit 827870d51c
2 changed files with 218 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
"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;
/**
* Убирает 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) {
const t = m[0];
if (!STOP_WORDS.has(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 : [] };
}
return {
docs: Array.isArray(index.docs) ? index.docs : [],
listResourceTypes() {
return extractResourceTypes({ docs: this.docs });
},
resourcePages() {
return resourcePages({ docs: this.docs });
},
};
}
/**
* 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 results = [];
for (const doc of docs) {
if (!doc) continue;
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;
}
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);
}
module.exports = {
loadIndex,
tokenize,
search,
stripHtml,
extractResourceTypes,
resourcePages,
};