OpenCode commited on
Commit
30c0765
·
1 Parent(s): 85cfe8e

perf: filter chapter text with bigram index

Browse files
static/reader-chapter-search-worker.mjs CHANGED
@@ -2,6 +2,8 @@ const PACKED_LIMIT = 64 * 1024 * 1024;
2
  const TEXT_LIMIT = 256 * 1024 * 1024;
3
  const PAGE_SIZE = 50;
4
  const CHECKPOINT_STRIDE = 256;
 
 
5
  const pause = () => new Promise(resolve => setTimeout(resolve, 0));
6
  function cooperativeYield() {
7
  let last = performance.now();
@@ -43,18 +45,60 @@ async function readBounded(stream, limit, signal) {
43
  }
44
 
45
  export function validateIndex(data, chapters) {
46
- if (data?.version !== 1 || data.kind !== "epub-search-index" ||
47
  !Array.isArray(data.chapters) || data.chapters.length !== chapters.length)
48
  throw new Error("全文搜索索引不完整,请重试");
49
  for (let i = 0; i < chapters.length; i++) {
50
  const item = data.chapters[i], expected = chapters[i];
51
  if (item?.index !== expected.index || item.path !== expected.path || typeof item.text !== "string")
52
  throw new Error("全文搜索索引与章节不一致");
 
 
 
 
 
 
 
 
 
 
 
 
53
  item.title = expected.title || `章节 ${expected.index}`;
54
  }
55
  return data.chapters;
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  // Scan bounded pieces so a new query can cancel even one very large chapter.
59
  // Keep counts, rather than one object per hit; all matches remain pageable.
60
  export async function scanText(text, query, visit, current = () => true, yieldWork = pause,
@@ -81,15 +125,18 @@ export async function countMatches(chapters, query, current, progress = null) {
81
  const counts = [], checkpoints = [], firstPage = [];
82
  const yieldWork = cooperativeYield();
83
  const pattern = new RegExp(escapePattern(query), "giu");
 
84
  let total = 0;
85
  for (const [chapterIndex, chapter] of chapters.entries()) {
86
  let count = 0;
87
- await scanText(chapter.text, query, (start, length) => {
88
- if ((total + count) % CHECKPOINT_STRIDE === 0)
89
- checkpoints.push({ ordinal: total + count, chapterIndex, start });
90
- count++;
91
- if (firstPage.length < PAGE_SIZE) firstPage.push(searchResult(chapter, start, length));
92
- }, current, yieldWork, 0, pattern);
 
 
93
  counts.push(count);
94
  total += count;
95
  if (progress && (counts.length === 1 || (count && total <= PAGE_SIZE) ||
@@ -112,6 +159,7 @@ function searchResult(chapter, start, length) {
112
  export async function resultPage(chapters, query, counts, offset, current, checkpoints = []) {
113
  const results = [];
114
  const pattern = new RegExp(escapePattern(query), "giu");
 
115
  offset = Math.max(0, Math.floor(offset));
116
  let checkpoint = null;
117
  for (let low = 0, high = checkpoints.length - 1; low <= high;) {
@@ -129,6 +177,7 @@ export async function resultPage(chapters, query, counts, offset, current, check
129
  const start = checkpoint && i === firstChapter ? checkpoint.start : 0;
130
  if (!start && skip >= counts[i]) { skip -= counts[i]; continue; }
131
  const chapter = chapters[i];
 
132
  await scanText(chapter.text, query, (matchStart, length) => {
133
  if (skip) { skip--; return; }
134
  results.push(searchResult(chapter, matchStart, length));
 
2
  const TEXT_LIMIT = 256 * 1024 * 1024;
3
  const PAGE_SIZE = 50;
4
  const CHECKPOINT_STRIDE = 256;
5
+ const BIGRAM_FILTER_HASHES = 7;
6
+ const MAX_BIGRAM_FILTER_BYTES = 32 * 1024;
7
  const pause = () => new Promise(resolve => setTimeout(resolve, 0));
8
  function cooperativeYield() {
9
  let last = performance.now();
 
45
  }
46
 
47
  export function validateIndex(data, chapters) {
48
+ if (![1, 2].includes(data?.version) || data.kind !== "epub-search-index" ||
49
  !Array.isArray(data.chapters) || data.chapters.length !== chapters.length)
50
  throw new Error("全文搜索索引不完整,请重试");
51
  for (let i = 0; i < chapters.length; i++) {
52
  const item = data.chapters[i], expected = chapters[i];
53
  if (item?.index !== expected.index || item.path !== expected.path || typeof item.text !== "string")
54
  throw new Error("全文搜索索引与章节不一致");
55
+ if (data.version === 2 && item.bf !== undefined) {
56
+ let binary;
57
+ try { binary = atob(item.bf); }
58
+ catch (_) { throw new Error("全文搜索过滤索引无效"); }
59
+ if (binary.length < 32 || binary.length > MAX_BIGRAM_FILTER_BYTES ||
60
+ (binary.length & (binary.length - 1)) !== 0)
61
+ throw new Error("全文搜索过滤索引无效");
62
+ const filter = new Uint8Array(binary.length);
63
+ for (let j = 0; j < binary.length; j++) filter[j] = binary.charCodeAt(j);
64
+ item.bigramFilter = filter;
65
+ delete item.bf;
66
+ }
67
  item.title = expected.title || `章节 ${expected.index}`;
68
  }
69
  return data.chapters;
70
  }
71
 
72
+ function bigramHashes(gram) {
73
+ let first = 2166136261, second = 0x9E3779B9;
74
+ for (const character of gram) {
75
+ const codepoint = character.codePointAt(0);
76
+ first = Math.imul(first ^ codepoint, 16777619) >>> 0;
77
+ second = (second ^ (codepoint + 0x9E3779B9 + ((second << 6) >>> 0) + (second >>> 2))) >>> 0;
78
+ }
79
+ second |= 1;
80
+ return Array.from({ length: BIGRAM_FILTER_HASHES }, (_, probe) =>
81
+ (first + Math.imul(probe, second)) >>> 0);
82
+ }
83
+
84
+ export function queryBigramFilters(query) {
85
+ const characters = Array.from(query);
86
+ if (characters.length < 2 || characters.some(character =>
87
+ character.toLowerCase() !== character.toUpperCase())) return null;
88
+ return characters.slice(1).map((character, index) => bigramHashes(characters[index] + character));
89
+ }
90
+
91
+ export function chapterMayContainBigrams(chapter, filters) {
92
+ const bloom = chapter.bigramFilter;
93
+ if (!filters || !bloom) return true;
94
+ const mask = bloom.length * 8 - 1;
95
+ for (const hashes of filters) for (const hash of hashes) {
96
+ const bit = hash & mask;
97
+ if (!(bloom[bit >> 3] & (1 << (bit & 7)))) return false;
98
+ }
99
+ return true;
100
+ }
101
+
102
  // Scan bounded pieces so a new query can cancel even one very large chapter.
103
  // Keep counts, rather than one object per hit; all matches remain pageable.
104
  export async function scanText(text, query, visit, current = () => true, yieldWork = pause,
 
125
  const counts = [], checkpoints = [], firstPage = [];
126
  const yieldWork = cooperativeYield();
127
  const pattern = new RegExp(escapePattern(query), "giu");
128
+ const filters = queryBigramFilters(query);
129
  let total = 0;
130
  for (const [chapterIndex, chapter] of chapters.entries()) {
131
  let count = 0;
132
+ if (chapterMayContainBigrams(chapter, filters)) {
133
+ await scanText(chapter.text, query, (start, length) => {
134
+ if ((total + count) % CHECKPOINT_STRIDE === 0)
135
+ checkpoints.push({ ordinal: total + count, chapterIndex, start });
136
+ count++;
137
+ if (firstPage.length < PAGE_SIZE) firstPage.push(searchResult(chapter, start, length));
138
+ }, current, yieldWork, 0, pattern);
139
+ }
140
  counts.push(count);
141
  total += count;
142
  if (progress && (counts.length === 1 || (count && total <= PAGE_SIZE) ||
 
159
  export async function resultPage(chapters, query, counts, offset, current, checkpoints = []) {
160
  const results = [];
161
  const pattern = new RegExp(escapePattern(query), "giu");
162
+ const filters = queryBigramFilters(query);
163
  offset = Math.max(0, Math.floor(offset));
164
  let checkpoint = null;
165
  for (let low = 0, high = checkpoints.length - 1; low <= high;) {
 
177
  const start = checkpoint && i === firstChapter ? checkpoint.start : 0;
178
  if (!start && skip >= counts[i]) { skip -= counts[i]; continue; }
179
  const chapter = chapters[i];
180
+ if (!chapterMayContainBigrams(chapter, filters)) continue;
181
  await scanText(chapter.text, query, (matchStart, length) => {
182
  if (skip) { skip--; return; }
183
  results.push(searchResult(chapter, matchStart, length));