@sdsrs/llm-wiki 0.8.2 → 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 +38 -0
- package/README.md +16 -0
- package/bin/llm-wiki.mjs +3 -1
- package/package.json +9 -1
- package/src/ask.mjs +52 -35
- 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,43 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.8.3 (2026-07-12)
|
|
16
|
+
|
|
17
|
+
Performance and test-hardening cleanup; no shipped behavior change. Suite 261 → 263.
|
|
18
|
+
|
|
19
|
+
**Changed:**
|
|
20
|
+
|
|
21
|
+
- **Retrieval parses the wiki page set once per query, not up to three times.** The
|
|
22
|
+
BM25 build, the vector-hit valid set, and the index-listing fallback's id set now
|
|
23
|
+
share one page cache keyed by the same mtime + size freshness token as the BM25
|
|
24
|
+
index (added in 0.8.2), so any add / edit / removal / invalidation still busts it
|
|
25
|
+
and retired knowledge can never resurface. On a long-lived MCP server a vector
|
|
26
|
+
(`auto`/`hybrid`) query drops its second full page parse; `locatePages` also reads
|
|
27
|
+
`wiki.config.json` once instead of twice.
|
|
28
|
+
- **The `mcp` command loads the MCP SDK + zod lazily.** They were imported at CLI
|
|
29
|
+
startup for every command; only `mcp` needs them. `scan` / `convert` / `ask` /
|
|
30
|
+
`init` and friends now skip ~200–250 ms of import cost.
|
|
31
|
+
|
|
32
|
+
**Internal (not shipped in the npm package):**
|
|
33
|
+
|
|
34
|
+
- Atomic-write regression guards: `saveManifest`, `saveVectorStore`, and the `scan`
|
|
35
|
+
plan write now assert the temp + rename path via a `fs.renameSync` spy — the old
|
|
36
|
+
`.tmp`-absence check alone also passed for a direct truncating write (mem #10097).
|
|
37
|
+
- The full eval report (`scripts/eval/run-all.mjs`) judges the shipped default
|
|
38
|
+
retrieval mode: answer-level head-to-head now defaults to `bm25,auto` (was the
|
|
39
|
+
hardcoded `bm25,hybrid`) and takes an `--answer-arms` flag.
|
|
40
|
+
|
|
3
41
|
## 0.8.2 (2026-07-12)
|
|
4
42
|
|
|
5
43
|
Performance, no behavior change. Suite 260 → 261.
|
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/bin/llm-wiki.mjs
CHANGED
|
@@ -14,7 +14,6 @@ import { statusKb } from '../src/status.mjs'
|
|
|
14
14
|
import { connectProject, installSkills } from '../src/connect.mjs'
|
|
15
15
|
import { exportGraph, loadGraph, exportMarkdownPages } from '../src/export.mjs'
|
|
16
16
|
import { shortestPath, neighborhood, hubs } from '../src/graph.mjs'
|
|
17
|
-
import { runMcpServer } from '../src/mcp.mjs'
|
|
18
17
|
|
|
19
18
|
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
20
19
|
|
|
@@ -203,6 +202,9 @@ program.command('mcp')
|
|
|
203
202
|
.description('run a read-only MCP server (stdio) over the knowledge base')
|
|
204
203
|
.option('--kb <dir>', 'knowledge base root', '.')
|
|
205
204
|
.action(async (opts) => {
|
|
205
|
+
// Lazy import: mcp.mjs pulls in the MCP SDK + zod, which no other command needs.
|
|
206
|
+
// Static-importing it would load them on every scan/convert/ask invocation.
|
|
207
|
+
const { runMcpServer } = await import('../src/mcp.mjs')
|
|
206
208
|
// stdout belongs to the MCP transport from here on — no console.log.
|
|
207
209
|
await runMcpServer(opts.kb)
|
|
208
210
|
})
|
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,29 +5,26 @@ 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'
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
// Two per-KB caches, both keyed by a cheap page-set freshness token so neither can
|
|
14
|
+
// serve a stale view. The token is each live page file's mtime+size: any page
|
|
15
|
+
// add / remove / in-place edit / invalidation (a frontmatter status change IS a
|
|
16
|
+
// content change) flips it and forces a rebuild. Statting the page set is far cheaper
|
|
17
|
+
// than reading + parsing it; MCP is read-only and never mutates pages while serving, so
|
|
18
|
+
// within one server's lifetime pages change only via a separate build process (which
|
|
19
|
+
// rewrites files with new mtimes). Both caches are bounded so a process serving many
|
|
20
|
+
// KBs can't grow them without limit.
|
|
21
|
+
const bm25Cache = new Map() // kbRoot -> { token: `${pagesToken}|w=${w}`, idx }
|
|
22
|
+
const pagesCache = new Map() // kbRoot -> { token: pagesToken, pages }
|
|
23
|
+
const CACHE_MAX = 8
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
// page add / remove / in-place edit / invalidation (a frontmatter status change IS a
|
|
23
|
-
// content change) or config change flips the token and forces a rebuild — so the cache
|
|
24
|
-
// can never serve a stale index. Statting the page set is far cheaper than reading and
|
|
25
|
-
// tokenizing it; MCP is read-only and never mutates pages while serving, so within one
|
|
26
|
-
// server's lifetime pages change only via a separate build process (which rewrites
|
|
27
|
-
// files with new mtimes).
|
|
28
|
-
function pageSetToken(kbRoot, w) {
|
|
25
|
+
function pagesToken(kbRoot) {
|
|
29
26
|
const wiki = kbPaths(kbRoot).wiki
|
|
30
|
-
const parts = [
|
|
27
|
+
const parts = []
|
|
31
28
|
for (const dir of PAGE_DIRS) {
|
|
32
29
|
let entries
|
|
33
30
|
try { entries = fs.readdirSync(path.join(wiki, dir)) } catch { continue }
|
|
@@ -42,6 +39,23 @@ function pageSetToken(kbRoot, w) {
|
|
|
42
39
|
return parts.join('|')
|
|
43
40
|
}
|
|
44
41
|
|
|
42
|
+
// The parsed, filtered live-page list — the dominant per-query cost (listWikiPages
|
|
43
|
+
// reads + frontmatter-parses every page), shared by the BM25 build, the vector-hit
|
|
44
|
+
// valid set, and the listing fallback's validIds. Excluding invalidated pages here is
|
|
45
|
+
// the query-time exclusion every consumer needs, done once. Callers on the same query
|
|
46
|
+
// pass the token already computed for the BM25 cache; standalone callers let it default.
|
|
47
|
+
function livePages(kbRoot, token = pagesToken(kbRoot)) {
|
|
48
|
+
let entry = pagesCache.get(kbRoot)
|
|
49
|
+
if (!entry || entry.token !== token) {
|
|
50
|
+
const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
|
|
51
|
+
pagesCache.delete(kbRoot) // re-insert so this KB becomes most-recent for LRU eviction
|
|
52
|
+
pagesCache.set(kbRoot, { token, pages })
|
|
53
|
+
while (pagesCache.size > CACHE_MAX) pagesCache.delete(pagesCache.keys().next().value)
|
|
54
|
+
entry = pagesCache.get(kbRoot)
|
|
55
|
+
}
|
|
56
|
+
return entry.pages
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
export function retrievePages(kbRoot, question, k = 6) {
|
|
46
60
|
// Title is a field: repeat it bm25TitleWeight times in the indexed text so a
|
|
47
61
|
// title term outweighs the same term buried in the body (measured +0.04 Recall@5
|
|
@@ -50,17 +64,17 @@ export function retrievePages(kbRoot, question, k = 6) {
|
|
|
50
64
|
// loadKbConfig guarantees a finite integer >= 1; cap the upper bound so a large
|
|
51
65
|
// value can't materialize a giant per-page array (Array(w).fill) and OOM retrieval.
|
|
52
66
|
const w = Math.min(25, loadKbConfig(kbRoot).bm25TitleWeight)
|
|
53
|
-
const token =
|
|
67
|
+
const token = pagesToken(kbRoot)
|
|
68
|
+
const bmKey = `${token}|w=${w}` // title weight changes the indexed text but not the page list
|
|
54
69
|
let entry = bm25Cache.get(kbRoot)
|
|
55
|
-
if (!entry || entry.token !==
|
|
56
|
-
const
|
|
57
|
-
const idx = buildBm25Index(pages.map(p => ({
|
|
70
|
+
if (!entry || entry.token !== bmKey) {
|
|
71
|
+
const idx = buildBm25Index(livePages(kbRoot, token).map(p => ({
|
|
58
72
|
id: p.relPath,
|
|
59
73
|
text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(p.data.tags).join(' '), p.body].join('\n'),
|
|
60
74
|
})))
|
|
61
75
|
bm25Cache.delete(kbRoot) // re-insert so this KB becomes most-recent for LRU eviction
|
|
62
|
-
bm25Cache.set(kbRoot, { token, idx })
|
|
63
|
-
while (bm25Cache.size >
|
|
76
|
+
bm25Cache.set(kbRoot, { token: bmKey, idx })
|
|
77
|
+
while (bm25Cache.size > CACHE_MAX) bm25Cache.delete(bm25Cache.keys().next().value)
|
|
64
78
|
entry = bm25Cache.get(kbRoot)
|
|
65
79
|
}
|
|
66
80
|
return searchBm25(entry.idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
|
|
@@ -110,7 +124,7 @@ export function fuseChannels({ bm25, vector }, k, { lexicalGuard = true } = {})
|
|
|
110
124
|
// 'bm25': lexical only, returns before any vector access.
|
|
111
125
|
// 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
|
|
112
126
|
// vector error) throws instead of degrading, and vectorEnabled is ignored.
|
|
113
|
-
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 } = {}) {
|
|
114
128
|
if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
|
|
115
129
|
const bm25 = retrievePages(kbRoot, question, k)
|
|
116
130
|
const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false, guardApplied: false })
|
|
@@ -121,27 +135,32 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
121
135
|
if (retrieval === 'hybrid') throw new Error(`retrieval 'hybrid' unavailable: ${what}`)
|
|
122
136
|
return asBm25()
|
|
123
137
|
}
|
|
124
|
-
|
|
138
|
+
// One config read for both the vectorEnabled gate and the lexicalGuard flag below.
|
|
139
|
+
const kbCfg = loadKbConfig(kbRoot)
|
|
140
|
+
if (retrieval === 'auto' && !kbCfg.vectorEnabled) return asBm25()
|
|
125
141
|
const store = loadVectorStore(kbRoot)
|
|
126
142
|
if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
|
|
127
|
-
const cfg =
|
|
143
|
+
const cfg = loadEmbedConfig(kbRoot)
|
|
128
144
|
if (!cfg?.embeddingModel) return unavailable('no embeddingModel in ~/.llm-wiki/config.json')
|
|
129
145
|
// A store built by a different embeddingModel lives in a foreign vector space:
|
|
130
146
|
// fusing it produces silent garbage, so treat it as missing (not a failure).
|
|
131
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\``)
|
|
132
148
|
try {
|
|
133
|
-
const
|
|
134
|
-
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' })
|
|
135
154
|
const qn = normalize(qv)
|
|
136
155
|
const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
|
|
137
156
|
// The store is a snapshot from the last embed; pages invalidated, deleted, or
|
|
138
157
|
// renamed since then still have vectors. Keep only hits that map to a live,
|
|
139
158
|
// non-invalidated page so retired knowledge cannot resurface (and askKb never
|
|
140
159
|
// ENOENTs reading a vanished file).
|
|
141
|
-
const valid = new Set(
|
|
160
|
+
const valid = new Set(livePages(kbRoot).map(pg => pg.relPath))
|
|
142
161
|
const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
|
|
143
162
|
const { hits, guardApplied } = fuseChannels({ bm25, vector: vecHitsValid }, k,
|
|
144
|
-
{ lexicalGuard: retrieval === 'auto' &&
|
|
163
|
+
{ lexicalGuard: retrieval === 'auto' && kbCfg.lexicalGuard })
|
|
145
164
|
return { hits, usedVector: true, guardApplied }
|
|
146
165
|
} catch (err) {
|
|
147
166
|
if (retrieval === 'hybrid') throw err
|
|
@@ -210,15 +229,13 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
|
|
|
210
229
|
return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
|
|
211
230
|
}
|
|
212
231
|
|
|
213
|
-
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 } = {}) {
|
|
214
233
|
const p = kbPaths(kbRoot)
|
|
215
|
-
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry })
|
|
234
|
+
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
|
|
216
235
|
if (retrieveOnly) return { pages: hits, answer: null }
|
|
217
236
|
let validIds
|
|
218
237
|
if (hits.length === 0) {
|
|
219
|
-
validIds = new Set(
|
|
220
|
-
.filter(pg => !pg.error && !isInvalidated(pg))
|
|
221
|
-
.map(pg => pg.relPath.replace(/\.md$/, '')))
|
|
238
|
+
validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
|
|
222
239
|
if (validIds.size === 0) throw new Error('No relevant pages found — the knowledge base has no valid pages.')
|
|
223
240
|
}
|
|
224
241
|
const cfg = loadLlmConfig(kbRoot)
|
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
|
+
}
|