// ═══════════════════════════════════════════════════════════════════════════ // lexical-index.js - MiniSearch 词法检索索引 // // 职责: // 1. 对 L0 atoms + L1 chunks + L2 events 建立词法索引 // 2. 提供词法检索接口(专名精确匹配兜底) // 3. 惰性构建 + 异步预热 + 缓存失效机制 // // 索引存储:纯内存(不持久化) // 分词器:统一使用 tokenizer.js(结巴 + 实体保护 + 降级) // 重建时机:CHAT_CHANGED / L0提取完成 / L2总结完成 // ═══════════════════════════════════════════════════════════════════════════ import MiniSearch from '../../../../libs/minisearch.mjs'; import { getContext } from '../../../../../../../extensions.js'; import { getSummaryStore } from '../../data/store.js'; import { getStateAtoms } from '../storage/state-store.js'; import { getAllChunks } from '../storage/chunk-store.js'; import { xbLog } from '../../../../core/debug-core.js'; import { tokenizeForIndex } from '../utils/tokenizer.js'; const MODULE_ID = 'lexical-index'; // ───────────────────────────────────────────────────────────────────────── // 缓存 // ───────────────────────────────────────────────────────────────────────── /** @type {MiniSearch|null} */ let cachedIndex = null; /** @type {string|null} */ let cachedChatId = null; /** @type {string|null} 数据指纹(atoms + chunks + events 数量) */ let cachedFingerprint = null; /** @type {boolean} 是否正在构建 */ let building = false; /** @type {Promise|null} 当前构建 Promise(防重入) */ let buildPromise = null; // ───────────────────────────────────────────────────────────────────────── // 工具函数 // ───────────────────────────────────────────────────────────────────────── /** * 清理事件摘要(移除楼层标记) * @param {string} summary * @returns {string} */ function cleanSummary(summary) { return String(summary || '') .replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '') .trim(); } /** * 计算缓存指纹 * @param {number} atomCount * @param {number} chunkCount * @param {number} eventCount * @returns {string} */ function computeFingerprint(atomCount, chunkCount, eventCount) { return `${atomCount}:${chunkCount}:${eventCount}`; } /** * 让出主线程(避免长时间阻塞 UI) * @returns {Promise} */ function yieldToMain() { return new Promise(resolve => setTimeout(resolve, 0)); } // ───────────────────────────────────────────────────────────────────────── // 文档收集 // ───────────────────────────────────────────────────────────────────────── /** * 收集所有待索引文档 * * @param {object[]} atoms - getStateAtoms() 返回值 * @param {object[]} chunks - getAllChunks(chatId) 返回值 * @param {object[]} events - store.json.events * @returns {object[]} 文档数组 */ function collectDocuments(atoms, chunks, events) { const docs = []; // L0 atoms for (const atom of (atoms || [])) { if (!atom?.atomId || !atom.semantic) continue; docs.push({ id: atom.atomId, type: 'atom', floor: atom.floor ?? -1, text: atom.semantic, }); } // L1 chunks for (const chunk of (chunks || [])) { if (!chunk?.chunkId || !chunk.text) continue; docs.push({ id: chunk.chunkId, type: 'chunk', floor: chunk.floor ?? -1, text: chunk.text, }); } // L2 events for (const ev of (events || [])) { if (!ev?.id) continue; const parts = []; if (ev.title) parts.push(ev.title); if (ev.participants?.length) parts.push(ev.participants.join(' ')); const summary = cleanSummary(ev.summary); if (summary) parts.push(summary); const text = parts.join(' ').trim(); if (!text) continue; docs.push({ id: ev.id, type: 'event', floor: null, text, }); } return docs; } // ───────────────────────────────────────────────────────────────────────── // 索引构建(分片,不阻塞主线程) // ───────────────────────────────────────────────────────────────────────── /** 每批添加的文档数 */ const BUILD_BATCH_SIZE = 500; /** * 构建 MiniSearch 索引(分片异步) * * @param {object[]} docs - 文档数组 * @returns {Promise} */ async function buildIndexAsync(docs) { const T0 = performance.now(); const index = new MiniSearch({ fields: ['text'], storeFields: ['type', 'floor'], idField: 'id', searchOptions: { boost: { text: 1 }, fuzzy: 0.2, prefix: true, }, tokenize: tokenizeForIndex, }); if (!docs.length) { return index; } // 分片添加,每批 BUILD_BATCH_SIZE 条后让出主线程 for (let i = 0; i < docs.length; i += BUILD_BATCH_SIZE) { const batch = docs.slice(i, i + BUILD_BATCH_SIZE); index.addAll(batch); // 非最后一批时让出主线程 if (i + BUILD_BATCH_SIZE < docs.length) { await yieldToMain(); } } const elapsed = Math.round(performance.now() - T0); xbLog.info(MODULE_ID, `索引构建完成: ${docs.length} 文档 (${elapsed}ms)` ); return index; } // ───────────────────────────────────────────────────────────────────────── // 检索 // ───────────────────────────────────────────────────────────────────────── /** * @typedef {object} LexicalSearchResult * @property {string[]} atomIds - 命中的 L0 atom IDs * @property {Set} atomFloors - 命中的 L0 楼层集合 * @property {string[]} chunkIds - 命中的 L1 chunk IDs * @property {Set} chunkFloors - 命中的 L1 楼层集合 * @property {string[]} eventIds - 命中的 L2 event IDs * @property {object[]} chunkScores - chunk 命中详情 [{ chunkId, score }] * @property {number} searchTime - 检索耗时 ms */ /** * 在词法索引中检索 * * @param {MiniSearch} index - 索引实例 * @param {string[]} terms - 查询词列表 * @returns {LexicalSearchResult} */ export function searchLexicalIndex(index, terms) { const T0 = performance.now(); const result = { atomIds: [], atomFloors: new Set(), chunkIds: [], chunkFloors: new Set(), eventIds: [], chunkScores: [], searchTime: 0, }; if (!index || !terms?.length) { result.searchTime = Math.round(performance.now() - T0); return result; } // 用所有 terms 联合查询 const queryString = terms.join(' '); let hits; try { hits = index.search(queryString, { boost: { text: 1 }, fuzzy: 0.2, prefix: true, combineWith: 'OR', // 使用与索引相同的分词器 tokenize: tokenizeForIndex, }); } catch (e) { xbLog.warn(MODULE_ID, '检索失败', e); result.searchTime = Math.round(performance.now() - T0); return result; } // 分类结果 const atomIdSet = new Set(); const chunkIdSet = new Set(); const eventIdSet = new Set(); for (const hit of hits) { const type = hit.type; const id = hit.id; const floor = hit.floor; switch (type) { case 'atom': if (!atomIdSet.has(id)) { atomIdSet.add(id); result.atomIds.push(id); if (typeof floor === 'number' && floor >= 0) { result.atomFloors.add(floor); } } break; case 'chunk': if (!chunkIdSet.has(id)) { chunkIdSet.add(id); result.chunkIds.push(id); result.chunkScores.push({ chunkId: id, score: hit.score }); if (typeof floor === 'number' && floor >= 0) { result.chunkFloors.add(floor); } } break; case 'event': if (!eventIdSet.has(id)) { eventIdSet.add(id); result.eventIds.push(id); } break; } } result.searchTime = Math.round(performance.now() - T0); xbLog.info(MODULE_ID, `检索完成: terms=[${terms.slice(0, 5).join(',')}] → atoms=${result.atomIds.length} chunks=${result.chunkIds.length} events=${result.eventIds.length} (${result.searchTime}ms)` ); return result; } // ───────────────────────────────────────────────────────────────────────── // 内部构建流程(收集数据 + 构建索引) // ───────────────────────────────────────────────────────────────────────── /** * 收集数据并构建索引 * * @param {string} chatId * @returns {Promise<{index: MiniSearch, fingerprint: string}>} */ async function collectAndBuild(chatId) { // 收集数据 const atoms = getStateAtoms() || []; const store = getSummaryStore(); const events = store?.json?.events || []; let chunks = []; try { chunks = await getAllChunks(chatId); } catch (e) { xbLog.warn(MODULE_ID, '获取 chunks 失败', e); } const fp = computeFingerprint(atoms.length, chunks.length, events.length); // 检查是否在收集过程中缓存已被其他调用更新 if (cachedIndex && cachedChatId === chatId && cachedFingerprint === fp) { return { index: cachedIndex, fingerprint: fp }; } // 收集文档 const docs = collectDocuments(atoms, chunks, events); // 异步分片构建 const index = await buildIndexAsync(docs); return { index, fingerprint: fp }; } // ───────────────────────────────────────────────────────────────────────── // 公开接口:getLexicalIndex(惰性获取) // ───────────────────────────────────────────────────────────────────────── /** * 获取词法索引(惰性构建 + 缓存) * * 如果缓存有效则直接返回;否则自动构建。 * 如果正在构建中,等待构建完成。 * * @returns {Promise} */ export async function getLexicalIndex() { const { chatId } = getContext(); if (!chatId) return null; // 快速路径:如果缓存存在且 chatId 未变,则直接命中 // 指纹校验放到构建流程中完成,避免为指纹而额外读一次 IndexedDB if (cachedIndex && cachedChatId === chatId && cachedFingerprint) { return cachedIndex; } // 正在构建中,等待结果 if (building && buildPromise) { try { await buildPromise; if (cachedIndex && cachedChatId === chatId && cachedFingerprint) { return cachedIndex; } } catch { // 构建失败,继续往下重建 } } // 需要重建(指纹将在 collectAndBuild 内部计算并写入缓存) xbLog.info(MODULE_ID, `缓存失效,重建索引 (chatId=${chatId.slice(0, 8)})`); building = true; buildPromise = collectAndBuild(chatId); try { const { index, fingerprint } = await buildPromise; // 原子替换缓存 cachedIndex = index; cachedChatId = chatId; cachedFingerprint = fingerprint; return index; } catch (e) { xbLog.error(MODULE_ID, '索引构建失败', e); return null; } finally { building = false; buildPromise = null; } } // ───────────────────────────────────────────────────────────────────────── // 公开接口:warmupIndex(异步预建) // ───────────────────────────────────────────────────────────────────────── /** * 异步预建索引 * * 在 CHAT_CHANGED 时调用,后台构建索引。 * 不阻塞调用方,不返回结果。 * 构建完成后缓存自动更新,后续 getLexicalIndex() 直接命中。 * * 调用时机: * - handleChatChanged(实体注入后) * - L0 提取完成 * - L2 总结完成 */ export function warmupIndex() { const { chatId } = getContext(); if (!chatId) return; // 已在构建中,不重复触发 if (building) return; // fire-and-forget getLexicalIndex().catch(e => { xbLog.warn(MODULE_ID, '预热索引失败', e); }); } // ───────────────────────────────────────────────────────────────────────── // 公开接口:invalidateLexicalIndex(缓存失效) // ───────────────────────────────────────────────────────────────────────── /** * 使缓存失效(下次 getLexicalIndex / warmupIndex 时自动重建) * * 调用时机: * - CHAT_CHANGED * - L0 提取完成 * - L2 总结完成 */ export function invalidateLexicalIndex() { if (cachedIndex) { xbLog.info(MODULE_ID, '索引缓存已失效'); } cachedIndex = null; cachedChatId = null; cachedFingerprint = null; }