@sdsrs/llm-wiki 0.7.4 → 0.8.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 +27 -0
- package/package.json +1 -1
- package/src/ask.mjs +28 -3
- package/src/templates.mjs +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-07-12)
|
|
4
|
+
|
|
5
|
+
Retrieval `auto` mode gains a cross-language noise guard — an additive config
|
|
6
|
+
key (`lexicalGuard`, default on) with an opt-out. No KB migration, no API
|
|
7
|
+
change, no new dependency. Suite 234 → 246.
|
|
8
|
+
|
|
9
|
+
**Changed:**
|
|
10
|
+
|
|
11
|
+
- **`auto` retrieval ranks vector-only when the lexical and semantic channels
|
|
12
|
+
disagree completely.** When BM25's top-k and the vector top-k return a
|
|
13
|
+
*disjoint* set of pages, BM25 contributed nothing the semantic channel
|
|
14
|
+
corroborates — on a cross-language query (e.g. a Chinese question against an
|
|
15
|
+
English KB) its incidental-token matches are noise that rank-based RRF would
|
|
16
|
+
blend in, pushing correct pages out of top-k. `auto` now drops BM25 and ranks
|
|
17
|
+
by the vector channel alone in that case. Measured on a 500-page KB: pure
|
|
18
|
+
cross-language Recall@5 rose 0.538 → 0.769 (matching pure vector) while
|
|
19
|
+
same-language fact retrieval stayed at 1.000 — the guard never fired on a
|
|
20
|
+
fact probe, so lexical retrieval is untouched where it is doing the work. The
|
|
21
|
+
guard is `auto`-only; explicit `hybrid` mode keeps pure RRF fusion. Set
|
|
22
|
+
`"lexicalGuard": false` in `wiki.config.json` to restore the previous
|
|
23
|
+
always-fuse behavior.
|
|
24
|
+
|
|
25
|
+
**Added:**
|
|
26
|
+
|
|
27
|
+
- `lexicalGuard` config key (boolean, default `true`), emitted into new KBs'
|
|
28
|
+
`wiki.config.json` by `init`.
|
|
29
|
+
|
|
3
30
|
## 0.7.4 (2026-07-12)
|
|
4
31
|
|
|
5
32
|
Two robustness fixes surfaced by an adversarial QA pass over the CLI. No config
|
package/package.json
CHANGED
package/src/ask.mjs
CHANGED
|
@@ -41,16 +41,39 @@ export function rrfFuse(lists, k) {
|
|
|
41
41
|
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, k)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// auto-mode guard: when the lexical (BM25) and semantic (vector) channels return
|
|
45
|
+
// disjoint top-k page sets, BM25 contributed nothing the vector channel corroborates
|
|
46
|
+
// — on a cross-language query its incidental-token hits are noise that rank-based RRF
|
|
47
|
+
// would blend in and push correct pages out of top-k. In that case drop BM25 and rank
|
|
48
|
+
// vector-only. Parameter-free: the overlap test uses the same k the call retrieves.
|
|
49
|
+
// vector.length > 0 is required so the guard never returns an empty list.
|
|
50
|
+
export function fuseChannels({ bm25, vector }, k, { lexicalGuard = true } = {}) {
|
|
51
|
+
if (lexicalGuard && vector.length > 0) {
|
|
52
|
+
const bm25Ids = new Set(bm25.map(h => h.relPath))
|
|
53
|
+
const disjoint = !vector.some(h => bm25Ids.has(h.relPath))
|
|
54
|
+
if (disjoint) {
|
|
55
|
+
return { hits: vector.slice(0, k).map(h => ({ ...h, sources: ['vector'] })), guardApplied: true }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
hits: rrfFuse([{ source: 'bm25', hits: bm25 }, { source: 'vector', hits: vector }], k),
|
|
60
|
+
guardApplied: false,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
// Three modes. 'auto' (default): BM25 always; vector channel only when opted
|
|
45
65
|
// in (vectorEnabled) AND the sidecar exists AND an embeddingModel is
|
|
46
66
|
// configured — fail-open, any vector error degrades to BM25 with a single
|
|
47
|
-
// stderr warning.
|
|
67
|
+
// stderr warning. When both channels run but return disjoint top-k page sets,
|
|
68
|
+
// the lexicalGuard (config, default on) drops BM25 and ranks vector-only so a
|
|
69
|
+
// cross-language query's lexical noise cannot displace correct semantic hits.
|
|
70
|
+
// 'bm25': lexical only, returns before any vector access.
|
|
48
71
|
// 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
|
|
49
72
|
// vector error) throws instead of degrading, and vectorEnabled is ignored.
|
|
50
73
|
export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry } = {}) {
|
|
51
74
|
if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
|
|
52
75
|
const bm25 = retrievePages(kbRoot, question, k)
|
|
53
|
-
const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false })
|
|
76
|
+
const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false, guardApplied: false })
|
|
54
77
|
if (retrieval === 'bm25') return asBm25()
|
|
55
78
|
// 'hybrid' is an explicit request: prerequisites failing is an error, not a
|
|
56
79
|
// silent degrade. 'auto' keeps the opt-in + fail-open contract unchanged.
|
|
@@ -77,7 +100,9 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
77
100
|
// ENOENTs reading a vanished file).
|
|
78
101
|
const valid = new Set(listWikiPages(kbRoot).filter(pg => !pg.error && !isInvalidated(pg)).map(pg => pg.relPath))
|
|
79
102
|
const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
|
|
80
|
-
|
|
103
|
+
const { hits, guardApplied } = fuseChannels({ bm25, vector: vecHitsValid }, k,
|
|
104
|
+
{ lexicalGuard: retrieval === 'auto' && loadKbConfig(kbRoot).lexicalGuard })
|
|
105
|
+
return { hits, usedVector: true, guardApplied }
|
|
81
106
|
} catch (err) {
|
|
82
107
|
if (retrieval === 'hybrid') throw err
|
|
83
108
|
process.stderr.write(`warning: vector retrieval unavailable (${err.message}); falling back to BM25\n`)
|
package/src/templates.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export const DEFAULT_CONFIG = {
|
|
|
14
14
|
maxFileBytes: 52428800,
|
|
15
15
|
bm25TitleWeight: 3,
|
|
16
16
|
vectorEnabled: false,
|
|
17
|
+
lexicalGuard: true,
|
|
17
18
|
relationTypes: ['implements', 'uses', 'depends_on', 'part_of', 'instance_of', 'derived_from', 'contrasts_with', 'causes'],
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -38,6 +39,11 @@ export function loadKbConfig(kbRoot) {
|
|
|
38
39
|
const n = Math.trunc(Number(merged[k]))
|
|
39
40
|
merged[k] = Number.isFinite(n) && n >= 1 ? n : DEFAULT_CONFIG[k]
|
|
40
41
|
}
|
|
42
|
+
// lexicalGuard is a boolean gate; a non-boolean override (string/number/null) must
|
|
43
|
+
// not read as truthy. Only an explicit true/false is honored — anything else falls
|
|
44
|
+
// back to the default. (Targeted, not a general boolean sweep: vectorEnabled keeps
|
|
45
|
+
// its existing truthy semantics to avoid changing behavior on malformed input.)
|
|
46
|
+
merged.lexicalGuard = typeof merged.lexicalGuard === 'boolean' ? merged.lexicalGuard : DEFAULT_CONFIG.lexicalGuard
|
|
41
47
|
return merged
|
|
42
48
|
}
|
|
43
49
|
|