novel_semantic_search
语义检索。以自然语言描述为查询,在全书的段落向量中按余弦相似度召回最相关段落,返回章节定位、原文片段与相似度分值。全程本地推理:模型随包分发,由 onnxruntime-web 的 WASM 后端在宿主进程内运行,不发起网络请求、不消耗在线 token。索引按内容指纹增量构建并落盘缓存,章节改动后只对新段落重算向量。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
book | string | 是 | 书名。空串抛「novel_semantic_search 需要 book 参数」;目录解析经 bookDir → sanitizeSegment,路径分隔符与上跳已剥离。 |
query | string | 是 | 检索用的语义描述。空串抛「novel_semantic_search 需要 query 参数」。自然语言越具体越好;超长查询在分词后按 512 token 截断。 |
top | integer | 否 | 返回条数。默认 5,区间 1–10;由 Math.min(Math.max(Number(top) || 5, 1), 10) 钳制,非数字或 0 回退 5,不抛错。 |
root | string | 否 | 章节库根目录。省略时取插件 config.root,再省略取会话工作目录;MCP 形态下必须落在启动参数 --root 指定的书库根内,越界会被静默回退到该书库根。 |
输出结构
契约字段(output.schema.required):book、query、available、results、message。available:false 时 results 恒为空数组,全部信息在 message 里。
| 字段 | 类型 | 内容 |
|---|---|---|
available | boolean | 本次是否真正执行了检索 |
cache | string | 枚举 hit(复用落盘索引)/ built(本次重建或补齐索引);仅在 available:true 时出现 |
indexSize | integer | 参与排序的段落向量条数(可能小于切块总数) |
results | array | 按分值降序,元素为 {id, chapter, text, score} |
results[].id | string | 章文件名 + "|" + 块序号(块序号来自 chunkText) |
results[].chapter | string | 章文件名;字段缺失时回退为 id 中 "|" 之前的部分,再回退为「全书」 |
results[].text | string | 段落原文,已去除 \r 并截断到 200 字 |
results[].score | number | 余弦相似度,保留 4 位小数(round(score × 10000) / 10000) |
message | string | 命中索引时为「使用本地语义索引(缓存)」;本次构建时为「首次建索引完成,已缓存」,并可追加补齐与写盘失败提示 |
渲染层输出为「《书名》语义检索「查询」(命中 N 段,索引 M 段)」加逐行 - [score] 章文件名: 段落文本;available:false 时只输出标题加「(不可用)」与 message。检索结果不做二次重排或去重:同一章的相邻块可能同时进入前 top,调用方需要按章聚合时应自行处理。
计算原理
1 · 模型与推理路径
选择 WASM 直连而非子进程或原生 .node 绑定,是为了避免宿主 ABI 冲突与进程崩溃风险:推理在宿主进程内完成,内存安全,无子进程。模型与分词器均为懒加载单例,第一次调用才载入。
2 · 向量化
批量推理按 id 回填向量而非按下标——失败项被过滤后会缩短数组,按下标回填会造成向量与段落错位。
3 · 切块与章节标记
超长行按 150 字硬切是必要的:PDF 或网页复制的无换行文本若整行进模型,会在 512 token 处静默截断,尾部内容丢失。切块长度上限由「累加后判断」改为「切满再留余量」后恒定 ≤ 150 字。
4 · 余弦排序
索引向量的存储维度是 128:模型直出 512 维后按「每 4 维取 1」降采样并重新 L2 归一化,落盘时再按 4 位小数取整。该降采样是有损压缩,用于缓存瘦身与维度统一,不保证完整语义信息保持;已是 128 维的旧缓存只做归一化,不二次降采样。
5 · 索引缓存与增量构建
cachedFp !== indexFp 并走增量补齐。message 中会带出「(部分段落失败,下次自动补齐 N/M)」或「;索引缓存写入失败(下次会重建)」,使残缺状态对调用方可见,而不是被当作完整缓存永久复用。
约束与边界
- 文本截断上限 512 token:
embed先按 4000 字符再按 512 token 截断。单块文本受chunkText的 150 字上限约束,正常路径不会触顶;query是唯一可能触顶的输入,超长查询只有前 512 token 参与匹配,且不会报错。 top是钳制而非报错:top: 50得到 10 条,top: 0或top: "abc"得到 5 条。调用方无法通过本参数取得 10 条以上的结果,需要更多召回时应换更窄的查询而非扩大 top。- 两层功能门控:
semanticSearch未开(或总开关semanticEmbedding关闭)时返回available:false,message为「「语义检索」已关闭:请在侧边栏「写作助手功能」→ 小模型页开启,或用 novel_sentence_config 设置 semanticSearch:true。」——这是配置状态,不是错误。 - 引擎不可用的降级文案是固定的:
modelPresent()为假或模型加载失败时返回available:false,message形如「语义引擎不可用:<错误前 300 字>。插件已回退纯规则模式,不影响其他功能。 【解决:确认 lib/models/ 含 bge-small-zh 模型文件;首次使用需自动加载,稍后重试】」。失败不是永久态:失败时刻被记录,30 秒冷却后允许一次重载尝试,所有入口共享该冷却逻辑。 - 异常一律安全降级:
execute主体被 try 包裹,任何异常返回available:false与「语义检索安全降级:<异常前 120 字>(不影响其他工具)」,绝不向宿主裸抛。但参数缺失的校验在 try 之外:book/query为空会正常抛错,不会被伪装成「引擎不可用」。 - 索引缓存按内容失效,不靠版本号:章节文本、章节集或切块边界变化都会改变指纹并触发重建;同一轮工具调用内的多次索引读取有进程内读缓存(键为路径 + mtimeMs + size),写盘后立即失效。缓存文件体积随书增长,
items[].t只保留段落前 200 字。 - 索引与检索在同一目录下共享:普通检索用索引键
<book>;风格对比另有<book>__style_<章名>键。两者格式一致、互不覆盖,但语义引擎的临时索引(如单章范围)不写盘,以免用局部向量污染全书指纹缓存。 - 可检索范围:只覆盖
novels/<book>/下.md/.markdown/.txt章节文件,按scanChapters的文件名章号排序;创作资料、点号目录与其他扩展名不参与。段落下限 8 字意味着极短的对白碎片不会被索引。 - 分值语义:
score是 128 维降采样向量的余弦值,取值区间为 [−1, 1](中文段落向量实际多为正值),没有绝对阈值——本工具不判断「是否相关」,只按分值降序返回前top条。跨版本比较也不成立:v4.3.0 修改了内容指纹的字段定界,既存索引会一次性失效重建;同一查询在重建前后可能因同一原因取得不同分值与排序,属预期的一次性代价。 - 与在线模型无关:本工具不调用任何在线大模型,返回体里也不含模型生成的解释性文本;
message、query、text都是本地字符串。查询本身不会写回书库,检索结果不落盘(仅索引缓存落在.novel-writer/embedding/下)。
相关工具
- novel_style_check —— 用同一引擎把目标章与全书其他章的段落向量做平均余弦比较,输出语义风格相似度。
- novel_sentence_analysis —— 语义隐性情感(情感原型句检索全书索引)依赖同一份索引与切块规则。
- novel_books —— 确认
book的精确取值,避免因书名不匹配而落到空目录。 - novel_style_report —— 报告中的「语义风格距离」维度由同一引擎提供。
源码位置
- lib/index.js —— 工具注册、输出契约与执行(
registerNovelSemanticSearch,第 2435 行起;参数校验第 2489–2493 行、execute主体 2495 行起、render第 2478 行) - lib/embedding.js —— 模型探测
modelPresent(第 40 行)、initEngine(第 52 行)、isAvailable(第 104 行)、embed(第 112 行)、embedMany(第 137 行)、cosine(第 179 行)、rankIndex(第 211 行)、search(第 222 行)、fingerprint(第 338 行)、downsample(第 361 行)、readIndexData(第 380 行)、saveIndex(第 408 行)、loadIndexMeta(第 440 行)、buildIndexIncremental(第 463 行)、extractChapterTitle(第 503 行)、chunkText(第 520 行);常量MODEL_NAME/EMBED_DIM/ENGINE_RETRY_COOLDOWN_MS第 28–37 行 - lib/core.js ——
bookDir(第 233 行)、scanChapters(第 236 行)、readTextFile(第 258 行)、cleanOutput(第 2308 行)、语义门控semanticFeatureEnabled(第 852 行) - mcp/server.mjs —— MCP 形态下的
--root与src越界拒绝(第 130 行、第 259–281 行)