@sdsrs/llm-wiki 0.4.1 → 0.5.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 +32 -0
- package/package.json +1 -1
- package/src/ask.mjs +17 -8
- package/src/embed.mjs +42 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 (2026-07-11)
|
|
4
|
+
|
|
5
|
+
No breaking changes, no KB migration, defaults unchanged. One behavior fix you
|
|
6
|
+
may notice: `embed` no longer aborts the whole run when a page exceeds the
|
|
7
|
+
embedding model's input limit — the text sent to the API is capped (~8000
|
|
8
|
+
worst-case tokens, with a per-page stderr warning); the page file itself is
|
|
9
|
+
never modified. Pin `@sdsrs/llm-wiki@0.4.1` to keep the previous (aborting)
|
|
10
|
+
behavior.
|
|
11
|
+
|
|
12
|
+
- feat(ask): `locatePages`/`askKb` accept `retrieval: 'auto' | 'bm25' | 'hybrid'`
|
|
13
|
+
(`auto` = existing behavior exactly; `bm25` forces lexical-only; `hybrid`
|
|
14
|
+
forces fusion and errors when vector prerequisites are missing instead of
|
|
15
|
+
degrading). Programmatic surface only — CLI behavior unchanged. `chatCompletion`
|
|
16
|
+
is now an exported helper.
|
|
17
|
+
- fix(embed): oversized pages embed their head instead of killing the run —
|
|
18
|
+
input capped at ~8000 worst-case tokens (binary-search cut, lone-surrogate
|
|
19
|
+
trim at the boundary), per-page warning, incremental reuse unaffected for
|
|
20
|
+
pages under the cap.
|
|
21
|
+
- Eval harness completed (dev, in-repo `scripts/eval/`): four probe classes
|
|
22
|
+
(fact / multihop / xlang / none), answer-level eval (abstention honesty +
|
|
23
|
+
3-dimension LLM-as-judge head-to-head with position-swap debiasing),
|
|
24
|
+
50/150-page scale corpora generated from local markdown (zero network),
|
|
25
|
+
experimental graph-degree RRF arm (measured negative on ./kb — recorded in
|
|
26
|
+
`scripts/eval/README.md`, not promoted to `ask`), and `run-all.mjs`
|
|
27
|
+
producing a single markdown report.
|
|
28
|
+
- Measured 2026-07-11 (./kb 35pp + tiers, k=5): abstention on unanswerable
|
|
29
|
+
probes 100% for both bm25 and hybrid (n=4, zero fabrication); answer-level
|
|
30
|
+
head-to-head hybrid vs bm25 (n=24, judge gpt-5.1) — correctness 7-1-16,
|
|
31
|
+
citations 11-3-10, completeness 7-3-14 (wins-losses-ties); retrieval
|
|
32
|
+
Recall@5 bm25→hybrid: 35pp .688→.979, 50pp .600→1.000, 150pp .667→1.000
|
|
33
|
+
(no BM25-favored regime found in the 35–150 range).
|
|
34
|
+
|
|
3
35
|
## 0.4.1 (2026-07-11)
|
|
4
36
|
|
|
5
37
|
Patch: all fixes, no new features, no deps, no KB migration. Defaults unchanged.
|
package/package.json
CHANGED
package/src/ask.mjs
CHANGED
|
@@ -36,17 +36,25 @@ export function rrfFuse(lists, k) {
|
|
|
36
36
|
// BM25 always; vector channel only when opted in (vectorEnabled) AND the
|
|
37
37
|
// sidecar exists AND an embeddingModel is configured. Fail-open: any vector
|
|
38
38
|
// error degrades to BM25 with a single stderr warning.
|
|
39
|
-
export async function locatePages(kbRoot, question, { k = 6, fetchImpl } = {}) {
|
|
39
|
+
export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto' } = {}) {
|
|
40
|
+
if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
|
|
40
41
|
const bm25 = retrievePages(kbRoot, question, k)
|
|
41
42
|
const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false })
|
|
42
|
-
if (
|
|
43
|
+
if (retrieval === 'bm25') return asBm25()
|
|
44
|
+
// 'hybrid' is an explicit request: prerequisites failing is an error, not a
|
|
45
|
+
// silent degrade. 'auto' keeps the opt-in + fail-open contract unchanged.
|
|
46
|
+
const unavailable = (what) => {
|
|
47
|
+
if (retrieval === 'hybrid') throw new Error(`retrieval 'hybrid' unavailable: ${what}`)
|
|
48
|
+
return asBm25()
|
|
49
|
+
}
|
|
50
|
+
if (retrieval === 'auto' && !loadKbConfig(kbRoot).vectorEnabled) return asBm25()
|
|
43
51
|
const store = loadVectorStore(kbRoot)
|
|
44
|
-
if (!store) return
|
|
52
|
+
if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
|
|
45
53
|
const cfg = loadLlmConfig(kbRoot)
|
|
46
|
-
if (!cfg?.embeddingModel) return
|
|
54
|
+
if (!cfg?.embeddingModel) return unavailable('no embeddingModel in ~/.llm-wiki/config.json')
|
|
47
55
|
// A store built by a different embeddingModel lives in a foreign vector space:
|
|
48
56
|
// fusing it produces silent garbage, so treat it as missing (not a failure).
|
|
49
|
-
if (store.model !== cfg.embeddingModel) return
|
|
57
|
+
if (store.model !== cfg.embeddingModel) return unavailable(`vector store was built with "${store.model}" but config says "${cfg.embeddingModel}" — re-run \`llm-wiki embed\``)
|
|
50
58
|
try {
|
|
51
59
|
const t = fetchImpl ? { fetchImpl, dispatcher: undefined } : await makeTransport()
|
|
52
60
|
const [qv] = await embedTexts(cfg, t, [question])
|
|
@@ -60,12 +68,13 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl } = {}) {
|
|
|
60
68
|
const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
|
|
61
69
|
return { hits: rrfFuse([{ source: 'bm25', hits: bm25 }, { source: 'vector', hits: vecHitsValid }], k), usedVector: true }
|
|
62
70
|
} catch (err) {
|
|
71
|
+
if (retrieval === 'hybrid') throw err
|
|
63
72
|
process.stderr.write(`warning: vector retrieval unavailable (${err.message}); falling back to BM25\n`)
|
|
64
73
|
return asBm25()
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
async function chatCompletion(cfg, t, messages) {
|
|
77
|
+
export async function chatCompletion(cfg, t, messages) {
|
|
69
78
|
const res = await t.fetchImpl(`${cfg.baseURL.replace(/\/$/, '')}/chat/completions`, {
|
|
70
79
|
method: 'POST',
|
|
71
80
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
|
|
@@ -118,9 +127,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
|
|
|
118
127
|
return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
|
|
119
128
|
}
|
|
120
129
|
|
|
121
|
-
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl } = {}) {
|
|
130
|
+
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto' } = {}) {
|
|
122
131
|
const p = kbPaths(kbRoot)
|
|
123
|
-
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl })
|
|
132
|
+
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval })
|
|
124
133
|
if (retrieveOnly) return { pages: hits, answer: null }
|
|
125
134
|
let validIds
|
|
126
135
|
if (hits.length === 0) {
|
package/src/embed.mjs
CHANGED
|
@@ -4,6 +4,44 @@ import { sha256Text } from './hashing.mjs'
|
|
|
4
4
|
import { normalize, pageEmbedText, loadVectorStore, saveVectorStore } from './vector.mjs'
|
|
5
5
|
|
|
6
6
|
const BATCH = 64
|
|
7
|
+
// Safety margin under the 8192-token input limit of common embedding models
|
|
8
|
+
// (e.g. text-embedding-3-small). We cap ONLY the text sent to the embedding API;
|
|
9
|
+
// whole-page retrieval is unaffected — the page file and its BM25 text are never
|
|
10
|
+
// touched, only the vector's input is truncated so one huge page can't abort the run.
|
|
11
|
+
const EMBED_TOKEN_CAP = 8000
|
|
12
|
+
|
|
13
|
+
// Worst-case token count for the embedding API — deliberately NOT scanner's
|
|
14
|
+
// estimateTokens (chars/4), which underestimates dense markdown/code where real
|
|
15
|
+
// BPE runs ~2-3.5 chars/token and lets 8192-token pages slip through. This assumes
|
|
16
|
+
// the pessimistic ~2 chars/token for non-CJK and 1 token/char for CJK, halving
|
|
17
|
+
// usable capacity for English prose — the accepted tradeoff for a deterministic,
|
|
18
|
+
// provider-independent cap. A location vector doesn't need the page's tail.
|
|
19
|
+
// (CJK char class copied from scanner.mjs estimateTokens — kept local on purpose.)
|
|
20
|
+
function worstCaseEmbedTokens(text) {
|
|
21
|
+
let cjk = 0
|
|
22
|
+
for (const ch of text) if (/[ -鿿豈-]/.test(ch)) cjk++
|
|
23
|
+
return cjk + (text.length - cjk) / 2
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Largest prefix of `text` whose worstCaseEmbedTokens is within EMBED_TOKEN_CAP.
|
|
27
|
+
// worstCaseEmbedTokens is monotonic non-decreasing in prefix length, so a binary
|
|
28
|
+
// search over the string finds the cut with no dependencies.
|
|
29
|
+
function capEmbedText(text) {
|
|
30
|
+
if (worstCaseEmbedTokens(text) <= EMBED_TOKEN_CAP) return text
|
|
31
|
+
let lo = 0, hi = text.length
|
|
32
|
+
while (lo < hi) {
|
|
33
|
+
const mid = Math.ceil((lo + hi) / 2)
|
|
34
|
+
if (worstCaseEmbedTokens(text.slice(0, mid)) <= EMBED_TOKEN_CAP) lo = mid
|
|
35
|
+
else hi = mid - 1
|
|
36
|
+
}
|
|
37
|
+
// Slicing by UTF-16 code unit can split an astral char (e.g. emoji, CJK ext-B),
|
|
38
|
+
// leaving a lone high surrogate (0xD800–0xDBFF) at the boundary. JSON.stringify
|
|
39
|
+
// escapes it as a literal \uD83D and a strict provider can reject the request —
|
|
40
|
+
// the exact abort this cap exists to prevent. Drop a dangling high surrogate.
|
|
41
|
+
const last = text.charCodeAt(lo - 1)
|
|
42
|
+
if (lo > 0 && last >= 0xD800 && last <= 0xDBFF) lo--
|
|
43
|
+
return text.slice(0, lo)
|
|
44
|
+
}
|
|
7
45
|
|
|
8
46
|
export async function embedTexts(cfg, t, texts) {
|
|
9
47
|
const res = await t.fetchImpl(`${cfg.baseURL.replace(/\/$/, '')}/embeddings`, {
|
|
@@ -35,9 +73,12 @@ export async function embedKb(kbRoot, { fetchImpl } = {}) {
|
|
|
35
73
|
const nextPages = {}
|
|
36
74
|
let reused = 0
|
|
37
75
|
for (const pg of pages) {
|
|
38
|
-
const
|
|
76
|
+
const raw = pageEmbedText(pg)
|
|
77
|
+
const text = capEmbedText(raw)
|
|
78
|
+
const truncated = text !== raw
|
|
39
79
|
const hash = sha256Text(text)
|
|
40
80
|
if (reuse[pg.relPath]?.hash === hash) { nextPages[pg.relPath] = reuse[pg.relPath]; reused++; continue }
|
|
81
|
+
if (truncated) process.stderr.write(`warning: ${pg.relPath} embed text truncated to ~${EMBED_TOKEN_CAP} worst-case tokens (page exceeds embedding model input limit)\n`)
|
|
41
82
|
jobs.push({ relPath: pg.relPath, text, hash })
|
|
42
83
|
}
|
|
43
84
|
let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
|