Generic Chinese markdown RAG framework: hybrid BM25 + vector search, axis-aware classification, jitter-aware eval.
Project description
⚠️ Pre-1.0 — API 可能在 minor 版本間變更。
繁體中文
為什麼選 wenji?
大多數 RAG 框架預設「LLM-default」:建索引用 LLM 抽 entity、LLM 做 community summary、LLM 重排序。成本隨語料線性成長,LLM 不可用時整個系統停擺。
wenji 走相反路線 — LLM-essential, not LLM-default:建索引零 LLM 呼叫;LLM 只允許在查詢時用,必須 cache,必須有確定性 fallback。結果:LLM 成本 = unique queries × cache miss rate,與語料大小無關。
典型使用場景
- 📚 中文知識語料搜尋 — 講道、課堂筆記、法律條文、古典詩詞、技術文章,中英混合支援
- 🖥️ 本地部署,零外部服務 — 一個 Python 行程 + 一個 SQLite 檔,不租 vector DB
- 🏷️ Tag + 分類軸瀏覽 — Web UI sidebar 篩選,
/tagstag 索引頁,?tag=X精確過濾 - 🤖 選配 LLM query rewrite — 短查詢擴詞,任何 OpenAI-compatible endpoint,結果 SQLite cache
- 📊 Eval 對齊 — JSONL eval runner,80q 基準,pass@3 partial+ 77.5%(v0.3.6 rewrite-off)
3 大特色
| 特色 | 實現方式 |
|---|---|
| 零 LLM 建索引 | SQLite FTS5 (BM25) + ONNX BGE-M3 INT8,byte-identical 重建 |
| 完全本地 | 無 vector DB,無外部 API,一個 SQLite 檔 |
| 可量測 | JSONL eval runner,80q 基準,pass@3 partial+ 77.5%(v0.3.6 rewrite-off) |
三行快速上手
示範語料
examples/articles/只隨 source checkout 提供;有自己的 markdown 目錄可直接 ingest。
pip install wenji
git clone https://github.com/notoriouslab/wenji # 只為了拿示範語料
wenji ingest dir wenji/examples/articles/ --db wenji.db
wenji serve --db wenji.db --port 8000
打開瀏覽器 http://127.0.0.1:8000,得到完整 Web UI(搜尋 + tag 瀏覽 + 文章閱讀器)。
支援 Python 3.10–3.12(3.13 尚未支援)。第一次跑 wenji ingest 或 wenji search 會自動下載 ONNX BGE-M3 INT8 embed model(~600 MB)至 user cache。macOS arm64 / Linux x86_64 內建 libsimple binary;其他平台用 wenji download-model 手動抓。
常見使用路徑
場景 1:CLI 快速搜尋
wenji search "勞動契約" --db wenji.db --top-k 5
輸出前 5 筆,含 BM25 / 向量分數、chunk-level 摘要、chunk 索引(可 deep link 至 /article/<id>#c<n>)。
場景 2:Web UI + tag 瀏覽
wenji serve --db wenji.db --port 8000
| 路由 | 功能 |
|---|---|
/ |
搜尋頁,含分軸 sidebar filter |
/tags |
tag 索引頁(tag → 文章數) |
/tag/<name> |
單一 tag 的文章列表 |
/article/<id> |
文章閱讀器(sticky TOC、scroll-spy、query-aware 自動捲動) |
⚠️ Production 部署 checklist(
wenji serve預設沒有認證、沒有速率限制):
- 設
WENJI_API_KEY=<random-32-bytes>開啟 API key auth;同時關掉/docs/openapi.json自動文件(沒設 API key 時這兩個端點公開)- 設
WENJI_CORS_ORIGINS=https://your-frontend.example.com(預設 empty 拒所有 cross-origin)- 綁
127.0.0.1走反代(nginx / Caddy)+ 反代層做 rate limit(/api/ask一次呼叫等於一次 LLM 計費)- Docker / systemd:用
EnvironmentFile=/etc/wenji.env載入WENJI_*(不要 inlineEnvironment=或 docker-e,會被systemctl show/ps看到)axes.yaml為選配;缺檔不影響 ingest/search,只是 sidebar 不會有分軸
場景 3:選配 LLM query rewrite
# 複製 .env.example 為 .env 後填入下列值(請勿 commit;.gitignore 已內建):
# WENJI_LLM_BASE_URL=https://api.groq.com/openai/v1
# WENJI_LLM_API_KEY=<your-key>
# WENJI_LLM_MODEL=llama-3.3-70b-versatile
direnv allow . # 或 `source .env`,二選一載入
wenji serve --db wenji.db
詳細的 .env 安全建議(不要 export 進 shell rc、不要 -e 給 docker)見「進階設定 → LLM Query Rewrite」。
rewrite 格式:1-3 組關鍵詞以 | 分隔(BM25-friendly);結果 SQLite cache(預設 30 天 TTL)。
注意:v0.3.6 rewrite-on 比 rewrite-off 低 3.7pp(73.8% vs 77.5%),wenji 預設 rewrite-off。建議先跑 rewrite-off 基準再決定是否開啟。
場景 4:Domain corpus(corpus-christian 範例)
wenji serve --db wenji.db \
--entity-source example:corpus-christian \
--intent-source example:corpus-christian
啟動 entity scoring + intent classification 層,Searcher pipeline 升級為:RRF merge with intent boost → entity scoring/filter。省略 flags 時退化為純 RRF + chunk signals(仍優於 v0.3.5 線性 hybrid)。
場景 5:Eval A/B 基準測試
前置:先在另一 terminal 跑
wenji serve --db wenji.db(eval runner 透過/api/search打 80q 基準);snapshottests/benchmark_80_v2_snapshot.json已內建 repo 內。Smoke 建議:改動 retrieval pipeline 後,先用 snapshot 前 10 題跑 mini-baseline(手動
jq '.categories[].questions |= .[:3]' snapshot.json > smoke.json之類)確認沒大幅退步,再跑全 80q。
wenji eval run-benchmark --no-rewrite --db wenji.db --out r0_off.json
wenji eval run-benchmark --enable-rewrite --db wenji.db --out r0_on.json
輸出標準 JSON 格式(per-question gold_path_match、pass@3、MRR@5)。run_id 自動補 _rewrite_on / _rewrite_off 後綴。用 wenji eval sanity-eyeball --baseline-output <path> 做人工雙閘門驗收。
數值有 LLM 抖動:rewrite-on 數值 ±1.5pp 視為 jitter 範圍內(同一基準重跑可能差 ±1.5pp);超過 1.5pp 才算 retrieval regression。
核心概念
搜尋架構
Searcher.search() v0.3.6 執行 11 步 pipeline:
query rewrite → entity detect → intent detect → alias expand
→ BM25 + vector → chunk BM25 → RRF merge (intent boost)
→ entity scoring + filter → ranker hooks → cross-encoder rerank → snippet hydration
省略 --entity-source / --intent-source 時,entity/intent 步驟 skip,降級為 RRF + chunk signals。
核心模組
| 模組 | 作用 |
|---|---|
wenji.ingest |
Disk-as-SSOT 切入:frontmatter、NFKC 正規化、deterministic ID、content hash、4 種切塊策略 |
wenji.search |
混合檢索 11 步 pipeline(BM25 + vector + RRF + entity/intent + rerank) |
wenji.classify |
跟語料無關的多軸 rule engine,user-supplied axes.yaml |
wenji.eval |
JSONL eval runner,multi-path gold set,jitter-aware gate |
wenji.ask |
RAG 問答(POST /api/ask),chunk-level citation,30 天 LLM cache |
wenji.observability |
corpus 快照 + query pipeline trace |
分類引擎
# axes.yaml — 摘錄自 examples/axes.yaml
axes:
- id: sermon
name: 講道
short: 講道
order: 1
description: 講道 / 信仰主題長文
rules:
- source_type: sermon
primary: true
每條 rule 支援 source_type / tag / title_regex(regex search)/ subtype 多欄位 AND 組合,及 hierarchical parent: <id>。Axes 是 derived data,隨時 wenji classify / wenji rebuild 重建,不動原始 markdown。
Observability
wenji stats --db wenji.db # articles / chunks / indices 快照
wenji segment "因信稱義" --db wenji.db # query pipeline trace(tokens、fts_form、rewrite)
等效 HTTP 端點:GET /api/stats、GET /api/segment?q=。
進階設定
LLM Query Rewrite
任何 OpenAI-compatible endpoint(Groq、OpenRouter、Together、Gemini、vLLM、llama.cpp …)。強烈建議用 .env + direnv 載入,避免 API key 寫進 shell rc 或被 process listing 看到:
# .env (請複製 .env.example 為 .env 後填入;不要 commit)
WENJI_LLM_BASE_URL=https://api.groq.com/openai/v1
WENJI_LLM_API_KEY=<your-key>
WENJI_LLM_MODEL=llama-3.3-70b-versatile
WENJI_LLM_TIMEOUT=10.0 # 選配,預設 10s
WENJI_LLM_REWRITE_CACHE_TTL_DAYS=30 # 選配,預設 30 天
⚠️ 確認
.gitignore含.env與.env.*(已內建)。不要export WENJI_LLM_API_KEY=...寫進~/.zshrc/~/.bashrc,也不要傳-e WENJI_LLM_API_KEY=...給 docker(會被ps看到)。
單次覆蓋:wenji serve --enable-rewrite / --no-rewrite(兩者互斥)。
LLM 失敗 fallback:rewrite endpoint 超時 / 5xx → rewriter skip,retrieval pipeline 不受影響(仍走原 query);/api/ask 在 LLM 失敗時 answer=null 但 citations 仍正常填值。
Entity / Intent Sources
多來源,last-write-wins on key collision:
wenji serve --db wenji.db \
--entity-source example:corpus-christian \
--entity-source /path/to/my_entities.json \
--intent-source example:corpus-christian
或 env var(comma-separated):
export WENJI_ENTITY_SOURCES=example:corpus-christian,/path/to/my_entities.json
export WENJI_INTENT_SOURCES=example:corpus-christian
Network URLs(http://、https://)被 source loader 拒絕;只接受 example:<name> 和本機路徑。注意:本機路徑目前沒有沙箱(Path traversal 防護待 v0.4),請只指向你信任的目錄。多來源 last-write-wins:右邊覆蓋左邊(--entity-source A --entity-source B → B 的 keys 優先)。
Web 部署:站點 URL / SEO / CORS(v0.3.7+)
對外發行時,由 env vars 控制 SEO meta 與 CORS(全部 unset 時預設不暴露任何品牌 / 不允許任何 cross-origin,最安全 zero-config):
# .env
WENJI_SITE_URL=https://wenji.example.com # 啟用 canonical / og:* / JSON-LD
WENJI_SITE_NAME=My Wenji # 可選,最長 256 字
WENJI_OG_IMAGE_URL=https://wenji.example.com/og.png # 可選;⚠️ 此 host 會收到所有訪客 IP / UA
WENJI_CORS_ORIGINS=https://my-frontend.example.com,https://api.example.com
URL host 啟動時做白名單驗證:拒 userinfo(https://a@b.com)、私網 IP、IDN homograph、控制字元、非預設 port、percent-encoded host —— fail-fast 啟動失敗。CORS 拒 * / null / wildcard subdomain / 非 https。
Local dev SPA:跑
localhost:5173連/api/*會被預設 CORS 擋下;開發時設WENJI_CORS_ORIGINS=http://localhost:5173 WENJI_ALLOW_HTTP_CORS=1。
部署前用 wenji doctor --db wenji.db 驗 db 一致性(cross-table sanity + sample FTS MATCH);exit 1 代表 db 不一致、wenji serve 啟動會拒絕 bind port。非中文 corpus 加 --sample-keywords k1,k2,k3 override。
升級指南
wenji 用 markdown 為 SSOT,舊 db 升級沒有 migration script —— 直接重建:
rm wenji.db && wenji ingest dir <markdown-dir> --db wenji.db
支援平台:
| 平台 | 狀態 | 備註 |
|---|---|---|
| macOS arm64(M1+) | ✅ supported | 內建 libsimple binary |
| Linux x86_64 | ✅ supported | 內建 libsimple binary |
| macOS x86_64(Intel) | ⚠️ experimental | 需自行編譯 libsimple |
| Linux ARM | ⚠️ experimental | 需自行編譯 libsimple |
| Windows | ❌ unsupported | libsimple 無 .dll |
中國大陸 / 受限網路:Hugging Face 模型下載可設 HF_ENDPOINT=https://hf-mirror.com。
進階參考
CLI 子命令
| 命令 | 用途 |
|---|---|
wenji ingest dir <path> |
從 markdown 目錄建索引 |
wenji search <query> |
CLI 搜尋 |
wenji serve |
啟動 FastAPI + Web UI |
wenji classify |
套用 axes.yaml |
wenji rebuild |
重建 derived tables(byte-identical) |
wenji stats |
corpus 快照 |
wenji segment <query> |
query pipeline trace |
wenji eval run-benchmark |
跑 80q 基準 |
wenji eval sanity-eyeball |
人工雙閘門驗收 |
wenji eval migrate-jsonl |
舊版 eval JSONL 轉換 |
wenji doctor |
db consistency 健康檢查(部署前驗 db) |
wenji inspect-chunks <file> |
預覽單檔切塊結果 |
wenji set-chunk-strategy |
寫 frontmatter chunk_strategy |
wenji corpus trim |
按 article_id / content_hash 刪除 |
wenji download-model |
手動下載 ONNX model + libsimple |
wenji aggregate clear-cache |
清除 LLM cache |
選型建議
何時開 rewrite?
| 情境 | 建議 |
|---|---|
| 短查詢(1-2 字)召回率差 | 嘗試 rewrite-on,先跑 A/B 確認效果 |
| 向量召回已夠強 | 預設 off(rewrite 可能注入噪音) |
| Baseline 重現 | --no-rewrite 鎖定 |
何時用 entity/intent?
| 情境 | 建議 |
|---|---|
| 純 RRF 效果已夠 | 省略 --entity-source,不需準備詞典 |
| 專業語料(神學、法律、醫學) | --entity-source + --intent-source 提升精確度 |
| 自訂 domain | Python API: EntityScorer.from_sources() / IntentClassifier.from_sources() |
整合與生態
notoriouslab 相關專案
| 專案 | 說明 |
|---|---|
| trad-zh-search | 繁體中文文本預處理工具 — CKIP 分詞 + bigram 索引生成,附可選擇的領域字典系統;可單獨搭配主流搜尋引擎使用 |
| vault-search | Obsidian 本地語意搜尋與發掘 — 中文友善,無雲端、無 API Key、無訂閱費 |
擴展點:RankerHook Protocol — boost(article, query, context) -> float,duck typing 滿足即可。詳見 docs/extending.md。
貢獻
pip install -e ".[dev]"
ruff check src/wenji tests/wenji # linter
pytest # unit(634 tests)
pytest -m integration # 真實 ONNX(需下載 ~600 MB)
詳見 CONTRIBUTING.md。
授權
MIT © 2026 notoriouslab
English
At a glance
| What | Drop a folder of markdown files in, get hybrid search + a web UI out. |
| Who for | Anyone with a Chinese (or mixed-language) markdown corpus — sermons, lecture notes, legal text, classical poetry, blog posts — who wants real search without renting a vector DB. |
| Stack | SQLite FTS5 (BM25) + ONNX BGE-M3 (vector) + libsimple (CJK tokenizer) + FastAPI + Jinja2 |
| Indexing cost | Zero LLM calls. Deterministic, byte-identical rebuild from disk. |
| LLM use | Optional, query-time only, cached, with a structured fallback that works without any LLM. |
| Deploy size | One Python process, one SQLite file. No external services. |
| Tested on | Python 3.10 / 3.11 / 3.12 — 641 tests (634 unit + 7 integration). 3.13 not yet supported (pyproject.toml pins requires-python = ">=3.10,<3.13"). |
Why wenji?
Most RAG frameworks are built around an "LLM-default" assumption: extract entities with an LLM during ingest, build community summaries with an LLM, re-rank with an LLM. The cost grows with the corpus, and the system stops working when the LLM is unavailable.
wenji is built on the opposite premise — LLM-essential, not LLM-default:
- The indexing pipeline performs zero LLM calls.
- LLM use is restricted to query time, must be cached, and must have a deterministic structured fallback.
- Entity dictionaries, classification axes, and chunking strategies are user-supplied, not LLM-derived.
The result: LLM cost scales with unique queries × cache miss rate, not with corpus size.
Quickstart (4 commands)
The demo corpus in
examples/articles/ships only with the source checkout; pointingestat your own markdown directory otherwise.
pip install wenji
git clone https://github.com/notoriouslab/wenji # demo corpus only
wenji ingest dir wenji/examples/articles/ --db wenji.db
wenji serve --db wenji.db --port 8000
Then open http://127.0.0.1:8000 — full Web UI with search, tag browsing, and article viewer.
To search from the command line:
wenji search "勞動契約" --db wenji.db --top-k 5
Common paths
| Scenario | Command |
|---|---|
| CLI search | wenji search "<query>" --db wenji.db |
| Web UI + tag browsing | wenji serve --db wenji.db → /tags, /tag/<name> |
| LLM query rewrite | Set WENJI_LLM_* env vars (see below) |
| Domain corpus (entity/intent) | wenji serve --entity-source example:corpus-christian --intent-source example:corpus-christian |
| Eval A/B | wenji eval run-benchmark --no-rewrite vs --enable-rewrite |
Search pipeline (v0.3.6)
Searcher.search() runs an 11-step pipeline:
query rewrite → entity detect → intent detect → alias expand
→ BM25 + vector → chunk BM25 → RRF merge (intent boost)
→ entity scoring + filter → ranker hooks → cross-encoder rerank → snippet hydration
Without --entity-source / --intent-source, the entity/intent steps are skipped and the pipeline degrades to RRF + chunk signals (still an improvement over v0.3.5 linear hybrid).
Core modules
| Module | Purpose |
|---|---|
wenji.ingest |
Disk-as-SSOT markdown ingest: frontmatter, NFKC normalization, deterministic IDs, content hashing, 4 chunking strategies. |
wenji.search |
Hybrid retrieval: 11-step pipeline (BM25 + vector + RRF + entity/intent + rerank). |
wenji.classify |
Corpus-agnostic multi-axis rule engine. Drop your axes.yaml, get tagged articles. |
wenji.eval |
JSONL-driven eval runner with jitter-aware gates. |
wenji.ask |
RAG question answering (POST /api/ask), chunk-level citations, 30-day LLM cache. |
wenji.observability |
Corpus snapshot + query pipeline trace (/api/stats, /api/segment). |
LLM query rewrite (optional, v0.3.2+)
Any OpenAI-compatible endpoint (Groq, OpenRouter, Together, Gemini, vLLM, llama.cpp…):
export WENJI_LLM_BASE_URL=https://api.groq.com/openai/v1
export WENJI_LLM_API_KEY=<your-key>
export WENJI_LLM_MODEL=llama-3.3-70b-versatile
Per-invocation override: wenji serve --enable-rewrite / --no-rewrite. The /api/search response includes a rewritten_query field (null when unchanged).
Note: v0.3.6 rewrite-on (73.8%) is 3.7pp below rewrite-off (77.5%) because wenji's vector recall is already strong. Default is rewrite-off. Run A/B with
wenji eval run-benchmarkbefore enabling in production.
Configuration
# axes.yaml — excerpt from examples/axes.yaml
axes:
- id: sermon
name: 講道
short: 講道
order: 1
description: 講道 / 信仰主題長文
rules:
- source_type: sermon
primary: true
Each rule supports source_type / tag / title_regex (regex search) / subtype fields combined with AND, plus hierarchical parent: <id>. Axes are derived data — wenji rebuild always regenerates them deterministically.
Ecosystem
| Project | Description |
|---|---|
| trad-zh-search | Traditional Chinese text preprocessing: CKIP segmentation + bigram index generation, with optional domain dictionaries. Works standalone with any major search engine. |
| vault-search | Obsidian local semantic search and discovery — Chinese-friendly, no cloud, no API key, no subscription. Your notes never leave your machine. |
Custom ranking: implement the RankerHook Protocol (boost(article, query, context) -> float) and pass it to Searcher. Duck typing — no import required. See docs/extending.md.
Star History
Development
pip install -e ".[dev]"
ruff check src/wenji tests/wenji
pytest # unit
pytest -m integration # ~600 MB model download, real ONNX
See CONTRIBUTING.md for the full PR flow.
License
MIT © 2026 notoriouslab
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file wenji-0.4.0.tar.gz.
File metadata
- Download URL: wenji-0.4.0.tar.gz
- Upload date:
- Size: 140.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
692d5c41096d52654da847b9a552fdd64cf17f26532014feb127a4553757093b
|
|
| MD5 |
49dd507a7c0d92c3eedcf0d2a3eb5486
|
|
| BLAKE2b-256 |
973a546c277f5ebcd83c885d9967b5071e32e444f09d2a1712165aaf722fc7db
|
Provenance
The following attestation bundles were made for wenji-0.4.0.tar.gz:
Publisher:
release.yml on notoriouslab/wenji
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wenji-0.4.0.tar.gz -
Subject digest:
692d5c41096d52654da847b9a552fdd64cf17f26532014feb127a4553757093b - Sigstore transparency entry: 2096809691
- Sigstore integration time:
-
Permalink:
notoriouslab/wenji@5629e291a22b20c195a5fe2fa169efb9ac9dc4d2 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/notoriouslab
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5629e291a22b20c195a5fe2fa169efb9ac9dc4d2 -
Trigger Event:
push
-
Statement type:
File details
Details for the file wenji-0.4.0-py3-none-any.whl.
File metadata
- Download URL: wenji-0.4.0-py3-none-any.whl
- Upload date:
- Size: 163.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ef0c40c12c6fb5c92173fbc0e635a4c9cd9482351e18af95731b60aedea751be
|
|
| MD5 |
c20013d44183858f41085f5b3eea56e7
|
|
| BLAKE2b-256 |
9b7869f191404901f7ef00eee2fa77fd2c67dec38d96cd4f65a3ce2fd3a7a8f1
|
Provenance
The following attestation bundles were made for wenji-0.4.0-py3-none-any.whl:
Publisher:
release.yml on notoriouslab/wenji
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wenji-0.4.0-py3-none-any.whl -
Subject digest:
ef0c40c12c6fb5c92173fbc0e635a4c9cd9482351e18af95731b60aedea751be - Sigstore transparency entry: 2096810275
- Sigstore integration time:
-
Permalink:
notoriouslab/wenji@5629e291a22b20c195a5fe2fa169efb9ac9dc4d2 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/notoriouslab
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5629e291a22b20c195a5fe2fa169efb9ac9dc4d2 -
Trigger Event:
push
-
Statement type: