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 бо́льший вес у страниц ресурсов.
После доработки — повторный прогон эталона.
+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,
};
+132 -126
View File
@@ -16,17 +16,17 @@
*/
const { llmChat } = require('./assist.js');
const { search, stripHtml } = require('./search.js');
const { search, stripHtml, resourceBaseSegment } = require('./search.js');
const DEFAULT_MODEL = 'gpt-oss-120b';
// Заголовки, которые ОБЯЗАН содержать ответ этапа 2 (П.12 брифа).
const REQUIRED_HEADERS = [
'Роль',
'Задача',
'Контекст',
'Требования',
'Ограничения',
'Роль',
'Задача',
'Контекст',
'Требования',
'Ограничения',
];
/**
@@ -37,9 +37,9 @@ const REQUIRED_HEADERS = [
* @returns {boolean}
*/
function isValidSourceUrl(url) {
if (!url || typeof url !== 'string') return false;
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
return true;
if (!url || typeof url !== 'string') return false;
if (url.includes('//') || url.includes(':') || url.includes('..')) return false;
return true;
}
/**
@@ -50,10 +50,10 @@ function isValidSourceUrl(url) {
* @returns {string|null} - "/<stand>/<location>" или null, если недопустимо.
*/
function sanitizeSourceUrl(stand, location) {
const clean = sanitizeLocation(location);
if (clean === null) return null;
const url = `/${stand}/${clean}`;
return isValidSourceUrl(url) ? url : null;
const clean = sanitizeLocation(location);
if (clean === null) return null;
const url = `/${stand}/${clean}`;
return isValidSourceUrl(url) ? url : null;
}
/**
@@ -64,10 +64,10 @@ function sanitizeSourceUrl(stand, location) {
* @returns {string|null}
*/
function sanitizeLocation(location) {
const s = String(location ?? '').trim();
if (!s) return null;
if (s.includes('..') || s.includes(':')) return null;
return s.replace(/^\/+|\/+$/g, '');
const s = String(location ?? '').trim();
if (!s) return null;
if (s.includes('..') || s.includes(':')) return null;
return s.replace(/^\/+|\/+$/g, '');
}
/**
@@ -84,53 +84,59 @@ function sanitizeLocation(location) {
* @returns {Array<{location:string,title:string,text:string,score:number}>}
*/
function mergeSearchResults(index, query, resourceType, opts = {}) {
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? Math.floor(opts.topK) : 5;
// 1) поиск по токенам исходного запроса (всегда).
const tokenResults = search(index, query, { topK: 50 });
// 1) поиск по токенам исходного запроса (всегда).
const tokenResults = search(index, query, { topK: 50 });
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
let resourceResults = [];
if (resourceType) {
resourceResults = search(index, resourceType, { topK: 50 });
}
// Объединяем по location, оценка = max.
const byLocation = new Map();
const add = (arr) => {
for (const r of arr) {
const key = r.location || '';
const prev = byLocation.get(key);
if (!prev || r.score > prev.score) {
byLocation.set(key, r);
}
// 2) поиск после этапа 1: если resource_type есть, ищем по его имени.
let resourceResults = [];
if (resourceType) {
// Ищем и по полному resource_type, и по базовому ресурсу (подресурс → родитель).
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);
}
}
};
add(tokenResults);
add(resourceResults);
// Собираем отсортированный массив.
const merged = [...byLocation.values()].sort(
(a, b) => b.score - a.score || a.location.localeCompare(b.location)
);
// Объединяем по location, оценка = max.
const byLocation = new Map();
const add = (arr) => {
for (const r of arr) {
const key = r.location || '';
const prev = byLocation.get(key);
if (!prev || r.score > prev.score) {
byLocation.set(key, r);
}
}
};
add(tokenResults);
add(resourceResults);
// Страница ресурса — обязательно первой (П.5), если resource_type найден.
const result = [];
if (resourceType) {
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
if (resourcePage) {
result.push(resourcePage);
// Собираем отсортированный массив.
const merged = [...byLocation.values()].sort(
(a, b) => b.score - a.score || a.location.localeCompare(b.location)
);
// Страница ресурса — обязательно первой (П.5), если resource_type найден.
const result = [];
if (resourceType) {
const resourcePage = merged.find((r) => isResourcePage(r.location, resourceType));
if (resourcePage) {
result.push(resourcePage);
}
}
}
// Дополняем до K без повторов.
for (const r of merged) {
if (result.length >= topK) break;
if (result.includes(r)) continue;
result.push(r);
}
// Дополняем до K без повторов.
for (const r of merged) {
if (result.length >= topK) break;
if (result.includes(r)) continue;
result.push(r);
}
return result.slice(0, topK);
return result.slice(0, topK);
}
/**
@@ -143,10 +149,10 @@ function mergeSearchResults(index, query, resourceType, opts = {}) {
* @returns {boolean}
*/
function isResourcePage(location, resourceType) {
const pageName = resourceType.replace(/^nubes_/, '');
if (!pageName) return false;
const seg = String(location || '').split('/')[0].trim();
return seg === pageName;
const pageName = resourceType.replace(/^nubes_/, '');
if (!pageName) return false;
const seg = String(location || '').split('/')[0].trim();
return seg === pageName;
}
/**
@@ -158,11 +164,11 @@ function isResourcePage(location, resourceType) {
* @returns {Array<{location:string,title:string,text:string}>}
*/
function buildContext(entries, maxChars = 1500) {
return entries.map((e) => ({
location: e.location,
title: e.title || '',
text: stripHtml(e.text || '').slice(0, maxChars),
}));
return entries.map((e) => ({
location: e.location,
title: e.title || '',
text: stripHtml(e.text || '').slice(0, maxChars),
}));
}
/**
@@ -173,26 +179,26 @@ function buildContext(entries, maxChars = 1500) {
* @returns {Array<{title:string, url:string}>}
*/
function collectSources(entries, stand) {
const out = [];
for (const e of entries) {
const url = sanitizeSourceUrl(stand, e.location);
if (url) {
out.push({ title: stripHtml(e.title || ''), url });
const out = [];
for (const e of entries) {
const url = sanitizeSourceUrl(stand, e.location);
if (url) {
out.push({ title: stripHtml(e.title || ''), url });
}
}
}
return out;
return out;
}
/**
* Системный промпт этапа 2: строгий шаблон с обязательными заголовками.
*/
const STAGE2_SYSTEM_PROMPT =
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
'Отвечай на русском языке.';
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
'Отвечай на русском языке.';
/**
* Этап 2: генерация итогового ответа по строгому шаблону.
@@ -208,61 +214,61 @@ const STAGE2_SYSTEM_PROMPT =
* @returns {Promise<{ok:true, answer:string} | {ok:false, error:string}>}
*/
async function stage2Generate({
question,
context,
baseUrl = process.env.LLM_BASE_URL || '',
apiKey = process.env.LLM_API_KEY || '',
model = process.env.LLM_MODEL || DEFAULT_MODEL,
maxTokens = 1024,
controller,
question,
context,
baseUrl = process.env.LLM_BASE_URL || '',
apiKey = process.env.LLM_API_KEY || '',
model = process.env.LLM_MODEL || DEFAULT_MODEL,
maxTokens = 1024,
controller,
}) {
const docsBlock = context
.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`)
.join('\n\n');
const docsBlock = context
.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`)
.join('\n\n');
const userContent =
'ДОКУМЕНТАЦИЯ:\n' +
(docsBlock || '(нет релевантных фрагментов)') +
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
String(question ?? '');
const userContent =
'ДОКУМЕНТАЦИЯ:\n' +
(docsBlock || '(нет релевантных фрагментов)') +
'\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' +
String(question ?? '');
const messages = [
{ role: 'system', content: STAGE2_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
];
const messages = [
{ role: 'system', content: STAGE2_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
];
let content;
try {
content = await llmChat({
messages,
maxTokens,
temperature: 0.2,
controller,
baseUrl,
apiKey,
model,
});
} catch (err) {
return { ok: false, error: err && err.code ? err.code : 'stage2_failed' };
}
let content;
try {
content = await llmChat({
messages,
maxTokens,
temperature: 0.2,
controller,
baseUrl,
apiKey,
model,
});
} catch (err) {
return { ok: false, error: err && err.code ? err.code : 'stage2_failed' };
}
// Проверяем наличие обязательных заголовков (П.12).
const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':'));
if (missing.length > 0) {
return { ok: false, error: 'bad_llm_output' };
}
// Проверяем наличие обязательных заголовков (П.12).
const missing = REQUIRED_HEADERS.filter((h) => !content.includes(h + ':'));
if (missing.length > 0) {
return { ok: false, error: 'bad_llm_output' };
}
return { ok: true, answer: content };
return { ok: true, answer: content };
}
module.exports = {
mergeSearchResults,
buildContext,
collectSources,
sanitizeSourceUrl,
sanitizeLocation,
isValidSourceUrl,
isResourcePage,
stage2Generate,
REQUIRED_HEADERS,
mergeSearchResults,
buildContext,
collectSources,
sanitizeSourceUrl,
sanitizeLocation,
isValidSourceUrl,
isResourcePage,
stage2Generate,
REQUIRED_HEADERS,
};