@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 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.8.3",
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 = loadLlmConfig(kbRoot)
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 t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
150
- const [qv] = await embedTexts(cfg, t, [question])
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 { loadLlmConfig, makeTransport } from './llm-config.mjs'
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 = loadLlmConfig(kbRoot)
75
- if (!cfg) throw new Error('No LLM configured. Create ~/.llm-wiki/config.json (OpenAI-compatible).')
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 t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
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))
@@ -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
+ }