@sdsrs/llm-wiki 0.8.3 → 0.9.1
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 +32 -0
- package/README.md +16 -0
- package/bin/llm-wiki.mjs +2 -1
- package/package.json +9 -1
- package/src/ask.mjs +10 -7
- package/src/embed.mjs +38 -10
- package/src/llm-config.mjs +18 -0
- package/src/local-embed.mjs +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1 (2026-07-12)
|
|
4
|
+
|
|
5
|
+
Correctness and diagnostics for the 0.9.0 local-embedding path; no change to the
|
|
6
|
+
vectors produced. Suite 271 → 272.
|
|
7
|
+
|
|
8
|
+
**Fixed:**
|
|
9
|
+
|
|
10
|
+
- **Long pages no longer truncate silently on local models.** A local model
|
|
11
|
+
(e.g. `local:Xenova/multilingual-e5-small`) has a ~512-token input window and its
|
|
12
|
+
pipeline silently truncates longer pages to a head-only vector. `embed` now warns per
|
|
13
|
+
page and reports a `truncated N` count in its summary, so the head-only truncation is
|
|
14
|
+
visible instead of silent. The vector produced is unchanged.
|
|
15
|
+
- **A broken transitive dependency is no longer masked as "install
|
|
16
|
+
@huggingface/transformers".** The missing-dependency hint now fires only when
|
|
17
|
+
transformers.js itself is absent; when transformers.js is installed but one of its
|
|
18
|
+
(native) deps fails to load, the original error naming the real culprit passes through.
|
|
19
|
+
- **Clearer `embed` error when no embedding model is configured** — it now names the
|
|
20
|
+
actual fix (set `embeddingModel`) for both remote and local, instead of telling a user
|
|
21
|
+
who already has chat credentials to add credentials.
|
|
22
|
+
|
|
23
|
+
## 0.9.0 (2026-07-12)
|
|
24
|
+
|
|
25
|
+
**Added:**
|
|
26
|
+
|
|
27
|
+
- **Local / offline embedding via transformers.js.** Set `"embeddingModel":
|
|
28
|
+
"local:Xenova/multilingual-e5-small"` in `~/.llm-wiki/config.json` and install the
|
|
29
|
+
optional `@huggingface/transformers` to generate and query vectors with no embedding
|
|
30
|
+
API. With only a local model configured, `embed` / `search` / `ask --retrieve-only`
|
|
31
|
+
run fully offline (answering with `ask` still needs a chat provider). The default
|
|
32
|
+
model is multilingual, so zh↔en cross-language retrieval is preserved. The dependency
|
|
33
|
+
is optional — the base install is unchanged.
|
|
34
|
+
|
|
3
35
|
## 0.8.3 (2026-07-12)
|
|
4
36
|
|
|
5
37
|
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/bin/llm-wiki.mjs
CHANGED
|
@@ -77,7 +77,8 @@ program.command('embed')
|
|
|
77
77
|
.option('--kb <dir>', 'knowledge base root', '.')
|
|
78
78
|
.action(async (opts) => {
|
|
79
79
|
const r = await embedKb(opts.kb)
|
|
80
|
-
|
|
80
|
+
const trunc = r.truncated ? `, truncated ${r.truncated}` : ''
|
|
81
|
+
console.log(`embedded ${r.embedded}, reused ${r.reused}, pruned ${r.pruned}${trunc} (model ${r.model}, dim ${r.dim})`)
|
|
81
82
|
})
|
|
82
83
|
|
|
83
84
|
program.command('ask <question>')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/llm-wiki",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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,10 @@
|
|
|
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 { estimateTokens } from './scanner.mjs'
|
|
7
|
+
import { embedLocal, isLocalModel, stripLocalPrefix } from './local-embed.mjs'
|
|
6
8
|
|
|
7
9
|
const BATCH = 64
|
|
8
10
|
// Safety margin under the 8192-token input limit of common embedding models
|
|
@@ -10,6 +12,15 @@ const BATCH = 64
|
|
|
10
12
|
// whole-page retrieval is unaffected — the page file and its BM25 text are never
|
|
11
13
|
// touched, only the vector's input is truncated so one huge page can't abort the run.
|
|
12
14
|
const EMBED_TOKEN_CAP = 8000
|
|
15
|
+
// Local (transformers.js) models don't reject over-limit input the way an API does —
|
|
16
|
+
// the feature-extraction pipeline silently truncates at the model's own window, so a
|
|
17
|
+
// page over it yields a head-only vector with NO error. e5-small and most Xenova
|
|
18
|
+
// sentence-transformers are 512 tokens; we keep the 8000 cap on the text we send (it
|
|
19
|
+
// bounds compute and stays well above the window so the pipeline still fills it) and
|
|
20
|
+
// warn separately, via a realistic token estimate, whenever a page crosses this window
|
|
21
|
+
// so the head-only truncation is visible instead of silent. A model with a different
|
|
22
|
+
// window would extend this into a per-model lookup.
|
|
23
|
+
const LOCAL_TOKEN_WINDOW = 512
|
|
13
24
|
|
|
14
25
|
// Worst-case token count for the embedding API — deliberately NOT scanner's
|
|
15
26
|
// estimateTokens (chars/4), which underestimates dense markdown/code where real
|
|
@@ -44,7 +55,10 @@ function capEmbedText(text) {
|
|
|
44
55
|
return text.slice(0, lo)
|
|
45
56
|
}
|
|
46
57
|
|
|
47
|
-
export async function embedTexts(cfg, t, texts) {
|
|
58
|
+
export async function embedTexts(cfg, t, texts, { role = 'passage' } = {}) {
|
|
59
|
+
if (isLocalModel(cfg.embeddingModel)) {
|
|
60
|
+
return embedLocal(stripLocalPrefix(cfg.embeddingModel), texts, { role, pipelineFactory: t?.pipelineFactory })
|
|
61
|
+
}
|
|
48
62
|
const url = `${cfg.baseURL.replace(/\/$/, '')}/embeddings`
|
|
49
63
|
let res
|
|
50
64
|
try {
|
|
@@ -70,29 +84,43 @@ export async function embedTexts(cfg, t, texts) {
|
|
|
70
84
|
return [...data.data].sort((a, b) => a.index - b.index).map(d => d.embedding)
|
|
71
85
|
}
|
|
72
86
|
|
|
73
|
-
export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
|
|
74
|
-
const cfg =
|
|
75
|
-
if (!cfg) throw new Error('No
|
|
76
|
-
|
|
87
|
+
export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}) {
|
|
88
|
+
const cfg = loadEmbedConfig(kbRoot)
|
|
89
|
+
if (!cfg?.embeddingModel) throw new Error('No embedding model configured. Set "embeddingModel" in ~/.llm-wiki/config.json — a remote one (e.g. "text-embedding-3-small") also needs the provider baseURL/apiKey; a local one (e.g. "local:Xenova/multilingual-e5-small") needs no credentials.')
|
|
90
|
+
const local = isLocalModel(cfg.embeddingModel)
|
|
77
91
|
const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
|
|
78
92
|
const prev = loadVectorStore(kbRoot)
|
|
79
93
|
const reuse = (prev && prev.model === cfg.embeddingModel) ? prev.pages : {}
|
|
80
94
|
const jobs = []
|
|
81
95
|
const nextPages = {}
|
|
82
96
|
let reused = 0
|
|
97
|
+
let truncatedCount = 0
|
|
83
98
|
for (const pg of pages) {
|
|
84
99
|
const raw = pageEmbedText(pg)
|
|
85
100
|
const text = capEmbedText(raw)
|
|
86
|
-
|
|
101
|
+
// API path: `truncated` means our worst-case cap actually cut the text (the API
|
|
102
|
+
// would otherwise reject it). Local path: the cap rarely fires (window ≪ 8000), so
|
|
103
|
+
// detect the model's OWN silent truncation with a realistic estimate against its
|
|
104
|
+
// window — that's what produces a head-only vector the user should know about.
|
|
105
|
+
const overWindow = local && estimateTokens(raw) > LOCAL_TOKEN_WINDOW
|
|
106
|
+
const truncated = overWindow || (!local && text !== raw)
|
|
87
107
|
const hash = sha256Text(text)
|
|
88
108
|
if (reuse[pg.relPath]?.hash === hash) { nextPages[pg.relPath] = reuse[pg.relPath]; reused++; continue }
|
|
89
|
-
if (truncated)
|
|
109
|
+
if (truncated) {
|
|
110
|
+
truncatedCount++
|
|
111
|
+
process.stderr.write(overWindow
|
|
112
|
+
? `warning: ${pg.relPath} exceeds the local embedding model's ~${LOCAL_TOKEN_WINDOW}-token window — its vector covers only the page head\n`
|
|
113
|
+
: `warning: ${pg.relPath} embed text truncated to ~${EMBED_TOKEN_CAP} worst-case tokens (page exceeds embedding model input limit)\n`)
|
|
114
|
+
}
|
|
90
115
|
jobs.push({ relPath: pg.relPath, text, hash })
|
|
91
116
|
}
|
|
92
117
|
let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
|
|
93
118
|
let embedded = 0
|
|
94
119
|
if (jobs.length > 0) {
|
|
95
|
-
const
|
|
120
|
+
const injected = fetchImpl || pipelineFactory
|
|
121
|
+
const t = injected
|
|
122
|
+
? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
|
|
123
|
+
: { ...(await makeTransport()), retry }
|
|
96
124
|
for (let i = 0; i < jobs.length; i += BATCH) {
|
|
97
125
|
const batch = jobs.slice(i, i + BATCH)
|
|
98
126
|
const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
|
|
@@ -113,5 +141,5 @@ export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
|
|
|
113
141
|
const pruned = prev ? Object.keys(prev.pages).filter(id => !(id in nextPages)).length : 0
|
|
114
142
|
const store = { model: cfg.embeddingModel, dim: dim ?? 0, pages: nextPages }
|
|
115
143
|
saveVectorStore(kbRoot, store)
|
|
116
|
-
return { embedded, reused, pruned, model: cfg.embeddingModel, dim: store.dim }
|
|
144
|
+
return { embedded, reused, pruned, truncated: truncatedCount, model: cfg.embeddingModel, dim: store.dim }
|
|
117
145
|
}
|
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,83 @@
|
|
|
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. Only map it when the ABSENT
|
|
20
|
+
// module is transformers.js itself: the same code fires when transformers.js IS
|
|
21
|
+
// installed but one of its (transitive/native) deps fails to resolve, and telling the
|
|
22
|
+
// user to re-install transformers then hides the real missing module. Pass those
|
|
23
|
+
// through so their message names the actual culprit.
|
|
24
|
+
export function friendlyImportError(err) {
|
|
25
|
+
if (err?.code !== 'ERR_MODULE_NOT_FOUND') return err
|
|
26
|
+
// Gate on the MISSING package — the quoted name after "Cannot find package/module" —
|
|
27
|
+
// NOT a substring of the whole message. A transitive failure names its real culprit
|
|
28
|
+
// in the quotes (e.g. 'onnxruntime-node') yet still mentions "@huggingface/transformers"
|
|
29
|
+
// in the "imported from <path>" tail, so a whole-message test mis-maps it. Rewrite only
|
|
30
|
+
// when the absent module IS transformers.js (the package root or a subpath import of it).
|
|
31
|
+
const missing = /Cannot find (?:package|module) '([^']+)'/.exec(err.message ?? '')?.[1]
|
|
32
|
+
if (missing && /^@huggingface\/transformers(\/|$)/.test(missing)) {
|
|
33
|
+
return new Error('local embedding needs @huggingface/transformers; run: npm i @huggingface/transformers')
|
|
34
|
+
}
|
|
35
|
+
return err
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// e5-family models are trained with asymmetric "query:" / "passage:" instruction
|
|
39
|
+
// prefixes; retrieval quality drops materially without them. Other families
|
|
40
|
+
// (e.g. paraphrase-multilingual) encode symmetrically and must NOT get a prefix.
|
|
41
|
+
function applyRolePrefix(model, texts, role) {
|
|
42
|
+
if (!/e5/i.test(model)) return texts
|
|
43
|
+
const tag = role === 'query' ? 'query: ' : 'passage: '
|
|
44
|
+
return texts.map(t => tag + t)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Default factory: dynamically import transformers.js and build a feature-extraction
|
|
48
|
+
// pipeline. Mean-pooled, UN-normalized — the shared normalize() in embed/ask applies
|
|
49
|
+
// the unit-length step, identical to the API path.
|
|
50
|
+
async function defaultPipelineFactory(model) {
|
|
51
|
+
let transformers
|
|
52
|
+
try {
|
|
53
|
+
transformers = await import('@huggingface/transformers')
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw friendlyImportError(err)
|
|
56
|
+
}
|
|
57
|
+
const extractor = await transformers.pipeline('feature-extraction', model)
|
|
58
|
+
return async (texts) => {
|
|
59
|
+
const out = await extractor(texts, { pooling: 'mean', normalize: false })
|
|
60
|
+
return out.tolist() // number[][], one row per input, in order
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Cache the real (expensive) per-model load across a batch run / long-lived MCP
|
|
65
|
+
// server. An injected factory (tests) bypasses the cache so tests stay isolated.
|
|
66
|
+
const pipelineCache = new Map() // model -> Promise<embedFn>
|
|
67
|
+
|
|
68
|
+
export async function embedLocal(model, texts, { role = 'passage', pipelineFactory } = {}) {
|
|
69
|
+
const prepared = applyRolePrefix(model, texts, role)
|
|
70
|
+
if (pipelineFactory) {
|
|
71
|
+
const embedFn = await pipelineFactory(model)
|
|
72
|
+
return embedFn(prepared)
|
|
73
|
+
}
|
|
74
|
+
let entry = pipelineCache.get(model)
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = defaultPipelineFactory(model)
|
|
77
|
+
pipelineCache.set(model, entry)
|
|
78
|
+
}
|
|
79
|
+
let embedFn
|
|
80
|
+
try { embedFn = await entry }
|
|
81
|
+
catch (err) { pipelineCache.delete(model); throw err } // don't cache a failed load
|
|
82
|
+
return embedFn(prepared)
|
|
83
|
+
}
|