feat(search): лексический поиск по search_index.json + эталонный набор (28 вопросов)
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{"question": "Как создать виртуальную машину в облаке Nubes?", "expected_pages": ["vc_vm_v3"], "resource_type": "nubes_vc_vm_v3"},
|
||||||
|
{"question": "Как настроить PostgreSQL с автоматическим бэкапом?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres"},
|
||||||
|
{"question": "Как создать базу данных и пользователя в PostgreSQL?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres_database"},
|
||||||
|
{"question": "Как выдать виртуальной машине белый IP-адрес?", "expected_pages": ["vcexternalip"], "resource_type": "nubes_vcexternalip"},
|
||||||
|
{"question": "Как поднять MongoDB с ежедневным бэкапом?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb"},
|
||||||
|
{"question": "Как завести пользователя и пароль для MongoDB?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb_user"},
|
||||||
|
{"question": "Как развернуть Redis для кэширования?", "expected_pages": ["redis"], "resource_type": "nubes_redis"},
|
||||||
|
{"question": "Как создать MariaDB и привязать к ней отдельную базу?", "expected_pages": ["mariadb"], "resource_type": "nubes_mariadb"},
|
||||||
|
{"question": "Как поднять ClickHouse и создать в нём таблицу?", "expected_pages": ["clickhouse"], "resource_type": "nubes_clickhouse_database"},
|
||||||
|
{"question": "Как развернуть Kafka-кластер и создать топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_topic"},
|
||||||
|
{"question": "Как завести пользователя Kafka с правами на конкретный топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_user"},
|
||||||
|
{"question": "Как развернуть RabbitMQ для очередей задач?", "expected_pages": ["rabbitmq"], "resource_type": "nubes_rabbitmq"},
|
||||||
|
{"question": "Как создать S3-бакет для хранения файлов?", "expected_pages": ["s3bucket", "s3"], "resource_type": "nubes_s3bucket"},
|
||||||
|
{"question": "Как создать S3-хранилище и сублюзеров с ограниченными ключами?", "expected_pages": ["s3"], "resource_type": "nubes_s3_sub_user"},
|
||||||
|
{"question": "Как развернуть Kubernetes-кластер в Shturval?", "expected_pages": ["k8s_sthutrval_cluster"], "resource_type": "nubes_k8s_sthutrval_cluster"},
|
||||||
|
{"question": "Как установить Velero для бэкапа Kubernetes?", "expected_pages": ["k8s_velero"], "resource_type": "nubes_k8s_velero"},
|
||||||
|
{"question": "Как развернуть OpenBao для хранения секретов в кластере?", "expected_pages": ["k8s_openbao"], "resource_type": "nubes_k8s_openbao"},
|
||||||
|
{"question": "Как создать DNS-запись для своего домена?", "expected_pages": ["dnsrecord", "zones_v2"], "resource_type": "nubes_dnsrecord"},
|
||||||
|
{"question": "Как создать организацию и vDC в VMware-облаке?", "expected_pages": ["vc_org", "vc_vdc"], "resource_type": "nubes_vc_vdc"},
|
||||||
|
{"question": "Как настроить SNAT в NSX-T?", "expected_pages": ["vc_nsxt"], "resource_type": "nubes_vc_nsxt_snat"},
|
||||||
|
{"question": "Как развернуть LLM-модель с секретами и командой?", "expected_pages": ["llm_ai"], "resource_type": "nubes_llm_ai"},
|
||||||
|
{"question": "Как развернуть Nextcloud и настроить бэкап?", "expected_pages": ["nextcloud"], "resource_type": "nubes_nextcloud"},
|
||||||
|
{"question": "Как развернуть Node.js приложение и сделать передеплой?", "expected_pages": ["nodejs"], "resource_type": "nubes_nodejs"},
|
||||||
|
{"question": "Как задеплоить Flask-приложение и получить его URL?", "expected_pages": ["flask"], "resource_type": "nubes_flask"},
|
||||||
|
{"question": "Как развернуть pgAdmin для управления базами?", "expected_pages": ["pgadmin"], "resource_type": "nubes_pgadmin"},
|
||||||
|
{"question": "Как подключиться к API Terraform-провайдера Nubes?", "expected_pages": [], "resource_type": ""},
|
||||||
|
{"question": "Какие версии провайдера сейчас доступны и как их обновлять?", "expected_pages": ["template"], "resource_type": ""},
|
||||||
|
{"question": "Как создать nubes_instance с плавающим IP floating_ip?", "expected_pages": [], "resource_type": ""}
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user