dsh-kb-lite
Verifieddsh-kb-lite · v0.3.0 · MIT
Local knowledge base for DeepSeek Harness: ingest Markdown/TXT/PDF, hybrid BM25 + optional dense retrieval, explainable ranking weights (bm25/dense/RRF/source boost/recency), cited answers. Host-only plugin, five tools, no network.
Install
dsh plugin add dsh-kb-lite Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-kb-lite
DeepSeek Harness 的本地知识库:把 Markdown、纯文本和 PDF 入库,用 BM25 或可选的稠密检索取回原文段落,并把模型能直接引用的出处交给它——文件、标题、行号或页码。
English | 简体中文
MIT。宿主侧插件,五个工具,没有浏览器界面,引擎不打开任何 socket。BM25 只依赖 Python 标准库。
为什么还要另做一个
dsh-kb-rag 是现有插件里最接近的一个,它在 DSH 0.2.0-rc.2 上装不上。它声明了指向 @deepseek-ai/dsh-client-runtime 的 dsh.client.inject,而 0.2.0-rc.2 的客户端基线里没有这个包。同时它把 @deepseek-ai/dsh-tools 钉在 ^0.1.0-rc.6,宿主是 0.2.0-rc.2 时 DSH 自己的版本闸门拒绝的正是这个范围。迄今发布的 22 个版本都带同一个 peer 范围,没有可以退回去的旧版本。
本插件站在这条取舍的另一边:只有宿主侧,不带客户端包,不做客户端注入,peer 范围写成带显式预发布分支的形式,让 0.2.0-rc.x 真的能匹配上。dsh-kb-rag 的思路本身没有问题——卡住它的是客户端依赖和 peer 范围,不是功能。如果将来出现面向 0.2.x、且不再注入 dsh-client-runtime 的版本,它覆盖的范围和这个插件大部分重叠,没有理由优先选这个。
范围是有意做小的:五个工具、一个本地索引目录、一条在从没装过 torch 的机器上也能跑的 BM25。
特性
- 五个工具:
kb_ingest、kb_search、kb_ask、kb_stats、kb_clear。 - 没有浏览器界面,也没有客户端包。
package.json里刻意不写dsh.client字段:插件只加宿主侧工具,所以 DSH 改客户端 API 时弄不坏已经装好的副本。这一点重要,因为客户端那一半一旦坏了会把整个 Web 界面一起带下去——DSH 的启动是全或无。 - 输入:
.md、.markdown、.txt、.pdf。PDF 需要pymupdf;没有时每个 PDF 作为逐文件错误返回,批次其余文件照常入库。其它扩展名不读:目录遍历会跳过,显式点名的文件会以unsupported_extension报错。 - 支持递归目录——隐藏目录以及
.git、node_modules、.venv、__pycache__一类的目录会跳过。按sha256增量:未变化的文件跳过,内容变化的重新索引,force: true无条件重解析。 max_files限制单次调用处理的文件数;超出的部分作为pending返回,下次调用接着做。- BM25 不依赖任何第三方库,含汉字二元组分词。
- 可选的稠密检索走
sentence-transformers和BAAI/bge-small-zh-v1.5;hybrid用倒数排名融合(RRF)合并两路结果。 - 引用信息:
path、title、heading,Markdown 与纯文本有start_line/end_line,PDF 有page,matched_by说明这一块是哪一路检回的。 kb_ask在字符预算内返回原文,不改写、不摘要、不润色。- 一个知识库一个目录。没有数据库服务,没有守护进程,不占端口。
安装
dsh plugin --profile web add dsh-kb-lite
或者直接从 GitHub 装:
dsh plugin --profile web add github:imaused/dsh-kb-lite
把 imaused 换成发布这个仓库的 GitHub 账号。如果你的 profile 不叫 web,换成自己的名字(例如 --profile desktop)。
装完重启 DSH,并开一个新会话。工具是在会话创建时绑定的;已经开着的会话看不到它们。
工具
| 工具 | 做什么 | 参数 |
|---|---|---|
kb_ingest |
索引文件或目录 | paths、recursive、force、max_files、with_embeddings、kb_root |
kb_search |
返回带片段的打分结果 | query、top_k、mode(auto、bm25、dense、hybrid)、kb_root |
kb_ask |
在字符预算内返回可供模型引用的原文段落 | query、top_k、max_chars、kb_root |
kb_stats |
文档数、块数、kb_root、占用大小、updated_at,最多 50 条已索引路径 |
kb_root |
kb_clear |
删除索引 | confirm: true、kb_root |
每个工具都接受可选的 kb_root,只对本次调用生效。传给 kb_ingest 的路径必须是绝对路径。
默认值:recursive: true、force: false、max_files: 50(限制在 1–500)、with_embeddings: "auto",kb_search 的 top_k 默认 8、kb_ask 默认 5(都限制在 1–50),max_chars 默认 6000。with_embeddings 接受 "auto"(有稠密依赖时算向量)、"on" 和 "off";只想用 BM25 时传 "off" 可以跳过这次入库的向量计算。
快速开始
下面都是对话里直接输入的提示词。每条都点名了工具和参数,这是拿到确定性调用最可靠的方式。
索引一个文档目录:
用 kb_ingest 把 D:/docs 入库:paths ["D:/docs"],recursive: true,max_files: 50。返回里会有
added/updated/skipped计数、逐文件的errors;如果目录里的受支持文件超过 50 个,还会有pending。路径必须是绝对路径:相对路径不会按工作目录解析,只会尝试拼到你的主目录上,好让报错说清楚到底试了哪个路径。提问并拿到可引用的段落:
用 kb_ask 从知识库回答:query "重试策略是怎么退避的",top_k: 5,max_chars: 6000。引用原文,并标出 path 和行号。只用 BM25 检索——对比两种模式时很有用:
用 kb_search 检索:query "BM25 k1 b",top_k: 8,mode: "bm25"。看看索引里到底有什么:
运行 kb_stats,告诉我 kb_root、文档数、块数、dense 是否可用,以及已索引的路径。清掉一个临时索引:
用 kb_clear 清空 kb_root "D:/kb/scratch",confirm: true。这会删掉该
kb_root下的索引文件——manifest.json、chunks.jsonl以及可能存在的向量文件。不会动你的原始文档;engine.log会留下。
配置
| 设置 | 默认值 | 作用 |
|---|---|---|
KB_LITE_PYTHON |
未设置——依次尝试 python、python3、py |
启动引擎进程用的解释器。找到的不是装了 pymupdf 或 sentence-transformers 的那个,或者这些名字都解析不到时,设它。 |
KB_LITE_ROOT |
~/.kb-lite |
调用没有传 kb_root 时使用的默认目录。~ 是用户主目录(os.homedir()),Windows 上例如 C:/Users/you/.kb-lite。 |
KB_LITE_MODEL |
BAAI/bge-small-zh-v1.5 |
稠密检索可用时使用的嵌入模型。 |
kb_root(单次调用) |
— | 覆盖 KB_LITE_ROOT,只对本次调用生效。 |
优先级:单次调用的 kb_root > KB_LITE_ROOT > ~/.kb-lite。
环境变量要在启动 DSH 之前设好,在会话里设不生效。
检索是怎么工作的
BM25。 纯 Python 实现,不用任何库。分词是拉丁词(转小写,[a-z0-9_]+)加汉字二元组(相邻两字),再加单字兜底,参数为 k1 = 1.2、b = 0.75。这是永远可用的模式:什么都没装,BM25 仍然是一套完整的、带排序的关键词检索。
稠密检索。 当 numpy 可导入、sentence-transformers 能加载时,引擎用 BAAI/bge-small-zh-v1.5(可用 KB_LITE_MODEL 改)给块算向量,并在查询侧加上该模型训练时用的指令前缀 为这个句子生成表示以用于检索相关文章:。稠密检索能找到与原文不共用一个词的改写问法。向量是在入库时算的,所以在装这些可选依赖之前建的索引没有向量——装好之后重新入库(force: true,或者先 kb_clear)才会有稠密结果。
混合检索。 倒数排名融合,k = 60:两路各取 top_k * 4 个候选,按融合分排序。每条命中的 matched_by 记录它是被 BM25、稠密检索还是两者一起找到的。
mode: "auto" 在有稠密时走 hybrid,否则走 bm25,每条结果都带 mode_used,不用猜实际跑的是哪条路。显式模式也按同样方式降级:机器上没有那套依赖,或者索引里没有向量时,dense 或 hybrid 会退回 BM25 而不是报错,并在 mode_used 里说明。kb_ask 没有 mode 参数,选择方式相同,也会报 mode_used。
实测(2026-10-23,45 篇 / 334 块,18 条查询):BM25 的 MRR 0.844、Recall@3 89%;稠密 0.861、Recall@3 100%;hybrid 0.872、94%。所以即使只有 45 篇,稠密也值得装——差距正好落在 BM25 看不见的换说法查询上(问「插件把界面搞坏」,原文只写「客户端注入」)。代价是延迟:CPU 上 BM25 约 70ms,稠密约 900ms(模型一次性加载之后)。同一个集合上重排实测更差(0.756):cross-encoder 会把"哪儿都提到关键词"的索引类文档顶上来;weights.rerank 保留但默认关闭。要判断自己的语料,同一个查询跑两遍——mode: "bm25" 和 mode: "hybrid"——对比命中即可。
分块决定了引用能不能落地。Markdown 按 ATX 标题切节,单节超过 1200 字符再按空行或句末切,重叠 120 字符。纯文本按空行切分,贪心合并到不超过 1200 字符,重叠同样是 120。PDF 按页取文本,再用同样的规则切,page 从 1 开始。每块都记录 start_line 和 end_line——md/txt 是原文行号,PDF 是该页内的行号——kb_ask 的输出之所以能直接引用,靠的就是这两个字段。
排序权重(0.2.0)
每个命中都带一个 score_detail,排名不再是黑箱:
{"bm25": 23.4, "dense": 0.81, "rrf": 0.032, "source_boost": 1.4,
"boost_rule": "memory/", "recency": 0.87, "age_days": 6.2, "final": 28.5}
bm25/dense 是两路原始分,rrf 是融合后的名次分,source_boost 是命中的路径乘数,recency 是时间衰减,final 是真正用来排序的数(score 等于 final)。
kb_search 和 kb_ask 都接受可选参数 weights,四项都能调:
{"bm25": 1.0, "dense": 0.6, "rrf_k": 60,
"boost": {"memory/": 1.4, "_templates/": 0.2, "kb/README.md": 1.5},
"recency": {"half_life_days": 30, "min": 0.25, "max": 1.0}}
bm25/dense:两路各自的权重;hybrid 时按加权 RRF 融合,用来表达"更信关键词还是更信语义"。boost:路径子串 → 乘数,最长匹配生效,所以文件级规则能压过目录级规则。recency:按文档年龄衰减0.5 ** (age_days / half_life_days),夹在[min, max]。适合"新结论该压过旧结论"的记忆/知识目录。
不传 weights 时行为与旧版完全一致。
存储布局
所有东西都在一个目录里,即 kb_root(默认 ~/.kb-lite,也就是放在用户主目录下,而不是当前工作目录——以桌面方式启动时 cwd 并不可靠):
kb_root/
manifest.json # {version, docs: {<doc_id>: {path, sha256, mtime, size, title, kind, pages, chunks}}, updated_at}
chunks.jsonl # 每行一个 JSON:{chunk_id, doc_id, ordinal, heading, start_line, end_line, page, text}
vectors.npy # 可选;行序与 chunks.jsonl 一致;float32
vectors.json # 没有 numpy 时的降级存储;与 .npy 二选一,不会同时存在
engine.log # 引擎日志;引擎的 stderr 也镜像到这里
doc_id = 规范化路径 sha1 的前 16 位;chunk_id = <doc_id>:<ordinal>。manifest.json 和 chunks.jsonl 都是纯文本——可以直接读、直接 diff,也可以拿来核对到底入库了什么。
索引是派生数据。要重置就删掉 kb_root 再重新入库,不要手改这些文件;kb_clear 就是替你删。改了 KB_LITE_MODEL 之后,已存的向量来自旧模型,应当重建(kb_clear,再入库)。
环境要求
- Node.js 18 或更新——插件的宿主侧。
- Python 3.9 或更新——运行引擎。
- 跑 BM25 不需要别的东西。
- 可选项,PDF 需要:
pymupdf(pip install pymupdf)。 - 可选项,稠密检索需要:
numpy和sentence-transformers(pip install numpy sentence-transformers),装进插件实际启动的那个解释器(见KB_LITE_PYTHON)。 - 不需要数据库、不需要服务、不需要 API key。
已知限制
以下都是已知的,没有藏在配置项后面:
- 没有重排器。 hybrid 只是把 BM25 与稠密两路排序融合,融合之后不再重新打分。
- 不处理 DOI,也不生成可点击的引用。 PDF 里的 DOI 就是普通文本。
kb_ask给你 path、行号和页码,不是一个能点开的文献链接。 - 不能导入 Zotero(或任何文献管理库)。 入口只有磁盘上的文件,把
kb_ingest指过去。 - 没有 OCR。 扫描版 PDF 没有文本层,抽不出任何东西。先自己做 OCR,再入库文本。
- 向量文件整体重写,不做增量更新。 每次入库都会按新的块列表重写
vectors.npy/vectors.json;未变化的块复用已有向量,但没有追加写,也没有后台压缩。 - BM25 索引是每次查询时重建的,不做缓存。 目标规模(几百个文件、几千个块)下没问题,换来的是磁盘格式足够简单;它不是为十万级块设计的。本机实测:25 个块的语料查询在毫秒级返回,代价随语料增长,而不是随查询增长。
- 块大小固定。 1200 字符、重叠 120,没有单次调用或单文件的覆盖开关。
- 只认四种扩展名。
.md、.markdown、.txt、.pdf。DOCX、HTML、EPUB 不读。 - 引擎是每个插件实例一个进程,不是共享守护进程。 每个加载了插件的 DSH 实例都会起自己的
python engine/kb_engine.py。两个 profile 就是两个进程;开着稠密检索时,各自加载一份嵌入模型,不是一个共享服务。
隐私
- 引擎不打开任何 socket,不发网络请求。没有遥测,没有更新检查,没有远程索引。
- 它唯一常驻的进程是每个插件实例一个本地
python engine/kb_engine.py;启动前还会跑一次一次性的解释器探测,详见SECURITY.md。 - 读什么:你传给
kb_ingest的文件,以及它自己的kb_root。 - 写什么:只写
kb_root里面。 - 不读也不存任何凭据。没有 key、没有 token、没有账号。
有一个容易搞错的例外要讲清楚:如果装了可选的稠密依赖,第一次运行时从 Hugging Face 下载 BAAI/bge-small-zh-v1.5 的是 sentence-transformers(不是本插件),除非模型已经在本机缓存里。在不能出网的机器上,要么先把缓存准备好,要么不要装稠密那一套。
SECURITY.md 里有精确的 argv、完整的读写边界,以及你可以自己跑的核对命令。
状态
版本 0.1.0。在 Windows 11、DSH 0.2.0-rc.2、Node 24、Python 3.14 上测过。Linux 与 macOS 未测试。
这个插件怎么提交到插件列表,写在 docs/submission.md 里。
排错
工具报 python_not_found、python_start_failed 或 engine_dead。 python_not_found 表示所有候选解释器都没应答;python_start_failed 表示找到了一个但退出码非零。启动引擎之前,插件会先跑 python -c "import sys; sys.stdout.write('ok')" 做一次探测,所以解释器存在但坏掉的情况会卡在这一步。先确认解释器:
python --version
然后用 KB_LITE_PYTHON 指到一个确定可用的解释器,重启 DSH。Windows 上如果 python 打开的是微软商店而不是解释器,写真实路径:
KB_LITE_PYTHON=C:/Users/you/AppData/Local/Programs/Python/Python314/python.exe
想知道引擎本身能不能跑,手工起一次并发一条请求:
echo '{"id":"1","op":"ping","params":{}}' | python engine/kb_engine.py
应该先看到一个 ready 行,然后是 {"id":"1","ok":true,"result":{"pong":true,...}}。这一步能过,问题就在插件使用的路径上,不在引擎;过不去,就看 kb_root 下的 engine.log。
PDF 被忽略或报错。 PDF 支持需要同一个解释器里有 pymupdf:
python -c "import fitz; print(fitz.__doc__)"
缺模块会以逐文件错误出现在 errors[] 里,错误码 pdf_unavailable,批次其余文件照常入库。能导入、但切出 0 个块的 PDF,多半是扫描件——没有文本层,也没有 OCR。
稠密检索不可用。 kb_stats 会报 dense: false,mode: "auto" 就停在 BM25。把依赖装进插件实际启动的那个解释器:
python -m pip install numpy sentence-transformers
然后重启 DSH。模型第一次使用可能需要下载,见上面「隐私」。没有这套依赖时,mode: "dense" 和 mode: "hybrid" 会静默退回 BM25,并在 mode_used 里报 "bm25"——不会出错,但也不会有稠密检索。
索引不在我以为的地方。 kb_stats 会返回当前使用的 kb_root。默认是 ~/.kb-lite;KB_LITE_ROOT 或单次调用的 kb_root 可以改。拿不准就传绝对路径。
中文回来是乱码。 在中文 Windows 控制台下,Python 的 stdout 默认是 GBK 而不是 UTF-8:
python -c "import sys; print(sys.stdout.encoding)"
输出 gbk 是正常的,这也正是引擎不信任控制台的原因:它在启动时把自己的 stdin/stdout/stderr 重设为 UTF-8,插件启动它时也带上了 PYTHONIOENCODING=utf-8 和 PYTHONUTF8=1。所以出现乱码说明你面对的不是插件启动的那个引擎——查 engine.log,并确认 KB_LITE_PYTHON 指的就是你以为的那个解释器。
入库没做完就停了。 是 max_files 截断的。返回里的 pending 是剩下的数量;对同样的路径再调一次 kb_ingest(已入库的文件会按 sha256 跳过,代价很小)。
许可证
MIT,见 LICENSE。