← dsh-novel-writer 技术文档 / novel_semantic_search

novel_semantic_search

语义检索。以自然语言描述为查询,在全书的段落向量中按余弦相似度召回最相关段落,返回章节定位、原文片段与相似度分值。全程本地推理:模型随包分发,由 onnxruntime-web 的 WASM 后端在宿主进程内运行,不发起网络请求、不消耗在线 token。索引按内容指纹增量构建并落盘缓存,章节改动后只对新段落重算向量。

参数

参数类型必填说明
bookstring书名。空串抛「novel_semantic_search 需要 book 参数」;目录解析经 bookDirsanitizeSegment,路径分隔符与上跳已剥离。
querystring检索用的语义描述。空串抛「novel_semantic_search 需要 query 参数」。自然语言越具体越好;超长查询在分词后按 512 token 截断。
topinteger返回条数。默认 5,区间 1–10;由 Math.min(Math.max(Number(top) || 5, 1), 10) 钳制,非数字或 0 回退 5,不抛错
rootstring章节库根目录。省略时取插件 config.root,再省略取会话工作目录;MCP 形态下必须落在启动参数 --root 指定的书库根内,越界会被静默回退到该书库根。

输出结构

契约字段(output.schema.required):bookqueryavailableresultsmessageavailable:falseresults 恒为空数组,全部信息在 message 里。

