🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

本地知识检索引擎 ang-XWBWZ/Pwiki

DeepSeek Harness
ang-XWBWZ67 天前有提交已提供中文文档扫描:高危

用 BM25 与语义混合搜索,为 AI 代理检索本地 Markdown 知识

29.6/100
综合分
2
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/9/16 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

自动兼容检查未通过:缺少 main/exports/bin 入口声明;仓库 package.json 标记 private,未发布到 npm,需从源码安装。以下命令仍可尝试,但可能失败。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add ang-XWBWZ/Pwiki
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · search
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 67 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装

缺少 main/exports/bin 入口声明;仓库 package.json 标记 private,未发布到 npm,需从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包pwiki(未发布到 npm,仅可源码安装)
✓Node 引擎要求 >=22 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✗入口文件缺少入口声明

源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/9/22 00:11:36

🔴 安全扫描发现高危能力自动 · 静态分析源码
v2.7.0
🔴 扫描:高危尚无人工评估

静态扫描发现安装期即执行代码,或命令参数由运行时变量拼接(参数来源无法静态判定,从严处理)。

命令参数由运行时变量拼接读写/删除本地文件读取剪贴板或密钥环境变量发起外部网络请求
查看扫描证据(共 111 处,展示前 100 条)
  • 读取本地文件
    api/src/service.ts:218
    if (!existsSync(absolutePath) || !statSync(absolutePath).isDirectory()) {
  • 读取本地文件
    api/src/service.ts:218
    if (!existsSync(absolutePath) || !statSync(absolutePath).isDirectory()) {
  • 执行外部命令固定命令
    cli/src/index.ts:17
    try { execSync("chcp 65001 > nul", { stdio: "pipe", timeout: 2000 }); } catch {}
  • 读取本地文件
    cli/src/index.ts:23
    return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version || "1.0.0";
  • 读取本地文件
    cli/src/index.ts:115
    if (!existsSync(abs) || !statSync(abs).isDirectory()) {
  • 读取本地文件
    cli/src/index.ts:115
    if (!existsSync(abs) || !statSync(abs).isDirectory()) {
  • 读取本地文件
    core/src/WikiEngine.ts:128
    if (!existsSync(absolute) || !statSync(absolute).isDirectory()) return false;
  • 读取本地文件
    core/src/WikiEngine.ts:128
    if (!existsSync(absolute) || !statSync(absolute).isDirectory()) return false;
  • 读取本地文件
    core/src/WikiEngine.ts:335
    try { content = readFileSync(resolveWithinSource(source.path, entry.relPath), "utf-8"); }
  • 读取本地文件
    core/src/WikiEngine.ts:357
    try { content = readFileSync(resolve(entry.sourceDir, entry.relPath), "utf-8"); }
  • 读取本地文件
    core/src/WikiEngine.ts:414
    if (existsSync(finalAbs)) return "exists: " + finalAbs;
  • 写入本地文件
    core/src/WikiEngine.ts:426
    mkdirSync(dirname(finalAbs), { recursive: true });
  • 写入本地文件
    core/src/WikiEngine.ts:427
    writeFileSync(finalAbs, fullContent, "utf-8");
  • 读取本地文件
    core/src/WikiEngine.ts:450
    if (!existsSync(fullPath)) return false;
  • 写入本地文件
    core/src/WikiEngine.ts:468
    writeFileSync(fullPath, newContent, "utf-8");
  • 写入本地文件
    core/src/WikiEngine.ts:497
    mkdirSync(dirname(dstFile), { recursive: true });
  • 写入本地文件
    core/src/WikiEngine.ts:498
    renameSync(srcFile, dstFile);
  • 删除本地文件
    core/src/WikiEngine.ts:529
    try { unlinkSync(fullPath); } catch { return false; }
  • 写入本地文件
    core/src/WikiEngine.ts:570
    writeFileSync(fullPath, content, "utf-8");
  • 读取密钥环境变量
    core/src/WikiEngine.ts:802
    const apiKey = process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || "";
  • 读取密钥环境变量
    core/src/WikiEngine.ts:803
    const apiBase = process.env.LLM_API_BASE || (process.env.DEEPSEEK_API_KEY && !process.env.OPENAI_API_KEY
  • 读取密钥环境变量
    core/src/WikiEngine.ts:805
    : process.env.OPENAI_API_KEY ? "https://api.openai.com/v1" : "");
  • 读取密钥环境变量
    core/src/WikiEngine.ts:893
    const actualKey = process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || "";
  • 发起网络请求
    core/src/WikiEngine.ts:917
    const res = await fetch(apiBase + "/chat/completions", {
  • 写入本地文件
    core/src/config.ts:61
    mkdirSync(d, { recursive: true });
  • 写入本地文件
    core/src/config.ts:70
    mkdirSync(d, { recursive: true });
  • 写入本地文件
    core/src/config.ts:92
    mkdirSync(d, { recursive: true });
  • 写入本地文件
    core/src/config.ts:101
    mkdirSync(d, { recursive: true });
  • 写入本地文件
    core/src/config.ts:129
    mkdirSync(d, { recursive: true });
  • 读取本地文件
    core/src/config.ts:144
    if (!existsSync(d)) mkdirSync(d, { recursive: true });
  • 写入本地文件
    core/src/config.ts:144
    if (!existsSync(d)) mkdirSync(d, { recursive: true });
  • 读取本地文件
    core/src/lib/bm25-sqlite.ts:126
    if (!existsSync(path)) return false;
  • 读取本地文件
    core/src/lib/bm25-sqlite.ts:146
    if (!existsSync(docsPath) || !existsSync(termsPath) || !existsSync(metaPath)) return null;
  • 读取本地文件
    core/src/lib/bm25-sqlite.ts:149
    const meta = JSON.parse(readFileSync(metaPath, "utf-8")) as {
  • 读取本地文件
    core/src/lib/bm25-sqlite.ts:155
    const docs = JSON.parse(readFileSync(docsPath, "utf-8")) as Record<string, Bm25DocRecord>;
  • 读取本地文件
    core/src/lib/bm25-sqlite.ts:156
    const terms = JSON.parse(readFileSync(termsPath, "utf-8")) as Record<string, Bm25TermEntry>;
  • 读取本地文件
    core/src/lib/bm25-sqlite.ts:173
    if (existsSync(path)) return false;
  • 读取本地文件
    core/src/lib/bm25.ts:123
    if (existsSync(compiledPath)) {
  • 读取本地文件
    core/src/lib/bm25.ts:124
    const record = JSON.parse(readFileSync(compiledPath, "utf-8"));
  • 读取本地文件
    core/src/lib/bm25.ts:512
    if (!existsSync(fullPath)) return null;
  • 读取本地文件
    core/src/lib/bm25.ts:513
    try { return readFileSync(fullPath, "utf-8"); } catch { return null; }
  • 读取本地文件
    core/src/lib/embedder.ts:30
    if (!existsSync(dir)) return false;
  • 读取本地文件
    core/src/lib/embedder.ts:31
    if (!existsSync(resolve(dir, "config.json"))) return false;
  • 读取本地文件
    core/src/lib/embedder.ts:32
    if (!existsSync(resolve(dir, "onnx"))) return false;
  • 读取本地文件
    core/src/lib/embedder.ts:34
    return existsSync(resolve(onnxDir, "model.onnx"))
  • 读取本地文件
    core/src/lib/embedder.ts:35
    || existsSync(resolve(onnxDir, "model_quantized.onnx"));
  • 读取本地文件
    core/src/lib/embedder.ts:46
    if (existsSync(quantFile)) { variant = "int8"; onnxFile = quantFile; }
  • 写入本地文件
    core/src/lib/embedder.ts:92
    mkdirSync(onnxDir, { recursive: true });
  • 执行外部命令运行时拼接
    core/src/lib/embedder.ts:101
    execSync(`curl -L -f -s -o "${out}" "${baseUrl}/${rel}"`, { timeout: 600_000 });
  • 读取本地文件
    core/src/lib/file-manifest.ts:51
    if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
  • 读取本地文件
    core/src/lib/file-manifest.ts:51
    if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
  • 写入本地文件
    core/src/lib/file-manifest.ts:57
    writeFileSync(manifestFile(sourceId), JSON.stringify(m, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/file-manifest.ts:178
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  • 写入本地文件
    core/src/lib/file-manifest.ts:178
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  • 读取本地文件
    core/src/lib/indexer-compile.ts:54
    if (!existsSync(fullPath)) continue;
  • 读取本地文件
    core/src/lib/indexer-compile.ts:234
    try { currentMD5 = computeMD5(readFileSync(fullPath, "utf-8")); } catch { return false; }
  • 读取本地文件
    core/src/lib/indexer-compile.ts:245
    if (computeMD5(readFileSync(fullPath, "utf-8")) !== currentMD5) return false;
  • 写入本地文件
    core/src/lib/indexer-compile.ts:274
    writeFileSync(compiledFile, JSON.stringify(record, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/indexer-embed.ts:52
    const raw = readFileSync(filePath, "utf-8");
  • 读取本地文件
    core/src/lib/indexer-embed.ts:75
    const raw = readFileSync(filePath, "utf-8");
  • 读取本地文件
    core/src/lib/indexer-embed.ts:193
    try { currentContent = readFileSync(fullPath, "utf-8"); } catch { continue; }
  • 读取本地文件
    core/src/lib/indexer-embed.ts:234
    try { latestContent = readFileSync(fullPath, "utf-8"); } catch { continue; }
  • 读取本地文件
    core/src/lib/indexer-embed.ts:321
    if (!existsSync(fullPath)) return false;
  • 读取本地文件
    core/src/lib/indexer-scan.ts:26
    items = await readdir(dir);
  • 读取本地文件
    core/src/lib/indexer-scan.ts:34
    st = await stat(full);
  • 读取本地文件
    core/src/lib/indexer-scan.ts:46
    setContent(entry.relPath, readFileSync(full, "utf-8"), entry.sourceDir);
  • 读取本地文件
    core/src/lib/parser.ts:21
    const raw = readFileSync(filePath, "utf-8");
  • 读取本地文件
    core/src/lib/parser.ts:57
    const finalMtime = mtime ?? statSync(filePath).mtime.toISOString();
  • 读取本地文件
    core/src/lib/reranker-config.ts:20
    if (!existsSync(path)) return {};
  • 读取本地文件
    core/src/lib/reranker-config.ts:21
    const parsed = JSON.parse(readFileSync(path, "utf-8"));
  • 写入本地文件
    core/src/lib/reranker-config.ts:69
    writeFileSync(configFile(), JSON.stringify(document, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/reranker.ts:82
    if (existsSync(path)) content = readFileSync(path, "utf-8");
  • 读取本地文件
    core/src/lib/reranker.ts:82
    if (existsSync(path)) content = readFileSync(path, "utf-8");
  • 读取本地文件
    core/src/lib/search.ts:72
    if (!existsSync(fullPath)) return null;
  • 读取本地文件
    core/src/lib/search.ts:73
    try { return readFileSync(fullPath, "utf-8"); } catch { return null; }
  • 读取本地文件
    core/src/lib/search.ts:199
    if (existsSync(fullPath)) {
  • 读取本地文件
    core/src/lib/search.ts:200
    try { content = readFileSync(fullPath, "utf-8"); } catch { }
  • 写入本地文件
    core/src/lib/source-shard.ts:114
    writeFileSync(sourceIndexFile(source.id), JSON.stringify(data, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/source-shard.ts:139
    if (!existsSync(file)) return {};
  • 读取本地文件
    core/src/lib/source-shard.ts:140
    const parsed = JSON.parse(readFileSync(file, "utf-8")) as SourceIndexData;
  • 读取本地文件
    core/src/lib/source-shard.ts:148
    return existsSync(join(sourceShardPath(sourceId), "index.json"));
  • 删除本地文件递归
    core/src/lib/source-shard.ts:158
    rmSync(target, { recursive: true, force: true });
  • 读取本地文件
    core/src/lib/store-config.ts:29
    if (existsSync(p)) return { ...DEFAULT, ...JSON.parse(readFileSync(p, "utf-8")) };
  • 读取本地文件
    core/src/lib/store-config.ts:29
    if (existsSync(p)) return { ...DEFAULT, ...JSON.parse(readFileSync(p, "utf-8")) };
  • 写入本地文件
    core/src/lib/store-config.ts:35
    writeFileSync(configFile(), JSON.stringify(c, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/store-index.ts:18
    if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
  • 读取本地文件
    core/src/lib/store-index.ts:18
    if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
  • 写入本地文件
    core/src/lib/store-index.ts:24
    writeFileSync(indexFile(), JSON.stringify(idx, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/store-index.ts:94
    if (existsSync(p)) {
  • 读取本地文件
    core/src/lib/store-index.ts:95
    const stats = JSON.parse(readFileSync(p, "utf-8"));
  • 写入本地文件
    core/src/lib/store-index.ts:103
    writeFileSync(bm25StatsFile(), JSON.stringify(stats, null, 2), "utf-8");
  • 读取本地文件
    core/src/lib/store-vectors.ts:14
    if (existsSync(p)) {
  • 读取本地文件
    core/src/lib/store-vectors.ts:15
    const v: EmbeddingData = JSON.parse(readFileSync(p, "utf-8"));
  • 读取本地文件
    core/src/lib/store-vectors.ts:149
    if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) as EmbeddingData;
  • 读取本地文件
    core/src/lib/store-vectors.ts:149
    if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) as EmbeddingData;
  • 写入本地文件
    core/src/lib/store-vectors.ts:155
    writeFileSync(vectorFile(sourceId), JSON.stringify(data), "utf-8");
  • 读取本地文件
    mcp/src/index.ts:244
    if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
  • 读取本地文件
    mcp/src/index.ts:244
    if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
  • 读取本地文件
    webpage/src/server.ts:96
    try { stat = statSync(target); }
  • 读取本地文件
    webpage/src/server.ts:106
    createReadStream(target).pipe(response);
扫描于 2026/11/4(0 天前) · 范围 全包扫描 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • BM25与语义混合搜索
  • 支持重排序与MCP

面向 AI 智能体的本地优先知识检索引擎——支持 BM25、语义搜索、重排序、MCP,并集成 Pi Agent 与 DeepSeek Harness。

README

由 DeepSeek 最新模型翻译生成
Pwiki 本地优先的 Markdown 知识引擎,具备 BM25F、语义检索、RRF 混合搜索、Cross-Encoder 重排序、MCP、HTTP API 和 Web 管理功能。 Pwiki 是一个面向本地 Markdown 知识的检索与管理引擎。 它提供完整的关键词检索、向量检索、Hybrid Retrieval、Chunk 级读取、知识条目 CRUD、Source 隔离,以及 CLI、MCP、HTTP API 和 Web 多种访问方式。 核心搜索默认不依赖云服务,Embedding 与可选 Reranker 均可使用本地 ONNX 模型运行。 架构 ┌──────────────────┐ │ Web UI │ └────────┬─────────┘ │ HTTP ┌────────▼─────────┐ │ Pwiki API │ └────────┬─────────┘ │ ┌───────────────────────┼──────────────────────┐ │ │ │ ┌────▼─────┐ ┌─────▼────┐ ┌─────▼────┐ │ CLI │ │ MCP │ │ Node API │ └────┬─────┘ └─────┬────┘ └─────┬────┘ │ │ │
展开完整文档(README 剩余 1 节)
Pwiki

本地优先的 Markdown 知识引擎,具备 BM25F、语义检索、RRF 混合搜索、Cross-Encoder 重排序、MCP、HTTP API 和 Web 管理功能。

Pwiki 是一个面向本地 Markdown 知识的检索与管理引擎。

它提供完整的关键词检索、向量检索、Hybrid Retrieval、Chunk 级读取、知识条目 CRUD、Source 隔离,以及 CLI、MCP、HTTP API 和 Web 多种访问方式。

核心搜索默认不依赖云服务,Embedding 与可选 Reranker 均可使用本地 ONNX 模型运行。

架构

┌──────────────────┐
│ Web UI │
└────────┬─────────┘
│ HTTP
┌────────▼─────────┐
│ Pwiki API │
└────────┬─────────┘
│
┌───────────────────────┼──────────────────────┐
│ │ │
┌────▼─────┐ ┌─────▼────┐ ┌─────▼────┐
│ CLI │ │ MCP │ │ Node API │
└────┬─────┘ └─────┬────┘ └─────┬────┘
│ │ │
└───────────────────────┼──────────────────────┘
▼
┌──────────────────────┐
│ WikiEngine │
│ │
│ Parser / CRUD │
│ Source Management │
│ Search Pipeline │
│ Index Maintenance │
└──────────┬───────────┘
│
┌────────────────┼─────────────────┐
▼ ▼ ▼
BM25 SQLite Vector Store Manifest

当前仓库由五个 workspace 组成:

Pwiki/
├── core/
├── cli/
├── mcp/
├── api/
└── webpage/

| Package | Purpose |
| @llangtop/pwiki-core | 核心索引、检索、Source 与 Markdown CRUD |
| @llangtop/pwiki-cli | 命令行客户端 |
| @llangtop/pwiki-mcp | MCP Server |
| @llangtop/pwiki-api | HTTP API Adapter |
| @llangtop/pwiki-webpage | Web 管理端 |

检索流水线

Pwiki 当前支持三种检索模式:

keyword
semantic
hybrid

默认使用 Hybrid Retrieval。

完整链路:

Query
│
├─────────────────────────────┐
│ │
▼ ▼
Tokenizer Embedding Model
│ │
▼ ▼
BM25F Retrieval Vector Retrieval
│ │
└──────────────┬──────────────┘
▼
Raw Candidates
│
▼
RRF
│
▼
Hybrid Candidate Set
│
optional│
▼
Cross-Encoder
│
▼
SearchHit[]
一个重要设计是:

候选检索与最终展示阈值分离。

Semantic 或 Keyword 阶段产生的候选不会因为单路展示阈值提前被丢弃,Hybrid 融合直接使用原始候选。

这样可以避免某个单独 Retriever 认为“分数不够高”的结果,在 RRF 融合之前就永远消失。

BM25 / BM25F

Keyword Retrieval 使用倒排索引。

当前索引数据存储于:

bm25.sqlite3

而不是查询时扫描全部 Markdown。

正常搜索只读取查询 token 对应的 postings。

字段权重:

title 3.0
path 2.5
tags 1.8
body 1.0

因此标题、文件路径和标签中的关键词会获得更高权重。

Engineering-aware Tokenizer

普通 whitespace tokenizer 对代码知识效果很差,因此 Pwiki 对工程文本进行了额外拆分。

支持:

snake_case
camelCase
PascalCase
kebab-case
dot.path
slash/path

例如:

finish_reason

可以产生:

finish
reason
finish_reason

搜索:

finish reason

仍然可以命中。

例如:

LLM_API_BASE

搜索:

api base

也可以匹配。

这对:

* API 参数;
* Java / TypeScript 标识符;
* 环境变量;
* 配置键;
* 文件路径;
* 类名;
* 方法名;

尤其重要。

中文则使用额外的 2-gram 处理以改善短词和连续文本检索。

Semantic Retrieval

Semantic Search 使用 Embedding 对 Chunk 建立向量表示。

默认模型:

bge-base-zh-v1.5

模型使用本地 ONNX runtime 执行。

初始化:

pwiki setup

模型缓存目录:

~/.pwiki/models

也可以通过:

WIKI_MODELS_DIR

覆盖。

Semantic Search 可以单独启停:

pwiki semantic on
pwiki semantic off

Hybrid Search

默认搜索模式:

pwiki search "query"

等价于:

BM25 candidates
+
Semantic candidates
↓
RRF

RRF 用于融合两套评分空间。

这样不需要直接比较:

BM25 score

与:

cosine similarity

这种本身没有统一数值语义的分数。

Cross-Encoder Reranker

Pwiki 支持在 Hybrid Retrieval 后增加可选 Cross-Encoder。

默认关闭。

pwiki reranker on

搜索链路变为:

BM25
\
→ RRF → Top-K candidates → Cross-Encoder → Final results
/
Vector

默认逻辑模型:

BAAI/bge-reranker-base

运行时使用兼容 ONNX 发行版。

可配置:

pwiki reranker on \
--input-top-k 30 \
--output-top-k 10 \
--batch-size 8

配置示例:

{
"reranker": {
"enabled": false,
"model": "BAAI/bge-reranker-base",
"dtype": "int8",
"inputTopK": 20,
"outputTopK": 10,
"maxLength": 512,
"batchSize": 8
}
}

Reranker 只处理 Hybrid 已经召回的少量候选。

不会对整个知识库执行 Cross-Encoder 推理。

模型不可用时,搜索明确退回原始 RRF 排序。

Chunk-level Retrieval

Pwiki 的搜索结果不只返回文件。

SearchHit 可以携带:

sourceId
relPath
chunkIndex
headingPath
startLine
endLine

例如一个搜索结果可以定位到:

docs/dlms/security.md
└── Security Setup
└── Invocation Counter

lines 138-172
chunk 7

因此 Agent 不需要:

search
↓
read entire 800-line document
↓
自己再次定位

而可以:

search
↓
wiki_read_chunk

或者:

search
↓
wiki_read_context

直接读取命中块及其前后文。

MCP 提供:

wiki_read_entry
wiki_read_chunk
wiki_read_context

这也是 Pwiki 面向 Agent 场景时比较核心的一层。

Source Sharding

Pwiki 支持同时加载多个知识目录。

每个目录注册为一个独立 Source:

Source
├── sourceId
├── rootPath
├── index
├── BM25 database
├── vectors
└── manifest

数据结构:

~/.pwiki/
└── sources/
├── source-A/
│ ├── index.json
│ ├── bm25.sqlite3
│ ├── vectors.json
│ └── manifest.json
│
└── source-B/
├── index.json
├── bm25.sqlite3
├── vectors.json
└── manifest.json

搜索可以指定:

sourceId

以及:

pathPrefix

例如:

pwiki search "authentication" \
--source a93d... \
--path-prefix protocol/dlms

过滤发生在实际 BM25 / Vector 评分之前。

指定 Source 没有结果时不会回退到其他 Source。

稳定条目标识

跨 API / MCP / CLI 操作时,条目使用:

sourceId + source-relative relPath

进行定位。

而不是把:

/Users/foo/Documents/wiki/a.md

这样的物理路径暴露为公共对象标识。

HTTP API 同样遵循这个原则。

例如:

source = 83af...
path = protocol/security.md

这也解决了多个 Source 中存在:

README.md

或:

index.md

时的路径冲突问题。

路径边界

涉及文件写入时,Pwiki 强制 Source 边界。

以下形式不会被接受:

../outside.md
../../etc/passwd
/absolute/path.md

Entry 必须解析到对应 Source 内部。

HTTP API 不直接把底层 renameEntry() / moveEntry() 的全局路径能力暴露给外部调用者。

所有写操作首先经过 source-aware service 层解析。

索引生命周期

Pwiki 的索引不是一次性构建产物。

Markdown 生命周期与索引生命周期保持同步。

支持:

create
modify
rename
move
delete
refresh

例如:

modify Markdown
↓
update index entry
↓
update BM25
↓
schedule/update embedding
↓
update manifest

变更检测

Refresh 使用内容 Hash:

MD5

检测文件变化,而不是单纯依赖:

mtime

这样可以避免:

* 文件时间戳被复制工具保留;
* timestamp 精度不足;
* 内容变化但 mtime 行为异常;

导致索引没有刷新。

删除清理

文件删除时会统一清理相关派生数据。

概念上:

removeEntryFromAllStores()

负责清理:

index
cache
vectors
chunkInfo
manifest
BM25

避免产生:

Markdown 已删除但搜索还能搜到

这种 stale index。

Embedding 原子性

Embedding 更新采用文件级 all-or-nothing 语义。

一个文件可能产生多个 Chunk:

document
├── chunk 0
├── chunk 1
├── chunk 2
└── chunk 3

如果新一轮向量生成过程中失败,不应该出现:

chunk 0 → new vector
chunk 1 → new vector
chunk 2 → old vector
chunk 3 → old vector

Pwiki 会保留原有文件向量状态,直到整份文件的新向量结果可提交。

这样降低 Hybrid Retrieval 中出现部分新索引、部分旧索引的可能性。

CRUD 一致性

CLI、MCP 与 HTTP 最终共享 WikiEngine 的 CRUD 语义。

包括:

create
modify
rename
move
delete

MCP 的相关调用为异步完成:

await engine.xxx()

返回时需要保证同步索引维护已经完成。

Semantic 后台状态则单独表达:

queued
processing
ready
failed

而不会把:

embedding task queued

伪装成:

vector index ready

LLM Compile

Pwiki 可以选择使用 LLM 对 Markdown 进行结构化知识编译。

基础检索完全不要求启用该功能。

命令:

pwiki compile-status
pwiki compile --all -l 10
pwiki llm

Compile 可以生成:

topic
concepts
aliases
...

例如原文只有:

HLS5

编译信息可能产生:

High Level Security
GMAC Authentication
DLMS HLS

这些 metadata 会重新进入 BM25 索引。

因此:

原始文本 + LLM derived metadata ↓ BM25 searchable fields

LLM 在这里承担的是:

knowledge enrichment

而不是替代 Retriever。

CLI

安装:

npm install -g @llangtop/pwiki-cli

要求:

Node.js >= 22

初始化:

pwiki setup

加载 Source:

pwiki load ~/documents/wiki

刷新:

pwiki refresh
搜索:

pwiki search "BM25"

关键词:

pwiki search "WikiEngine" -k

语义:

pwiki search "如何定位搜索结果所在段落" -s

状态:

pwiki status

读取:

pwiki read docs/search.md --source

创建:

pwiki create docs/new-entry.md

MCP Server

安装:

npm install -g @llangtop/pwiki-mcp

客户端配置:

{
"pwiki": {
"command": "pwiki-mcp",
"env": {
"WIKI_HOME": "/path/to/.pwiki"
}
}
}

主要 MCP Tools:

wiki_search
wiki_read_entry
wiki_read_chunk
wiki_read_context
wiki_status

wiki_load
wiki_unload
wiki_refresh

wiki_create_entry
wiki_modify_entry
wiki_rename_entry
wiki_move_entry

wiki_compile
wiki_compile_all
wiki_compile_status

wiki_configure_reranker

典型 Agent 工作流:

User Question
↓
wiki_search
↓
SearchHit
↓
wiki_read_chunk
↓
wiki_read_context
↓
Reasoning
↓
Answer

而不是一次把整个知识库塞进上下文。

HTTP API

Pwiki 1.3.x 增加 HTTP Adapter。

API prefix:

/api/v1

主要路由:

| Method | Path | Purpose |
| GET | /api/v1/status | Engine 状态 |
| GET | /api/v1/sources | Source 列表 |
| POST | /api/v1/sources | Load Source |
| DELETE | /api/v1/sources/:id | Unload Source |
| GET | /api/v1/files | 文件树 |
| GET | /api/v1/search | 搜索 |
| GET | /api/v1/entry | 读取 |
| POST | /api/v1/entries | 创建 |
| PUT | /api/v1/entry | 修改正文 |
| PATCH | /api/v1/entry/title | 修改标题 |
| POST | /api/v1/entry/move | 移动 |
| DELETE | /api/v1/entry | 删除 |
| POST | /api/v1/refresh | Refresh |
| GET | /api/v1/models | 模型 |

API 直接调用 Core:

HTTP
↓
PwikiApiService
↓
WikiEngine

不会:

HTTP
↓
spawn CLI

也不会:

HTTP
↓
spawn MCP server

HTTP Contracts

API 层使用显式 DTO 和统一 envelope。

概念形式:

{
"ok": true,
"value": {}
}

错误:

{
"ok": false,
"error": {}
}

同时显式描述:

source scope
pagination
content truncation
background vector status

应用程序因此无需解析 CLI 文本输出。

Web Management

Webpage 是 HTTP API 的浏览器客户端。

启动:

npm install

npm run build -w @llangtop/pwiki-webpage

npm run start \
-w @llangtop/pwiki-webpage \
-- --port 4317

访问:

http://127.0.0.1:4317/

Web 当前提供:

* Source / Markdown 文件树;
* 文件筛选;
* Markdown 阅读;
* Markdown 编辑;
* 保存;
* 重命名;
* 移动;
* 删除;
* Keyword Search;
* Semantic Search;
* Hybrid Search;
* Cross-Encoder 开关;
* 搜索历史;
* Heading Outline;
* 文件属性;
* 工作区与窗口状态;
* 多套 CSS Variable Theme。

搜索历史与最近关闭文件保存在:

localStorage

不会写入知识源本身。

API / Web Security

当前 API 没有内置:

authentication
TLS
public Internet access control

默认监听:

127.0.0.1

非 loopback 地址下,Source Management 默认不开放。

需要显式: bash --allow-source-management

才允许远程加载、刷新或卸载本地 Source。

因此当前设计目标是: text localhost

或者: text trusted internal network

公网部署应自行增加: text Reverse Proxy TLS Authentication Authorization Network ACL

存储布局

默认数据目录: text ~/.pwiki

结构: text ~/.pwiki/ ├── sources/ │ └── / │ ├── index.json │ ├── bm25.sqlite3 │ ├── vectors.json │ └── manifest.json │ ├── index.json ├── compiled/ └── models/

其中: text index.json

保存知识条目 metadata。
text
bm25.sqlite3

保存 BM25 文档与 postings。
text
vectors.json

保存 Semantic Retrieval 所需向量。
text
manifest.json

记录文件 hash 与索引生命周期状态。
text
compiled/

保存 LLM Compile 结果。
text
models/

保存本地 ONNX 模型。

BM25 SQLite 迁移

1.3.2 开始使用: text bm25.sqlite3

替换早期: text bm25_docs.json bm25_terms.json bm25_meta.json

首次打开旧索引时会执行迁移。

旧 JSON 文件暂时保留作为回退数据。

正常查询不再加载完整 terms 文件,而是根据查询词读取对应 postings。

文档新增、修改、重命名和移动也只更新对应文档事务。

环境变量

| 变量 | 用途 |
| WIKI_HOME | Pwiki 数据目录 |
| WIKI_MODELS_DIR | 模型缓存目录 |
| WIKI_MODEL_ID | Embedding model |
| LLM_API_KEY | LLM API key |
| LLM_API_BASE | Compatible API endpoint |
| LLM_MODEL | Compile model |
| LLM_JSON_MODE | JSON mode compatibility |
| LLM_THINKING_PARAM | Thinking parameter compatibility |

开发

Clone: bash
git clone https://github.com/ang-XWBWZ/Pwiki
cd Pwiki

Install: bash
npm install

Build all workspaces: bash
npm run build --workspaces

或者: bash
npm run build

运行 CLI: bash
npm run start -w @llangtop/pwiki-cli

运行 MCP: bash
npm run start -w @llangtop/pwiki-mcp

运行 API: bash
npm run start -w @llangtop/pwiki-api -- --port 4318

运行 Web: bash
npm run start -w @llangtop/pwiki-webpage -- --port 4317

发布

当前公开 npm 发布主要包括: text @llangtop/pwiki-core @llangtop/pwiki-cli @llangtop/pwiki-mcp

Linux: bash ./publish.sh

发布脚本不会自动修改版本号。

发布前需要同步更新对应: text package.json

版本。

Pwiki 是什么

从 UI 看,Pwiki 的 Web 页面当然很容易让人联想到一个简化版 Markdown / Obsidian 工具。

但 Web 只是整个项目的一种入口。

真正的主体仍然是: text Markdown Sources ↓ WikiEngine ↓ Index / Retrieval / CRUD ↓ CLI / MCP / HTTP / Web

因此 Pwiki 更接近一个:

local knowledge engine shared by humans, agents and applications

它不会花主要精力追赶成熟笔记软件的插件生态、Canvas、移动端和完整编辑器体验。

Web 的职责是让人类可以直接检查、搜索和维护同一套知识。

MCP 让 Agent 使用它。

HTTP API 让其他软件使用它。

而这些入口最终共享同一个知识核心。
ps:领域上下文管理正在开发
预览分享卡片长什么样 ↓ang-XWBWZ/Pwiki
分享 · DeepSeek Harness Hub
ang-XWBWZ/Pwiki

用 BM25 与语义混合搜索,为 AI 代理检索本地 Markdown 知识

这是你的仓库?认领条目

回到顶部