@sdsrs/llm-wiki 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +16 -0
- package/package.json +9 -1
- package/src/ask.mjs +10 -7
- package/src/embed.mjs +13 -7
- package/src/llm-config.mjs +18 -0
- package/src/local-embed.mjs +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-07-12)
|
|
4
|
+
|
|
5
|
+
**Added:**
|
|
6
|
+
|
|
7
|
+
- **Local / offline embedding via transformers.js.** Set `"embeddingModel":
|
|
8
|
+
"local:Xenova/multilingual-e5-small"` in `~/.llm-wiki/config.json` and install the
|
|
9
|
+
optional `@huggingface/transformers` to generate and query vectors with no embedding
|
|
10
|
+
API. With only a local model configured, `embed` / `search` / `ask --retrieve-only`
|
|
11
|
+
run fully offline (answering with `ask` still needs a chat provider). The default
|
|
12
|
+
model is multilingual, so zh↔en cross-language retrieval is preserved. The dependency
|
|
13
|
+
is optional — the base install is unchanged.
|
|
14
|
+
|
|
3
15
|
## 0.8.3 (2026-07-12)
|
|
4
16
|
|
|
5
17
|
Performance and test-hardening cleanup; no shipped behavior change. Suite 261 → 263.
|
package/README.md
CHANGED
|
@@ -144,6 +144,22 @@ similarity (RRF); `--retrieve-only` labels each hit `[bm25]`, `[vector]` or
|
|
|
144
144
|
`[bm25+vector]`. Vectors only *locate* pages — context is still whole pages,
|
|
145
145
|
never chunks. Any vector-path failure falls back to BM25 with a warning.
|
|
146
146
|
|
|
147
|
+
### Local / offline embedding
|
|
148
|
+
|
|
149
|
+
Vector retrieval normally calls an embedding API. To run it locally with no API key:
|
|
150
|
+
|
|
151
|
+
1. Install the optional model runtime: `npm i @huggingface/transformers`
|
|
152
|
+
2. In `~/.llm-wiki/config.json`, set a `local:` embedding model:
|
|
153
|
+
```json
|
|
154
|
+
{ "embeddingModel": "local:Xenova/multilingual-e5-small" }
|
|
155
|
+
```
|
|
156
|
+
3. `llm-wiki embed --kb ./kb` — the first run downloads the model (~120 MB).
|
|
157
|
+
|
|
158
|
+
The default `Xenova/multilingual-e5-small` is multilingual, so cross-language
|
|
159
|
+
(zh↔en) retrieval keeps working. With only a local embedding model configured,
|
|
160
|
+
`embed`, `search`, and `ask --retrieve-only` run fully offline; generating an
|
|
161
|
+
answer with `ask` still needs a chat provider.
|
|
162
|
+
|
|
147
163
|
### Graph queries
|
|
148
164
|
|
|
149
165
|
`graph.json` (rebuilt by `index`) is a link graph over the pages — wikilinks,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/llm-wiki",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Compile messy document directories into Karpathy-style llm_wiki knowledge bases — standalone Q&A CLI + Claude Code/Codex skills",
|
|
5
5
|
"directories": {
|
|
6
6
|
"test": "test"
|
|
@@ -51,5 +51,13 @@
|
|
|
51
51
|
"undici": "^8.7.0",
|
|
52
52
|
"yaml": "^2.9.0",
|
|
53
53
|
"zod": "^4.4.3"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@huggingface/transformers": "^3.0.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@huggingface/transformers": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/ask.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { loadKbConfig } from './templates.mjs'
|
|
|
5
5
|
import { listWikiPages, isInvalidated, asList, PAGE_DIRS } from './pages.mjs'
|
|
6
6
|
import { buildBm25Index, searchBm25 } from './bm25.mjs'
|
|
7
7
|
import { worstCaseTokens } from './scanner.mjs'
|
|
8
|
-
import { loadLlmConfig, makeTransport } from './llm-config.mjs'
|
|
8
|
+
import { loadLlmConfig, loadEmbedConfig, makeTransport } from './llm-config.mjs'
|
|
9
9
|
import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
|
|
10
10
|
import { embedTexts } from './embed.mjs'
|
|
11
11
|
import { fetchWithRetry } from './retry.mjs'
|
|
@@ -124,7 +124,7 @@ export function fuseChannels({ bm25, vector }, k, { lexicalGuard = true } = {})
|
|
|
124
124
|
// 'bm25': lexical only, returns before any vector access.
|
|
125
125
|
// 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
|
|
126
126
|
// vector error) throws instead of degrading, and vectorEnabled is ignored.
|
|
127
|
-
export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry } = {}) {
|
|
127
|
+
export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
|
|
128
128
|
if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
|
|
129
129
|
const bm25 = retrievePages(kbRoot, question, k)
|
|
130
130
|
const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false, guardApplied: false })
|
|
@@ -140,14 +140,17 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
140
140
|
if (retrieval === 'auto' && !kbCfg.vectorEnabled) return asBm25()
|
|
141
141
|
const store = loadVectorStore(kbRoot)
|
|
142
142
|
if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
|
|
143
|
-
const cfg =
|
|
143
|
+
const cfg = loadEmbedConfig(kbRoot)
|
|
144
144
|
if (!cfg?.embeddingModel) return unavailable('no embeddingModel in ~/.llm-wiki/config.json')
|
|
145
145
|
// A store built by a different embeddingModel lives in a foreign vector space:
|
|
146
146
|
// fusing it produces silent garbage, so treat it as missing (not a failure).
|
|
147
147
|
if (store.model !== cfg.embeddingModel) return unavailable(`vector store was built with "${store.model}" but config says "${cfg.embeddingModel}" — re-run \`llm-wiki embed\``)
|
|
148
148
|
try {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
149
|
+
const injected = fetchImpl || pipelineFactory
|
|
150
|
+
const t = injected
|
|
151
|
+
? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
|
|
152
|
+
: { ...(await makeTransport()), retry }
|
|
153
|
+
const [qv] = await embedTexts(cfg, t, [question], { role: 'query' })
|
|
151
154
|
const qn = normalize(qv)
|
|
152
155
|
const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
|
|
153
156
|
// The store is a snapshot from the last embed; pages invalidated, deleted, or
|
|
@@ -226,9 +229,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
|
|
|
226
229
|
return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry } = {}) {
|
|
232
|
+
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
|
|
230
233
|
const p = kbPaths(kbRoot)
|
|
231
|
-
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry })
|
|
234
|
+
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
|
|
232
235
|
if (retrieveOnly) return { pages: hits, answer: null }
|
|
233
236
|
let validIds
|
|
234
237
|
if (hits.length === 0) {
|
package/src/embed.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { listWikiPages, isInvalidated } from './pages.mjs'
|
|
2
|
-
import {
|
|
2
|
+
import { loadEmbedConfig, makeTransport } from './llm-config.mjs'
|
|
3
3
|
import { sha256Text } from './hashing.mjs'
|
|
4
4
|
import { normalize, pageEmbedText, loadVectorStore, saveVectorStore } from './vector.mjs'
|
|
5
5
|
import { fetchWithRetry } from './retry.mjs'
|
|
6
|
+
import { embedLocal, isLocalModel, stripLocalPrefix } from './local-embed.mjs'
|
|
6
7
|
|
|
7
8
|
const BATCH = 64
|
|
8
9
|
// Safety margin under the 8192-token input limit of common embedding models
|
|
@@ -44,7 +45,10 @@ function capEmbedText(text) {
|
|
|
44
45
|
return text.slice(0, lo)
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export async function embedTexts(cfg, t, texts) {
|
|
48
|
+
export async function embedTexts(cfg, t, texts, { role = 'passage' } = {}) {
|
|
49
|
+
if (isLocalModel(cfg.embeddingModel)) {
|
|
50
|
+
return embedLocal(stripLocalPrefix(cfg.embeddingModel), texts, { role, pipelineFactory: t?.pipelineFactory })
|
|
51
|
+
}
|
|
48
52
|
const url = `${cfg.baseURL.replace(/\/$/, '')}/embeddings`
|
|
49
53
|
let res
|
|
50
54
|
try {
|
|
@@ -70,10 +74,9 @@ export async function embedTexts(cfg, t, texts) {
|
|
|
70
74
|
return [...data.data].sort((a, b) => a.index - b.index).map(d => d.embedding)
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
|
|
74
|
-
const cfg =
|
|
75
|
-
if (!cfg) throw new Error('No
|
|
76
|
-
if (!cfg.embeddingModel) throw new Error('No embedding model configured. Add "embeddingModel" to your provider (or flat config) in ~/.llm-wiki/config.json, e.g. "text-embedding-3-small".')
|
|
77
|
+
export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}) {
|
|
78
|
+
const cfg = loadEmbedConfig(kbRoot)
|
|
79
|
+
if (!cfg?.embeddingModel) throw new Error('No usable embedding config. A remote embeddingModel needs chat credentials (baseURL/apiKey) in ~/.llm-wiki/config.json; a local one needs none — set "embeddingModel": "local:Xenova/multilingual-e5-small".')
|
|
77
80
|
const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
|
|
78
81
|
const prev = loadVectorStore(kbRoot)
|
|
79
82
|
const reuse = (prev && prev.model === cfg.embeddingModel) ? prev.pages : {}
|
|
@@ -92,7 +95,10 @@ export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
|
|
|
92
95
|
let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
|
|
93
96
|
let embedded = 0
|
|
94
97
|
if (jobs.length > 0) {
|
|
95
|
-
const
|
|
98
|
+
const injected = fetchImpl || pipelineFactory
|
|
99
|
+
const t = injected
|
|
100
|
+
? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
|
|
101
|
+
: { ...(await makeTransport()), retry }
|
|
96
102
|
for (let i = 0; i < jobs.length; i += BATCH) {
|
|
97
103
|
const batch = jobs.slice(i, i + BATCH)
|
|
98
104
|
const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
|
package/src/llm-config.mjs
CHANGED
|
@@ -65,6 +65,24 @@ export function loadLlmConfig(kbRoot) {
|
|
|
65
65
|
return cfg
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// The embedding path (embedKb, locatePages' vector branch) needs an embeddingModel
|
|
69
|
+
// but not necessarily chat credentials: a LOCAL model runs with no baseURL/apiKey at
|
|
70
|
+
// all, so a fully-offline KB (local embeddings, no chat provider) can still embed +
|
|
71
|
+
// search. A REMOTE embeddingModel still needs the chat provider's baseURL/apiKey to
|
|
72
|
+
// call the API, so it resolves only when loadLlmConfig does. loadLlmConfig's own
|
|
73
|
+
// (chat-complete-or-null) contract is unchanged.
|
|
74
|
+
export function loadEmbedConfig(kbRoot) {
|
|
75
|
+
const chat = loadLlmConfig(kbRoot)
|
|
76
|
+
if (chat?.embeddingModel) return chat
|
|
77
|
+
const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
|
|
78
|
+
const globalFile = path.join(dir, 'config.json')
|
|
79
|
+
if (!fs.existsSync(globalFile)) return null
|
|
80
|
+
const fileCfg = readJsonFile(globalFile, { redactContents: true })
|
|
81
|
+
const em = fileCfg.embeddingModel
|
|
82
|
+
?? Object.values(fileCfg.providers ?? {}).map(p => p?.embeddingModel).find(Boolean)
|
|
83
|
+
return (typeof em === 'string' && em.startsWith('local:')) ? { embeddingModel: em } : null
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
// Node's built-in fetch rejects the npm undici package's dispatcher
|
|
69
87
|
// ("invalid onRequestStart method": handler interface mismatch between
|
|
70
88
|
// undici 8.x and Node's bundled undici), so when a proxy is configured we
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Local/offline embedding via transformers.js. The heavy dependency
|
|
2
|
+
// (@huggingface/transformers, ~253 MB installed) is an optional peer dependency,
|
|
3
|
+
// imported ONLY here and ONLY when a `local:` embeddingModel is used — the base
|
|
4
|
+
// install stays lean. (Separate from the ~120 MB first-run model download noted in
|
|
5
|
+
// the README — that's the model weights, not this package.) The pipeline factory is
|
|
6
|
+
// injectable so tests never touch the real dependency.
|
|
7
|
+
|
|
8
|
+
const LOCAL_PREFIX = 'local:'
|
|
9
|
+
|
|
10
|
+
export function isLocalModel(m) {
|
|
11
|
+
return typeof m === 'string' && m.startsWith(LOCAL_PREFIX)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stripLocalPrefix(m) {
|
|
15
|
+
return m.slice(LOCAL_PREFIX.length)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A missing optional dependency surfaces as ERR_MODULE_NOT_FOUND — turn it into an
|
|
19
|
+
// actionable instruction instead of an opaque stack. Other errors pass through.
|
|
20
|
+
export function friendlyImportError(err) {
|
|
21
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
22
|
+
return new Error('local embedding needs @huggingface/transformers; run: npm i @huggingface/transformers')
|
|
23
|
+
}
|
|
24
|
+
return err
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// e5-family models are trained with asymmetric "query:" / "passage:" instruction
|
|
28
|
+
// prefixes; retrieval quality drops materially without them. Other families
|
|
29
|
+
// (e.g. paraphrase-multilingual) encode symmetrically and must NOT get a prefix.
|
|
30
|
+
function applyRolePrefix(model, texts, role) {
|
|
31
|
+
if (!/e5/i.test(model)) return texts
|
|
32
|
+
const tag = role === 'query' ? 'query: ' : 'passage: '
|
|
33
|
+
return texts.map(t => tag + t)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Default factory: dynamically import transformers.js and build a feature-extraction
|
|
37
|
+
// pipeline. Mean-pooled, UN-normalized — the shared normalize() in embed/ask applies
|
|
38
|
+
// the unit-length step, identical to the API path.
|
|
39
|
+
async function defaultPipelineFactory(model) {
|
|
40
|
+
let transformers
|
|
41
|
+
try {
|
|
42
|
+
transformers = await import('@huggingface/transformers')
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw friendlyImportError(err)
|
|
45
|
+
}
|
|
46
|
+
const extractor = await transformers.pipeline('feature-extraction', model)
|
|
47
|
+
return async (texts) => {
|
|
48
|
+
const out = await extractor(texts, { pooling: 'mean', normalize: false })
|
|
49
|
+
return out.tolist() // number[][], one row per input, in order
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cache the real (expensive) per-model load across a batch run / long-lived MCP
|
|
54
|
+
// server. An injected factory (tests) bypasses the cache so tests stay isolated.
|
|
55
|
+
const pipelineCache = new Map() // model -> Promise<embedFn>
|
|
56
|
+
|
|
57
|
+
export async function embedLocal(model, texts, { role = 'passage', pipelineFactory } = {}) {
|
|
58
|
+
const prepared = applyRolePrefix(model, texts, role)
|
|
59
|
+
if (pipelineFactory) {
|
|
60
|
+
const embedFn = await pipelineFactory(model)
|
|
61
|
+
return embedFn(prepared)
|
|
62
|
+
}
|
|
63
|
+
let entry = pipelineCache.get(model)
|
|
64
|
+
if (!entry) {
|
|
65
|
+
entry = defaultPipelineFactory(model)
|
|
66
|
+
pipelineCache.set(model, entry)
|
|
67
|
+
}
|
|
68
|
+
let embedFn
|
|
69
|
+
try { embedFn = await entry }
|
|
70
|
+
catch (err) { pipelineCache.delete(model); throw err } // don't cache a failed load
|
|
71
|
+
return embedFn(prepared)
|
|
72
|
+
}
|