knolo-core 0.1.4 → 0.2.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/DOCS.md +355 -0
- package/LICENSE +20 -17
- package/README.md +196 -61
- package/dist/builder.d.ts +0 -5
- package/dist/builder.js +31 -28
- package/dist/pack.d.ts +3 -29
- package/dist/pack.js +43 -37
- package/dist/quality/diversify.d.ts +13 -0
- package/dist/quality/diversify.js +41 -0
- package/dist/quality/proximity.d.ts +2 -0
- package/dist/quality/proximity.js +31 -0
- package/dist/quality/signature.d.ts +3 -0
- package/dist/quality/signature.js +24 -0
- package/dist/quality/similarity.d.ts +3 -0
- package/dist/quality/similarity.js +27 -0
- package/dist/query.d.ts +1 -7
- package/dist/query.js +129 -70
- package/dist/rank.d.ts +7 -6
- package/dist/rank.js +6 -19
- package/dist/utils/utf8.d.ts +8 -0
- package/dist/utils/utf8.js +72 -0
- package/package.json +22 -7
package/README.md
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
|
|
2
|
-
|
|
3
2
|
# 🧠 KnoLo Core
|
|
4
3
|
|
|
5
4
|
[](https://www.npmjs.com/package/knolo-core)
|
|
6
5
|
[](https://www.npmjs.com/package/knolo-core)
|
|
7
6
|
[](./LICENSE)
|
|
8
7
|
|
|
9
|
-
**KnoLo Core** is a **local-first knowledge base
|
|
10
|
-
|
|
8
|
+
**KnoLo Core** is a **local-first knowledge base** for small LLMs.
|
|
9
|
+
Package documents into a compact `.knolo` file and query them deterministically —
|
|
10
|
+
**no embeddings, no vector DB, no cloud**. Ideal for **on‑device / offline** assistants.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
## ✨
|
|
14
|
+
## ✨ Highlights (v0.2.0)
|
|
15
|
+
|
|
16
|
+
* 🔎 **Stronger relevance:**
|
|
15
17
|
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* **Required phrase enforcement** (quoted & `requirePhrases`)
|
|
19
|
+
* **Proximity bonus** using minimal term-span cover
|
|
20
|
+
* **Optional heading boosts** when headings are present
|
|
21
|
+
* 🌀 **Duplicate-free results:** **near-duplicate suppression** + **MMR diversity**
|
|
22
|
+
* 🧮 **KNS tie‑breaker:** lightweight numeric signature to stabilize close ties
|
|
23
|
+
* ⚡ **Faster & leaner:** precomputed `avgBlockLen` in pack metadata
|
|
24
|
+
* 📱 **Works in Expo/React Native:** safe TextEncoder/TextDecoder ponyfills
|
|
25
|
+
* 📑 **Context Patches:** LLM‑friendly snippets for prompts
|
|
26
|
+
* 🔒 **Local & private:** everything runs on device
|
|
21
27
|
|
|
22
28
|
---
|
|
23
29
|
|
|
@@ -27,7 +33,7 @@ It lets you package your own documents into a compact `.knolo` file and query th
|
|
|
27
33
|
npm install knolo-core
|
|
28
34
|
```
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
Dev from source:
|
|
31
37
|
|
|
32
38
|
```bash
|
|
33
39
|
git clone https://github.com/yourname/knolo-core.git
|
|
@@ -38,113 +44,242 @@ npm run build
|
|
|
38
44
|
|
|
39
45
|
---
|
|
40
46
|
|
|
41
|
-
## 🚀 Usage
|
|
47
|
+
## 🚀 Usage
|
|
42
48
|
|
|
43
|
-
### 1
|
|
49
|
+
### 1) Node.js (build → mount → query → patch)
|
|
44
50
|
|
|
45
|
-
```
|
|
51
|
+
```ts
|
|
46
52
|
import { buildPack, mountPack, query, makeContextPatch } from "knolo-core";
|
|
47
53
|
|
|
48
54
|
const docs = [
|
|
49
|
-
{ heading: "React Native Bridge", text: "The bridge sends messages between JS and native. You can throttle events
|
|
50
|
-
{ heading: "Throttling",
|
|
51
|
-
{ heading: "Debounce vs Throttle", text: "Debounce waits for silence
|
|
55
|
+
{ id: "guide", heading: "React Native Bridge", text: "The bridge sends messages between JS and native. You can throttle events..." },
|
|
56
|
+
{ id: "throttle", heading: "Throttling", text: "Throttling reduces frequency of events to avoid flooding the bridge." },
|
|
57
|
+
{ id: "dvst", heading: "Debounce vs Throttle", text: "Debounce waits for silence; throttle guarantees a max rate." }
|
|
52
58
|
];
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
const
|
|
60
|
+
const bytes = await buildPack(docs); // build .knolo bytes
|
|
61
|
+
const kb = await mountPack({ src: bytes }); // mount in-memory
|
|
62
|
+
const hits = query(kb, '“react native” throttle', // quotes enforce phrase
|
|
63
|
+
{ topK: 5, requirePhrases: ["max rate"] });
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
console.log(hits);
|
|
66
|
+
/*
|
|
67
|
+
[
|
|
68
|
+
{ blockId: 2, score: 6.73, text: "...", source: "dvst" },
|
|
69
|
+
...
|
|
70
|
+
]
|
|
71
|
+
*/
|
|
63
72
|
|
|
64
|
-
// Turn into an LLM-friendly context patch
|
|
65
73
|
const patch = makeContextPatch(hits, { budget: "small" });
|
|
66
|
-
console.log(
|
|
74
|
+
console.log(patch);
|
|
67
75
|
```
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
### 2. CLI (build `.knolo` file)
|
|
77
|
+
### 2) CLI (build a `.knolo` file)
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
Create `docs.json`:
|
|
74
80
|
|
|
75
81
|
```json
|
|
76
82
|
[
|
|
77
|
-
{ "heading": "Guide", "text": "Install deps
|
|
78
|
-
{ "heading": "FAQ",
|
|
83
|
+
{ "id": "guide", "heading": "Guide", "text": "Install deps...\n\n## Throttle\nLimit frequency of events." },
|
|
84
|
+
{ "id": "faq", "heading": "FAQ", "text": "What is throttling? It reduces event frequency." }
|
|
79
85
|
]
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
|
|
88
|
+
Build:
|
|
83
89
|
|
|
84
90
|
```bash
|
|
85
|
-
# writes
|
|
86
|
-
npx knolo docs.json
|
|
91
|
+
# writes knowledge.knolo
|
|
92
|
+
npx knolo docs.json knowledge.knolo
|
|
87
93
|
```
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
Then load it in your app:
|
|
90
96
|
|
|
91
|
-
```
|
|
97
|
+
```ts
|
|
92
98
|
import { mountPack, query } from "knolo-core";
|
|
93
|
-
|
|
94
|
-
const kb = await mountPack({ src: "./mypack.knolo" });
|
|
99
|
+
const kb = await mountPack({ src: "./knowledge.knolo" });
|
|
95
100
|
const hits = query(kb, "throttle events", { topK: 3 });
|
|
96
|
-
console.log(hits);
|
|
97
101
|
```
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
### 3. React / Expo (load from asset)
|
|
103
|
+
### 3) React / Expo
|
|
102
104
|
|
|
103
105
|
```ts
|
|
104
106
|
import { Asset } from "expo-asset";
|
|
105
107
|
import * as FileSystem from "expo-file-system";
|
|
106
|
-
import { mountPack, query
|
|
108
|
+
import { mountPack, query } from "knolo-core";
|
|
107
109
|
|
|
108
|
-
async function
|
|
109
|
-
const asset = Asset.fromModule(require("./assets/
|
|
110
|
+
async function loadKB() {
|
|
111
|
+
const asset = Asset.fromModule(require("./assets/knowledge.knolo"));
|
|
110
112
|
await asset.downloadAsync();
|
|
111
113
|
|
|
112
114
|
const base64 = await FileSystem.readAsStringAsync(asset.localUri!, { encoding: FileSystem.EncodingType.Base64 });
|
|
113
115
|
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
|
|
114
116
|
|
|
115
117
|
const kb = await mountPack({ src: bytes.buffer });
|
|
116
|
-
|
|
117
|
-
return makeContextPatch(hits, { budget: "mini" });
|
|
118
|
+
return query(kb, `“react native” throttling`, { topK: 5 });
|
|
118
119
|
}
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
---
|
|
122
123
|
|
|
123
|
-
## 📑 API
|
|
124
|
+
## 📑 API
|
|
125
|
+
|
|
126
|
+
### `buildPack(docs) -> Promise<Uint8Array>`
|
|
127
|
+
|
|
128
|
+
Builds a pack from an array of documents.
|
|
124
129
|
|
|
125
130
|
```ts
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
type BuildInputDoc = {
|
|
132
|
+
id?: string; // optional doc id (exposed as hit.source)
|
|
133
|
+
heading?: string; // optional heading (used for boosts)
|
|
134
|
+
text: string; // raw markdown accepted (lightly stripped)
|
|
135
|
+
};
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
* Stores optional `heading` and `id` alongside each block.
|
|
139
|
+
* Computes and persists `meta.stats.avgBlockLen` for faster queries.
|
|
140
|
+
|
|
141
|
+
### `mountPack({ src }) -> Promise<Pack>`
|
|
142
|
+
|
|
143
|
+
Loads a pack from a URL, `Uint8Array`, or `ArrayBuffer`.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
type Pack = {
|
|
147
|
+
meta: { version: number; stats: { docs: number; blocks: number; terms: number; avgBlockLen?: number } };
|
|
148
|
+
lexicon: Map<string, number>;
|
|
149
|
+
postings: Uint32Array;
|
|
150
|
+
blocks: string[];
|
|
151
|
+
headings?: (string | null)[];
|
|
152
|
+
docIds?: (string | null)[];
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
> **Compatibility:** v0.2.0 reads both v1 packs (string-only blocks) and v2 packs (objects with `text/heading/docId`).
|
|
128
157
|
|
|
129
|
-
|
|
130
|
-
mountPack({ src: string | Uint8Array | ArrayBuffer }) -> Promise<Pack>
|
|
158
|
+
### `query(pack, q, opts) -> Hit[]`
|
|
131
159
|
|
|
132
|
-
|
|
133
|
-
query(pack, "your query", { topK?: number, requirePhrases?: string[] }) -> Hit[]
|
|
160
|
+
Deterministic lexical search with phrase enforcement, proximity, and de‑duplication.
|
|
134
161
|
|
|
135
|
-
|
|
136
|
-
|
|
162
|
+
```ts
|
|
163
|
+
type QueryOptions = {
|
|
164
|
+
topK?: number; // default 10
|
|
165
|
+
requirePhrases?: string[]; // additional phrases to require (unquoted)
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
type Hit = {
|
|
169
|
+
blockId: number;
|
|
170
|
+
score: number;
|
|
171
|
+
text: string;
|
|
172
|
+
source?: string; // docId if provided at build time
|
|
173
|
+
};
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**What happens under the hood (v0.2.0):**
|
|
177
|
+
|
|
178
|
+
* Tokenize + **enforce all phrases** (quoted in `q` and `requirePhrases`)
|
|
179
|
+
* Candidate generation via inverted index
|
|
180
|
+
* **Proximity bonus** using minimal window covering all query terms
|
|
181
|
+
* Optional **heading overlap boost** (when headings are present)
|
|
182
|
+
* Tiny **KNS** numeric-signature tie‑breaker (\~±2% influence)
|
|
183
|
+
* **Near-duplicate suppression** (5‑gram Jaccard) + **MMR** diversity for top‑K
|
|
184
|
+
|
|
185
|
+
### `makeContextPatch(hits, { budget }) -> ContextPatch`
|
|
186
|
+
|
|
187
|
+
Create structured snippets for LLM prompts.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
type ContextPatch = {
|
|
191
|
+
background: string[];
|
|
192
|
+
snippets: Array<{ text: string; source?: string }>;
|
|
193
|
+
definitions: Array<{ term: string; def: string; evidence?: number[] }>;
|
|
194
|
+
facts: Array<{ s: string; p: string; o: string; evidence?: number[] }>;
|
|
195
|
+
};
|
|
137
196
|
```
|
|
138
197
|
|
|
198
|
+
Budgets: `"mini" | "small" | "full"`.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 🧠 Relevance & De‑dupe Details
|
|
203
|
+
|
|
204
|
+
* **Phrases:**
|
|
205
|
+
Quoted phrases in the query (e.g., `“react native”`) and any `requirePhrases` **must appear** in results. Candidates failing this are dropped before ranking.
|
|
206
|
+
|
|
207
|
+
* **Proximity:**
|
|
208
|
+
We compute the **minimum span** that covers all query terms and apply a gentle multiplier:
|
|
209
|
+
`1 + 0.15 / (1 + span)` (bounded, stable).
|
|
210
|
+
|
|
211
|
+
* **Heading Boost:**
|
|
212
|
+
If you provide headings at build time, overlap with query terms boosts the score proportionally to the fraction of unique query terms present in the heading.
|
|
213
|
+
|
|
214
|
+
* **Duplicate Control:**
|
|
215
|
+
We use **5‑gram Jaccard** to filter near‑duplicates and **MMR** (λ≈0.8) to promote diversity within the top‑K.
|
|
216
|
+
|
|
217
|
+
* **KNS Signature (optional spice):**
|
|
218
|
+
A tiny numeric signature provides deterministic tie‑breaking without changing the overall retrieval behavior.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 🛠 Input Format & Pack Layout
|
|
223
|
+
|
|
224
|
+
**Input docs:**
|
|
225
|
+
`{ id?: string, heading?: string, text: string }`
|
|
226
|
+
|
|
227
|
+
**Pack layout (binary):**
|
|
228
|
+
`[metaLen:u32][meta JSON][lexLen:u32][lexicon JSON][postCount:u32][postings][blocksLen:u32][blocks JSON]`
|
|
229
|
+
|
|
230
|
+
* `meta.stats.avgBlockLen` is persisted (v2).
|
|
231
|
+
* `blocks JSON` may be:
|
|
232
|
+
|
|
233
|
+
* **v1:** `string[]` (text only)
|
|
234
|
+
* **v2:** `{ text, heading?, docId? }[]`
|
|
235
|
+
|
|
236
|
+
The runtime auto‑detects either format.
|
|
237
|
+
|
|
139
238
|
---
|
|
140
239
|
|
|
141
|
-
##
|
|
240
|
+
## 🔁 Migration (0.1.x → 0.2.0)
|
|
241
|
+
|
|
242
|
+
* **No API breaks.** `buildPack`, `mountPack`, `query`, `makeContextPatch` unchanged.
|
|
243
|
+
* Packs built with 0.1.x still load and query fine.
|
|
244
|
+
* If you want heading boosts and `hit.source`, pass `heading` and `id` to `buildPack`.
|
|
245
|
+
* React Native/Expo users no longer need polyfills—ponyfills are included.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## ⚡ Performance Tips
|
|
250
|
+
|
|
251
|
+
* Prefer multiple smaller blocks (≈512 tokens) over giant ones for better recall + proximity.
|
|
252
|
+
* Provide `heading` for each block: cheap, high‑signal boost.
|
|
253
|
+
* For large corpora, consider sharding packs by domain/topic to keep per‑pack size modest.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## ❓ FAQ
|
|
258
|
+
|
|
259
|
+
**Q: Does this use embeddings?**
|
|
260
|
+
No. Pure lexical retrieval (index, positions, BM25L, proximity, phrases).
|
|
261
|
+
|
|
262
|
+
**Q: Can I run this offline?**
|
|
263
|
+
Yes. Everything is local.
|
|
264
|
+
|
|
265
|
+
**Q: How do I prevent duplicates?**
|
|
266
|
+
It’s built in (Jaccard + MMR). You can tune λ and similarity threshold in code if you fork.
|
|
267
|
+
|
|
268
|
+
**Q: Is RN/Expo supported?**
|
|
269
|
+
Yes—TextEncoder/TextDecoder ponyfills are included.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## 🗺️ Roadmap
|
|
142
274
|
|
|
143
275
|
* Multi-resolution packs (summaries + facts)
|
|
144
|
-
* Overlay
|
|
145
|
-
* WASM core for
|
|
276
|
+
* Overlay layers (user annotations)
|
|
277
|
+
* WASM core for big-browser indexing
|
|
278
|
+
* Delta updates / append-only patch packs
|
|
146
279
|
|
|
147
280
|
---
|
|
148
281
|
|
|
149
|
-
|
|
282
|
+
## 📄 License
|
|
283
|
+
|
|
284
|
+
Apache-2.0 — see [LICENSE](./LICENSE).
|
|
150
285
|
|
package/dist/builder.d.ts
CHANGED
|
@@ -3,9 +3,4 @@ export type BuildInputDoc = {
|
|
|
3
3
|
heading?: string;
|
|
4
4
|
text: string;
|
|
5
5
|
};
|
|
6
|
-
/** Build a `.knolo` pack from an array of input documents. At present each
|
|
7
|
-
* document becomes a single block. Future versions may split documents into
|
|
8
|
-
* multiple blocks based on headings or token count to improve retrieval
|
|
9
|
-
* granularity.
|
|
10
|
-
*/
|
|
11
6
|
export declare function buildPack(docs: BuildInputDoc[]): Promise<Uint8Array>;
|
package/dist/builder.js
CHANGED
|
@@ -1,34 +1,44 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* builder.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `id`, `heading` and `text` fields. The builder performs simple
|
|
7
|
-
* Markdown stripping and calls the indexer to generate the inverted index. The
|
|
8
|
-
* resulting pack binary can be persisted to disk or served directly to
|
|
9
|
-
* clients.
|
|
4
|
+
* Build `.knolo` packs from input docs. Now persists optional headings/docIds
|
|
5
|
+
* and stores avgBlockLen in meta for faster/easier normalization at query-time.
|
|
10
6
|
*/
|
|
11
7
|
import { buildIndex } from './indexer.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* multiple blocks based on headings or token count to improve retrieval
|
|
15
|
-
* granularity.
|
|
16
|
-
*/
|
|
8
|
+
import { tokenize } from './tokenize.js';
|
|
9
|
+
import { getTextEncoder } from './utils/utf8.js';
|
|
17
10
|
export async function buildPack(docs) {
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
// Prepare blocks (strip MD) and carry heading/docId for optional boosts.
|
|
12
|
+
const blocks = docs.map((d, i) => ({
|
|
13
|
+
id: i,
|
|
14
|
+
text: stripMd(d.text),
|
|
15
|
+
heading: d.heading,
|
|
16
|
+
}));
|
|
17
|
+
// Build index
|
|
21
18
|
const { lexicon, postings } = buildIndex(blocks);
|
|
19
|
+
// Compute avg token length once (store in meta)
|
|
20
|
+
const totalTokens = blocks.reduce((sum, b) => sum + tokenize(b.text).length, 0);
|
|
21
|
+
const avgBlockLen = blocks.length ? totalTokens / blocks.length : 1;
|
|
22
22
|
const meta = {
|
|
23
|
-
version:
|
|
24
|
-
stats: {
|
|
23
|
+
version: 2,
|
|
24
|
+
stats: {
|
|
25
|
+
docs: docs.length,
|
|
26
|
+
blocks: blocks.length,
|
|
27
|
+
terms: lexicon.length,
|
|
28
|
+
avgBlockLen,
|
|
29
|
+
},
|
|
25
30
|
};
|
|
26
|
-
//
|
|
27
|
-
const
|
|
31
|
+
// Persist blocks as objects to optionally carry heading/docId
|
|
32
|
+
const blocksPayload = blocks.map((b, i) => ({
|
|
33
|
+
text: b.text,
|
|
34
|
+
heading: b.heading ?? null,
|
|
35
|
+
docId: docs[i]?.id ?? null,
|
|
36
|
+
}));
|
|
37
|
+
// Encode sections
|
|
38
|
+
const enc = getTextEncoder();
|
|
28
39
|
const metaBytes = enc.encode(JSON.stringify(meta));
|
|
29
40
|
const lexBytes = enc.encode(JSON.stringify(lexicon));
|
|
30
|
-
const blocksBytes = enc.encode(JSON.stringify(
|
|
31
|
-
// Compute lengths and allocate output
|
|
41
|
+
const blocksBytes = enc.encode(JSON.stringify(blocksPayload));
|
|
32
42
|
const totalLength = 4 + metaBytes.length +
|
|
33
43
|
4 + lexBytes.length +
|
|
34
44
|
4 + postings.length * 4 +
|
|
@@ -61,19 +71,12 @@ export async function buildPack(docs) {
|
|
|
61
71
|
}
|
|
62
72
|
/** Strip Markdown syntax with lightweight regexes (no deps). */
|
|
63
73
|
function stripMd(md) {
|
|
64
|
-
|
|
65
|
-
let text = md.replace(/```[^```]*```/g, ' ');
|
|
66
|
-
// Remove inline code backticks
|
|
74
|
+
let text = md.replace(/```[\s\S]*?```/g, ' ');
|
|
67
75
|
text = text.replace(/`[^`]*`/g, ' ');
|
|
68
|
-
// Remove emphasis markers (*, _, ~)
|
|
69
76
|
text = text.replace(/[\*_~]+/g, ' ');
|
|
70
|
-
// Remove headings (#)
|
|
71
77
|
text = text.replace(/^#+\s*/gm, '');
|
|
72
|
-
// Links [text](url) -> text
|
|
73
78
|
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
|
74
|
-
// Remove any remaining brackets
|
|
75
79
|
text = text.replace(/[\[\]()]/g, ' ');
|
|
76
|
-
// Collapse whitespace
|
|
77
80
|
text = text.replace(/\s+/g, ' ').trim();
|
|
78
81
|
return text;
|
|
79
82
|
}
|
package/dist/pack.d.ts
CHANGED
|
@@ -1,47 +1,21 @@
|
|
|
1
1
|
export type MountOptions = {
|
|
2
2
|
src: string | ArrayBufferLike | Uint8Array;
|
|
3
3
|
};
|
|
4
|
-
/** Metadata about the pack. Version numbers should increment with format
|
|
5
|
-
* changes, allowing the runtime to adapt accordingly. */
|
|
6
4
|
export type PackMeta = {
|
|
7
5
|
version: number;
|
|
8
6
|
stats: {
|
|
9
7
|
docs: number;
|
|
10
8
|
blocks: number;
|
|
11
9
|
terms: number;
|
|
10
|
+
avgBlockLen?: number;
|
|
12
11
|
};
|
|
13
12
|
};
|
|
14
|
-
/**
|
|
15
|
-
* A mounted pack exposing the inverted index, block text and optional field
|
|
16
|
-
* metadata. The core runtime reads from these structures directly at query
|
|
17
|
-
* time.
|
|
18
|
-
*/
|
|
19
13
|
export type Pack = {
|
|
20
14
|
meta: PackMeta;
|
|
21
|
-
/** Map of token to term identifier used in the postings list. */
|
|
22
15
|
lexicon: Map<string, number>;
|
|
23
|
-
/** Flattened postings list where each term section starts with the termId
|
|
24
|
-
* followed by (blockId, positions..., 0) tuples, ending with a 0.
|
|
25
|
-
*/
|
|
26
16
|
postings: Uint32Array;
|
|
27
|
-
/** Array of block texts. Each block corresponds to a chunk of the original
|
|
28
|
-
* documents. The blockId used in the postings list indexes into this array.
|
|
29
|
-
*/
|
|
30
17
|
blocks: string[];
|
|
18
|
+
headings?: (string | null)[];
|
|
19
|
+
docIds?: (string | null)[];
|
|
31
20
|
};
|
|
32
|
-
/**
|
|
33
|
-
* Load a `.knolo` pack from a variety of sources. The pack binary layout is
|
|
34
|
-
* currently:
|
|
35
|
-
*
|
|
36
|
-
* [metaLen:u32][meta JSON][lexLen:u32][lexicon JSON][postCount:u32][postings][blocksLen:u32][blocks JSON]
|
|
37
|
-
*
|
|
38
|
-
* All integers are little endian. `metaLen`, `lexLen` and `blocksLen` denote
|
|
39
|
-
* the byte lengths of the subsequent JSON sections. `postCount` is the number
|
|
40
|
-
* of 32‑bit integers in the postings array. This simple layout is sufficient
|
|
41
|
-
* for v0 and avoids any additional dependencies beyond standard typed arrays.
|
|
42
|
-
*
|
|
43
|
-
* @param opts Options specifying how to load the pack. Accepts a URL string,
|
|
44
|
-
* ArrayBuffer, or Uint8Array.
|
|
45
|
-
* @returns A Promise resolving to a mounted pack with the index and blocks.
|
|
46
|
-
*/
|
|
47
21
|
export declare function mountPack(opts: MountOptions): Promise<Pack>;
|
package/dist/pack.js
CHANGED
|
@@ -1,45 +1,31 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* pack.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* portable across Node.js and browser environments.
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* Load a `.knolo` pack from a variety of sources. The pack binary layout is
|
|
12
|
-
* currently:
|
|
13
|
-
*
|
|
14
|
-
* [metaLen:u32][meta JSON][lexLen:u32][lexicon JSON][postCount:u32][postings][blocksLen:u32][blocks JSON]
|
|
15
|
-
*
|
|
16
|
-
* All integers are little endian. `metaLen`, `lexLen` and `blocksLen` denote
|
|
17
|
-
* the byte lengths of the subsequent JSON sections. `postCount` is the number
|
|
18
|
-
* of 32‑bit integers in the postings array. This simple layout is sufficient
|
|
19
|
-
* for v0 and avoids any additional dependencies beyond standard typed arrays.
|
|
20
|
-
*
|
|
21
|
-
* @param opts Options specifying how to load the pack. Accepts a URL string,
|
|
22
|
-
* ArrayBuffer, or Uint8Array.
|
|
23
|
-
* @returns A Promise resolving to a mounted pack with the index and blocks.
|
|
4
|
+
* Mount `.knolo` packs across Node, browsers, and RN/Expo. Now tolerant of:
|
|
5
|
+
* - blocks as string[] (v1) or object[] with { text, heading?, docId? } (v2)
|
|
6
|
+
* - meta.stats.avgBlockLen (optional)
|
|
7
|
+
* Includes RN/Expo-safe TextDecoder via ponyfill.
|
|
24
8
|
*/
|
|
9
|
+
import { getTextDecoder } from './utils/utf8.js';
|
|
25
10
|
export async function mountPack(opts) {
|
|
26
11
|
const buf = await resolveToBuffer(opts.src);
|
|
27
12
|
const dv = new DataView(buf);
|
|
13
|
+
const dec = getTextDecoder();
|
|
28
14
|
let offset = 0;
|
|
29
|
-
//
|
|
15
|
+
// meta
|
|
30
16
|
const metaLen = dv.getUint32(offset, true);
|
|
31
17
|
offset += 4;
|
|
32
|
-
const metaJson =
|
|
18
|
+
const metaJson = dec.decode(new Uint8Array(buf, offset, metaLen));
|
|
33
19
|
offset += metaLen;
|
|
34
20
|
const meta = JSON.parse(metaJson);
|
|
35
|
-
//
|
|
21
|
+
// lexicon
|
|
36
22
|
const lexLen = dv.getUint32(offset, true);
|
|
37
23
|
offset += 4;
|
|
38
|
-
const lexJson =
|
|
24
|
+
const lexJson = dec.decode(new Uint8Array(buf, offset, lexLen));
|
|
39
25
|
offset += lexLen;
|
|
40
26
|
const lexEntries = JSON.parse(lexJson);
|
|
41
27
|
const lexicon = new Map(lexEntries);
|
|
42
|
-
//
|
|
28
|
+
// postings
|
|
43
29
|
const postCount = dv.getUint32(offset, true);
|
|
44
30
|
offset += 4;
|
|
45
31
|
const postings = new Uint32Array(postCount);
|
|
@@ -47,29 +33,49 @@ export async function mountPack(opts) {
|
|
|
47
33
|
postings[i] = dv.getUint32(offset, true);
|
|
48
34
|
offset += 4;
|
|
49
35
|
}
|
|
50
|
-
//
|
|
36
|
+
// blocks (v1: string[]; v2: {text, heading?, docId?}[])
|
|
51
37
|
const blocksLen = dv.getUint32(offset, true);
|
|
52
38
|
offset += 4;
|
|
53
|
-
const blocksJson =
|
|
54
|
-
const
|
|
55
|
-
|
|
39
|
+
const blocksJson = dec.decode(new Uint8Array(buf, offset, blocksLen));
|
|
40
|
+
const parsed = JSON.parse(blocksJson);
|
|
41
|
+
let blocks = [];
|
|
42
|
+
let headings;
|
|
43
|
+
let docIds;
|
|
44
|
+
if (Array.isArray(parsed) && parsed.length && typeof parsed[0] === 'string') {
|
|
45
|
+
// v1
|
|
46
|
+
blocks = parsed;
|
|
47
|
+
}
|
|
48
|
+
else if (Array.isArray(parsed)) {
|
|
49
|
+
blocks = [];
|
|
50
|
+
headings = [];
|
|
51
|
+
docIds = [];
|
|
52
|
+
for (const it of parsed) {
|
|
53
|
+
if (it && typeof it === 'object') {
|
|
54
|
+
blocks.push(String(it.text ?? ''));
|
|
55
|
+
headings.push(it.heading ?? null);
|
|
56
|
+
docIds.push(it.docId ?? null);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
blocks.push(String(it ?? ''));
|
|
60
|
+
headings.push(null);
|
|
61
|
+
docIds.push(null);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
blocks = [];
|
|
67
|
+
}
|
|
68
|
+
return { meta, lexicon, postings, blocks, headings, docIds };
|
|
56
69
|
}
|
|
57
|
-
/** Resolve the `src` field of MountOptions into an ArrayBuffer. Supports:
|
|
58
|
-
* - strings interpreted as URLs (via fetch)
|
|
59
|
-
* - Uint8Array and ArrayBuffer inputs
|
|
60
|
-
*/
|
|
61
70
|
async function resolveToBuffer(src) {
|
|
62
71
|
if (typeof src === 'string') {
|
|
63
72
|
const res = await fetch(src);
|
|
64
|
-
|
|
65
|
-
return ab;
|
|
73
|
+
return await res.arrayBuffer();
|
|
66
74
|
}
|
|
67
75
|
if (src instanceof Uint8Array) {
|
|
68
|
-
// If the view covers the whole buffer, return it directly (cast to ArrayBuffer).
|
|
69
76
|
if (src.byteOffset === 0 && src.byteLength === src.buffer.byteLength) {
|
|
70
77
|
return src.buffer;
|
|
71
78
|
}
|
|
72
|
-
// Otherwise, copy to a new buffer so we return exactly the bytes for this view.
|
|
73
79
|
const copy = src.slice();
|
|
74
80
|
return copy.buffer;
|
|
75
81
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type HitLike = {
|
|
2
|
+
blockId: number;
|
|
3
|
+
score: number;
|
|
4
|
+
text: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
};
|
|
7
|
+
export type DiversifyOptions = {
|
|
8
|
+
k: number;
|
|
9
|
+
lambda?: number;
|
|
10
|
+
simThreshold?: number;
|
|
11
|
+
sim?: (a: HitLike, b: HitLike) => number;
|
|
12
|
+
};
|
|
13
|
+
export declare function diversifyAndDedupe(hits: HitLike[], opts: DiversifyOptions): HitLike[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/quality/diversify.ts
|
|
2
|
+
import { jaccard5 } from './similarity.js';
|
|
3
|
+
export function diversifyAndDedupe(hits, opts) {
|
|
4
|
+
const { k, lambda = 0.8, simThreshold = 0.92, sim = (a, b) => jaccard5(a.text, b.text) } = opts;
|
|
5
|
+
const pool = [...hits].sort((a, b) => b.score - a.score);
|
|
6
|
+
const kept = [];
|
|
7
|
+
while (pool.length && kept.length < k) {
|
|
8
|
+
// compute MMR for current pool against kept
|
|
9
|
+
let bestIdx = 0;
|
|
10
|
+
let bestMMR = -Infinity;
|
|
11
|
+
for (let i = 0; i < pool.length; i++) {
|
|
12
|
+
const h = pool[i];
|
|
13
|
+
let maxSim = 0;
|
|
14
|
+
for (const s of kept) {
|
|
15
|
+
const v = sim(h, s);
|
|
16
|
+
if (v > maxSim)
|
|
17
|
+
maxSim = v;
|
|
18
|
+
if (v >= simThreshold) {
|
|
19
|
+
maxSim = v;
|
|
20
|
+
break;
|
|
21
|
+
} // early out
|
|
22
|
+
}
|
|
23
|
+
// skip near-duplicates
|
|
24
|
+
if (maxSim >= simThreshold)
|
|
25
|
+
continue;
|
|
26
|
+
const mmr = lambda * h.score - (1 - lambda) * maxSim;
|
|
27
|
+
if (mmr > bestMMR) {
|
|
28
|
+
bestMMR = mmr;
|
|
29
|
+
bestIdx = i;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// if everything was a near-duplicate, just take the next best by score
|
|
33
|
+
const pick = pool.splice(bestMMR === -Infinity ? 0 : bestIdx, 1)[0];
|
|
34
|
+
if (!pick)
|
|
35
|
+
break;
|
|
36
|
+
// final dedupe check before push
|
|
37
|
+
if (!kept.some((x) => sim(x, pick) >= simThreshold))
|
|
38
|
+
kept.push(pick);
|
|
39
|
+
}
|
|
40
|
+
return kept;
|
|
41
|
+
}
|