442 lines
16 KiB
JavaScript
442 lines
16 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 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<MiniSearch|null>|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<void>}
|
||
*/
|
||
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<MiniSearch>}
|
||
*/
|
||
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<number>} atomFloors - 命中的 L0 楼层集合
|
||
* @property {string[]} chunkIds - 命中的 L1 chunk IDs
|
||
* @property {Set<number>} 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<MiniSearch|null>}
|
||
*/
|
||
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;
|
||
}
|