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
+35
View File
@@ -0,0 +1,35 @@
# Оценка качества на эталонном наборе (шаг 9, первый прогон)
Дата: 2026-10-07
## Метрики (28 вопросов)
| Метрика | Значение | Целевой порог (бриф П.7) |
|---|---|---|
| Точность этапа 1 (resource_type) | **79% (22/28)** | ≥ 95% (с учётом повторов) |
| Поиск: ожидаемая страница в top-5 | **71% (20/28)** | ≥ 80% |
## Выявленные проблемы поиска
1. **`_manual` и служебные (`curated`/`30_registry`) страницы конкурируют со страницами ресурсов** в скоринге. Пример: «создать постгрес» → `postgres_manual`, «Node.js приложение» → `curated`.
2. **Кириллица ↔ латиница**: «постгрес» не токенизируется в `postgres` (слова разные). Нужен словарь-маппинг частых терминов.
3. **Подресурсы теряют родителя**: `nubes_postgres_database`, `nubes_mongodb_user`, `nubes_kafka_topic` при поиске по токенам не приводят на страницу `postgres`/`mongodb`/`kafka` (в тексте нет буквального токена `postgres_database`).
## Примеры ошибок
| Вопрос | resource_type (верно?) | Проблема |
|---|---|---|
| «создать постгрес базу» | `nubes_postgres_database` ✅ | страница `postgres_manual` вместо `postgres` |
| «пользователь пароль MongoDB» | `nubes_mongodb_user` ✅ | страница `curated` вместо `mongodb` |
| «Kafka пользователь» | `nubes_kafka_user` ✅ | `postgres_manual` вместо `kafka` |
| «Node.js приложение» | `nubes_nodejs_redeploy` ⚠️ | `curated` вместо `nodejs` |
| «vDC организация» | `nubes_vc_org` (ожид. `nubes_vc_vdc`) ⚠️ | этап 1 выбрал org вместо vdc |
## Требуемые доработки (шаг 9 итерация)
1. **Буст страницам ресурсов** (первый сегмент не `_manual`, не `curated`/`30_registry`) — повысить score.
2. **Маппинг кириллица→латиница** для частых терминов (постгрес→postgres, бакет→bucket, диск→disk, машина→vm и т.п.).
3. **Подресурс → родитель**: `nubes_<база>_<подресурс>` при поиске дополнительно искать по токену `<база>`.
4. Пересборка скора: title бо́льший вес у страниц ресурсов.
После доработки — повторный прогон эталона.
+84 -3
View File
@@ -18,6 +18,8 @@ const STOP_WORDS = new Set([
// русские // русские
"и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из", "и", "в", "на", "с", "для", "как", "что", "это", "при", "по", "к", "из",
"от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие", "от", "не", "а", "но", "или", "чтобы", "когда", "где", "какой", "какие",
"создать", "настроить", "развернуть", "поднять", "завести", "сделать",
"база", "базу", "базы", "пользователь", "пользователя",
// английские // английские
"the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "with", "the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "with",
"by", "is", "are", "how", "what", "which", "do", "does", "can", "by", "is", "are", "how", "what", "which", "do", "does", "can",
@@ -26,6 +28,27 @@ const STOP_WORDS = new Set([
/** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */ /** Токен = последовательность латиница/кириллица/цифры/подчёркивание. */
const TOKEN_RE = /[a-zа-яё0-9_]+/giu; 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-теги из текста, заменяя каждый тег на пробел
* (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности. * (чтобы соседние слова не склеивались). Не раскодирует HTML-сущности.
@@ -52,8 +75,10 @@ function tokenize(text) {
const re = new RegExp(TOKEN_RE.source, "giu"); const re = new RegExp(TOKEN_RE.source, "giu");
let m; let m;
while ((m = re.exec(clean)) !== null) { while ((m = re.exec(clean)) !== null) {
const t = m[0]; let t = m[0];
if (!STOP_WORDS.has(t)) tokens.push(t); if (STOP_WORDS.has(t)) continue;
if (TRANSLIT[t]) t = TRANSLIT[t]; // транслитерация кириллических терминов
tokens.push(t);
} }
return tokens; return tokens;
} }
@@ -131,6 +156,51 @@ function loadIndex(rawJson) {
}; };
} }
/**
* Классифицирует первый сегмент 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-подобный лексический поиск по индексу. * BM25-подобный лексический поиск по индексу.
* *
@@ -147,9 +217,16 @@ function search(index, query, opts = {}) {
const qTokens = tokenize(query); const qTokens = tokenize(query);
if (qTokens.length === 0 || docs.length === 0) return []; 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 = []; const results = [];
for (const doc of docs) { for (const doc of docs) {
if (!doc) continue; 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;
const titleTokens = tokenize(doc.title); const titleTokens = tokenize(doc.title);
const textTokens = tokenize(doc.text); const textTokens = tokenize(doc.text);
@@ -164,9 +241,11 @@ function search(index, query, opts = {}) {
score += hits; score += hits;
} }
score *= boost;
if (score > 0) { if (score > 0) {
results.push({ results.push({
location: typeof doc.location === "string" ? doc.location : "", location: loc,
title: typeof doc.title === "string" ? doc.title : "", title: typeof doc.title === "string" ? doc.title : "",
text: typeof doc.text === "string" ? doc.text : "", text: typeof doc.text === "string" ? doc.text : "",
score, score,
@@ -185,4 +264,6 @@ module.exports = {
stripHtml, stripHtml,
extractResourceTypes, extractResourceTypes,
resourcePages, resourcePages,
pageKind,
resourceBaseSegment,
}; };
+7 -1
View File
@@ -16,7 +16,7 @@
*/ */
const { llmChat } = require('./assist.js'); const { llmChat } = require('./assist.js');
const { search, stripHtml } = require('./search.js'); const { search, stripHtml, resourceBaseSegment } = require('./search.js');
const DEFAULT_MODEL = 'gpt-oss-120b'; const DEFAULT_MODEL = 'gpt-oss-120b';
@@ -92,7 +92,13 @@ function mergeSearchResults(index, query, resourceType, opts = {}) {
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени. // 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
let resourceResults = []; let resourceResults = [];
if (resourceType) { if (resourceType) {
// Ищем и по полному resource_type, и по базовому ресурсу (подресурс → родитель).
resourceResults = search(index, resourceType, { topK: 50 }); resourceResults = search(index, resourceType, { topK: 50 });
const base = resourceBaseSegment(resourceType);
if (base && base !== resourceType) {
const baseResults = search(index, base, { topK: 50 });
resourceResults = resourceResults.concat(baseResults);
}
} }
// Объединяем по location, оценка = max. // Объединяем по location, оценка = max.