字段类型内容
availableboolean本次是否真正执行了检索
cachestring枚举 hit(复用落盘索引)/ built(本次重建或补齐索引);仅在 available:true 时出现
indexSizeinteger参与排序的段落向量条数(可能小于切块总数)
resultsarray按分值降序,元素为 {id, chapter, text, score}
results[].idstring章文件名 + "|" + 块序号(块序号来自 chunkText
results[].chapterstring章文件名;字段缺失时回退为 id 中 "|" 之前的部分,再回退为「全书」
results[].textstring段落原文,已去除 \r 并截断到 200 字
results[].scorenumber余弦相似度,保留 4 位小数(round(score × 10000) / 10000
messagestring命中索引时为「使用本地语义索引(缓存)」;本次构建时为「首次建索引完成,已缓存」,并可追加补齐与写盘失败提示

渲染层输出为「《书名》语义检索「查询」(命中 N 段,索引 M 段)」加逐行 - [score] 章文件名: 段落文本available:false 时只输出标题加「(不可用)」与 message。检索结果不做二次重排或去重:同一章的相邻块可能同时进入前 top,调用方需要按章聚合时应自行处理。

计算原理

1 · 模型与推理路径

模型:lib/models/ 下的 bge-small-zh-v1.5(onnx/model_quantized.onnx 优先,回退 model.onnx) 分词:@huggingface/tokenizers(WASM,294KB);tokenizer.json 缺失时读 tokenizer.json.gz 解压 推理:onnxruntime-web 的 WASM 后端,numThreads = 1,wasmPaths 由 require.resolve 定位 可用性探测 modelPresent() 要求同时存在: onnx/model_quantized.onnx 或 onnx/model.onnx tokenizer.json 或 tokenizer.json.gz tokenizer_config.json

选择 WASM 直连而非子进程或原生 .node 绑定,是为了避免宿主 ABI 冲突与进程崩溃风险:推理在宿主进程内完成,内存安全,无子进程。模型与分词器均为懒加载单例,第一次调用才载入。

2 · 向量化

embed(text): trim 后为空 → 返回 null(确定性跳过,不进重试队列) 文本先截到 4000 字符,再按 token 截到 512(bge 上限) input_ids / attention_mask / token_type_ids 三个 int64 张量送入会话 取 last_hidden_state 的前 dim 维(CLS 汇聚)→ L2 归一化 → 返回单位向量 范数非有限或为 0 时按 1 处理 embedMany(chunks):并发 4;抛错的块整体重试一轮,仍失败则剔除(返回条数可能少于输入条数)

批量推理按 id 回填向量而非按下标——失败项被过滤后会缩短数组,按下标回填会造成向量与段落错位。

3 · 切块与章节标记

chunkText(text): 归一 CRLF / CR / U+2028 / U+2029 / U+0085 为 \n 识别章节标题行(整行 ≤ 40 字,且匹配 第[零一二三四五六七八九十百千两0-9\d]{1,8}[章节回卷部] / Chapter N / 序章|序言|楔子|引子|尾声|终章|番外) 命中则切分当前块并切换 chapter 标记;标题行本身不进正文 单行超 150 字按 [\s\S]{1,150} 切片;再按「先切满 150 字、余量留到下一块」累积 flush 时机:空行 / 章节切换 / 文本结束 过滤:trim 后长度 ≥ 8;但已识别章节内只要有内容就保留(短章节不被吞掉) id = chapter + "#" + 块序号;chapter 无标题时默认为「全书」

超长行按 150 字硬切是必要的:PDF 或网页复制的无换行文本若整行进模型,会在 512 token 处静默截断,尾部内容丢失。切块长度上限由「累加后判断」改为「切满再留余量」后恒定 ≤ 150 字。

4 · 余弦排序

cosine(a, b):长度不等 / 空数组 / 任一范数为 0 / 结果非有限 → 返回 0 rankIndex(queryVec, index, k): dim = index 中 vec 长度的众数 queryVec 长度 ≠ dim 时先降采样到 128 维 对每条算 cosine → 按 score 降序 → 取前 k search(query, index, k) = embed(query) 后交给 rankIndex;无向量或索引为空返回 []

索引向量的存储维度是 128:模型直出 512 维后按「每 4 维取 1」降采样并重新 L2 归一化,落盘时再按 4 位小数取整。该降采样是有损压缩,用于缓存瘦身与维度统一,不保证完整语义信息保持;已是 128 维的旧缓存只做归一化,不二次降采样。

5 · 索引缓存与增量构建

缓存路径:<root>/.novel-writer/embedding/<book 清洗后>.json 内容:{ model: "bge-small-zh-v1.5", dim: 128, fp, items: [{id, t, th, ch, v}] } 写盘:zstd 压缩(Node 不支持 zstd 时回退纯 JSON);t 截断 200 字;v 取 4 位小数 fingerprint(chunks) = sha1( 逐块 "id长度:id" + \0 + "text长度:text" + \1 ) 长度前缀用于消解边界歧义(字段自身含分隔符时不会产生同指纹) buildIndexIncremental(chunks, oldItems): 逐块计算 th = sha1(text) 前 16 位 旧条目 id 相同且 th 相同 → 直接复用旧向量 其余(新增或内容变化)→ 送入 embedMany 输出统一 128 维,按 id 回填后过滤空项 loadIndexMeta 校验:model 与 dim 必须匹配;任一条目 vec 长度 ≠ 128 视为缓存损坏 → 视为未命中
fingerprint 用「实际写入条目」重算。 若部分段落推理失败被剔除,落盘指纹必然与「全量内容指纹」不同,下次调用即判为 cachedFp !== indexFp 并走增量补齐。message 中会带出「(部分段落失败,下次自动补齐 N/M)」或「;索引缓存写入失败(下次会重建)」,使残缺状态对调用方可见,而不是被当作完整缓存永久复用。

约束与边界

  • 文本截断上限 512 tokenembed 先按 4000 字符再按 512 token 截断。单块文本受 chunkText 的 150 字上限约束,正常路径不会触顶;query 是唯一可能触顶的输入,超长查询只有前 512 token 参与匹配,且不会报错。
  • top 是钳制而非报错top: 50 得到 10 条,top: 0top: "abc" 得到 5 条。调用方无法通过本参数取得 10 条以上的结果,需要更多召回时应换更窄的查询而非扩大 top。
  • 两层功能门控semanticSearch 未开(或总开关 semanticEmbedding 关闭)时返回 available:falsemessage 为「「语义检索」已关闭:请在侧边栏「写作助手功能」→ 小模型页开启,或用 novel_sentence_config 设置 semanticSearch:true。」——这是配置状态,不是错误。
  • 引擎不可用的降级文案是固定的modelPresent() 为假或模型加载失败时返回 available:falsemessage 形如「语义引擎不可用:<错误前 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 修改了内容指纹的字段定界,既存索引会一次性失效重建;同一查询在重建前后可能因同一原因取得不同分值与排序,属预期的一次性代价。
  • 与在线模型无关:本工具不调用任何在线大模型,返回体里也不含模型生成的解释性文本;messagequerytext 都是本地字符串。查询本身不会写回书库,检索结果不落盘(仅索引缓存落在 .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 形态下的 --rootsrc 越界拒绝(第 130 行、第 259–281 行)