benchmark-search.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import assert from 'node:assert/strict'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import { performance } from 'node:perf_hooks'
  6. import { register } from 'node:module'
  7. import { createServer } from 'node:http'
  8. register('./strip-types-loader.mjs', import.meta.url)
  9. const { searchFinalized } = await import('../packages/core/src/retrieval/search.ts')
  10. const { syncFinalizedIndex } = await import('../packages/core/src/retrieval/sync.ts')
  11. const { SearchCache } = await import('../packages/core/src/retrieval/cache.ts')
  12. const { HttpEmbeddingProvider } = await import('../packages/embedding-provider/src/http.ts')
  13. const { removeSync } = await import('../packages/core/src/repo/remove.ts')
  14. const outputArg = process.argv.indexOf('--out')
  15. const output = outputArg < 0 ? undefined : process.argv[outputArg + 1]
  16. if (outputArg >= 0 && !output) throw new Error('--out requires a JSON path')
  17. const chapterArg = process.argv.indexOf('--chapters')
  18. const selectedChapters = chapterArg < 0 ? [200, 1000] : [Number(process.argv[chapterArg + 1])]
  19. if (selectedChapters.some(count => !Number.isInteger(count) || count < 1 || count > 1000)) throw new Error('--chapters must be 1–1000')
  20. const modeArg = process.argv.indexOf('--mode')
  21. const selectedModes = modeArg < 0 ? ['keyword', 'hybrid'] : [process.argv[modeArg + 1]]
  22. if (selectedModes.some(mode => !['keyword', 'hybrid'].includes(mode))) throw new Error('--mode must be keyword or hybrid')
  23. // Instrument this isolated benchmark only; the product has no timing state or watcher.
  24. const phases = { cacheOpenMs: 0, vectorsMs: 0, publishMs: 0, keywordMs: 0, embeddingMs: 0 }
  25. const filesystem = { realpathCalls: 0, realpathMs: 0 }
  26. const realpath = fs.realpathSync.native
  27. fs.realpathSync.native = (...args) => {
  28. const start = performance.now()
  29. try { return realpath(...args) } finally { filesystem.realpathCalls++; filesystem.realpathMs += performance.now() - start }
  30. }
  31. const openCache = SearchCache.open
  32. SearchCache.open = async function (...args) {
  33. const start = performance.now()
  34. try { return await openCache.apply(this, args) } finally { phases.cacheOpenMs += performance.now() - start }
  35. }
  36. for (const method of ['vectors', 'publish', 'keyword']) {
  37. const original = SearchCache.prototype[method]
  38. SearchCache.prototype[method] = function (...args) {
  39. const start = performance.now()
  40. try { return original.apply(this, args) } finally { phases[`${method}Ms`] += performance.now() - start }
  41. }
  42. }
  43. const tempPrefix = path.join(os.tmpdir(), 'webnovel-benchmark-search-')
  44. const root = fs.mkdtempSync(tempPrefix)
  45. const marker = '独有青铜信物'
  46. const changedMarker = '独有琉璃信物'
  47. const dimensions = 1536
  48. const traffic = { requests: 0, documents: 0, queries: 0 }
  49. const server = createServer((req, res) => {
  50. void (async () => {
  51. let text = ''
  52. for await (const part of req) text += part
  53. const body = JSON.parse(text)
  54. assert.equal(req.url, '/v1/embeddings')
  55. assert.equal(body.dimensions, dimensions)
  56. traffic.requests++
  57. const data = body.input.map((input, index) => {
  58. if (input.startsWith('document:\n')) traffic.documents++
  59. else traffic.queries++
  60. let seed = 17
  61. for (const char of input) seed = (Math.imul(seed, 31) + char.codePointAt(0)) >>> 0
  62. const embedding = Array.from({ length: dimensions }, (_, dimension) => {
  63. if (dimension === 0 && (input.includes(marker) || input.includes(changedMarker))) return 100
  64. seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
  65. return Math.round(((seed / 0xffffffff) - 0.5) * 10000) / 10000
  66. })
  67. return { index, embedding }
  68. }).reverse()
  69. res.setHeader('content-type', 'application/json')
  70. res.end(JSON.stringify({ data }))
  71. })().catch(error => { console.error(error.message); res.destroy() })
  72. })
  73. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
  74. const report = {
  75. measuredAt: new Date().toISOString(), node: process.version, platform: `${process.platform}/${process.arch}`,
  76. cpu: os.cpus()[0]?.model, dimensions, warmRepeats: 3,
  77. boundary: '合成中文语料,每章约 2000 汉字。计时覆盖源扫描、哈希、SQLite 校准、查询、原文复核;hybrid 另含独立提供方和本地 HTTP 批量传输。向量由确定性夹具生成,不代表实际模型质量、远端延迟或费用。首次索引不是冷操作系统页缓存。',
  78. cases: [],
  79. }
  80. function makeBook(chapters, mode) {
  81. const book = path.join(root, `${chapters}-${mode}`)
  82. const alphabet = [...'山水天地人海青云故乡归客长夜星河风雪灯火旧城远路江湖刀剑门前月下少年记忆书信岁梦舟渡桥岸声影明暗欢悲前后左右言语心事春夏秋冬']
  83. let sourceBytes = 0
  84. let hanCharacters = 0
  85. let target
  86. for (let number = 1; number <= chapters; number++) {
  87. let seed = number
  88. const chars = Array.from({ length: 2000 }, () => {
  89. seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
  90. return alphabet[seed % alphabet.length]
  91. })
  92. if (number === Math.ceil(chapters / 2)) chars.splice(640, marker.length, ...marker)
  93. const body = Array.from({ length: 20 }, (_, row) => chars.slice(row * 100, row * 100 + 100).join('')).join('\n\n')
  94. const text = `---\n版本: 1\n角色: 已定稿\n---\n\n${body}\n`
  95. const file = path.join(book, `定稿/卷${String(Math.ceil(number / 100)).padStart(2, '0')}/${String(number).padStart(4, '0')}-合成章节.md`)
  96. fs.mkdirSync(path.dirname(file), { recursive: true })
  97. fs.writeFileSync(file, text)
  98. sourceBytes += Buffer.byteLength(text)
  99. hanCharacters += chars.length
  100. if (number === Math.ceil(chapters / 2)) target = file
  101. }
  102. return { book, target, sourceBytes, hanCharacters }
  103. }
  104. const milliseconds = value => Math.round(value * 100) / 100
  105. async function measure(book, query, provider) {
  106. const beforeTraffic = { ...traffic }
  107. const beforePhases = { ...phases }
  108. const beforeFilesystem = { ...filesystem }
  109. const start = performance.now()
  110. const result = await searchFinalized(book, { query, provider })
  111. const elapsedMs = milliseconds(performance.now() - start)
  112. assert.equal(result.ok, true, JSON.stringify(result))
  113. assert.equal(result.mode, provider ? 'hybrid' : 'keyword', JSON.stringify(result))
  114. assert.equal(result.status, 'matches')
  115. assert.ok(result.hits.some(hit => hit.snippet.includes(query)))
  116. assert.equal(result.issues.length, 0)
  117. const timings = Object.fromEntries(Object.keys(phases).map(key => [key, milliseconds(phases[key] - beforePhases[key])]))
  118. timings.scansAndFusionMs = milliseconds(elapsedMs - Object.values(timings).reduce((sum, value) => sum + value, 0))
  119. const filesystemTimings = { realpathCalls: filesystem.realpathCalls - beforeFilesystem.realpathCalls, realpathMs: milliseconds(filesystem.realpathMs - beforeFilesystem.realpathMs) }
  120. console.error(`[search benchmark] ${path.basename(book)}: ${elapsedMs} ms ${JSON.stringify({ ...timings, ...filesystemTimings })}`)
  121. return {
  122. elapsedMs, timings, filesystemTimings, index: result.index, hits: result.hits.length,
  123. requests: traffic.requests - beforeTraffic.requests, documentsSent: traffic.documents - beforeTraffic.documents,
  124. queriesSent: traffic.queries - beforeTraffic.queries,
  125. }
  126. }
  127. async function measureSync(book, provider) {
  128. if (!provider) return undefined
  129. const before = { ...traffic }
  130. const start = performance.now()
  131. const result = await syncFinalizedIndex(book, { getProvider: () => provider })
  132. assert.ok(result.ok, result.failure?.message)
  133. return { elapsedMs: milliseconds(performance.now() - start), generated: result.state.generated,
  134. reused: result.state.reused, requests: traffic.requests - before.requests,
  135. documentsSent: traffic.documents - before.documents }
  136. }
  137. try {
  138. for (const chapters of selectedChapters) for (const mode of selectedModes) {
  139. const fixture = makeBook(chapters, mode)
  140. const provider = mode === 'keyword' ? undefined : new HttpEmbeddingProvider({
  141. protocol: 'openai-compatible', baseURL: `http://127.0.0.1:${server.address().port}/v1`, model: 'benchmark-fixture',
  142. dimensions, apiKeyEnv: 'UNUSED_FIXTURE_KEY', batchSize: 32, timeoutMs: 30_000, maxRetries: 0,
  143. sendDimensions: true, documentPrefix: 'document:', queryPrefix: 'query:', geminiTaskMode: 'native',
  144. }, async () => 'benchmark-fixture-key')
  145. if (provider) {
  146. const embed = provider.embed.bind(provider)
  147. provider.embed = async (...args) => {
  148. const start = performance.now()
  149. try { return await embed(...args) } finally { phases.embeddingMs += performance.now() - start }
  150. }
  151. }
  152. try {
  153. console.error(`[search benchmark] ${chapters} chapters / ${mode}: first index`)
  154. const initialSync = await measureSync(fixture.book, provider)
  155. const cold = await measure(fixture.book, marker, provider)
  156. const coldIndexBytes = fs.statSync(path.join(fixture.book, '.webnovel/finalized-search.sqlite')).size
  157. const warm = []
  158. for (let iteration = 0; iteration < report.warmRepeats; iteration++) warm.push(await measure(fixture.book, marker, provider))
  159. console.error(`[search benchmark] ${chapters} chapters / ${mode}: content update`)
  160. const before = fs.statSync(fixture.target)
  161. fs.writeFileSync(fixture.target, fs.readFileSync(fixture.target, 'utf8').replace(marker, changedMarker))
  162. fs.utimesSync(fixture.target, before.atime, before.mtime)
  163. const updateSync = await measureSync(fixture.book, provider)
  164. const update = await measure(fixture.book, changedMarker, provider)
  165. if (provider) {
  166. assert.equal(warm[0].index.embedded, 0)
  167. assert.equal(warm[0].index.reused, cold.index.chunks)
  168. assert.equal(update.documentsSent, 0)
  169. assert.ok(updateSync.generated > 0 && updateSync.generated < cold.index.chunks)
  170. }
  171. report.cases.push({ chapters, mode, sourceBytes: fixture.sourceBytes, hanCharacters: fixture.hanCharacters, initialSync, updateSync, cold, warm,
  172. warmMedianMs: [...warm.map(row => row.elapsedMs)].sort((a, b) => a - b)[1], update, coldIndexBytes,
  173. updatedIndexBytes: fs.statSync(path.join(fixture.book, '.webnovel/finalized-search.sqlite')).size })
  174. } finally { await provider?.close() }
  175. }
  176. const json = JSON.stringify(report, null, 2) + '\n'
  177. if (output) fs.writeFileSync(path.resolve(output), json)
  178. console.log(json)
  179. } finally {
  180. server.closeAllConnections()
  181. await new Promise(resolve => server.close(resolve))
  182. if (!path.resolve(root).startsWith(path.resolve(tempPrefix))) throw new Error('Unexpected benchmark cleanup path')
  183. removeSync(root)
  184. }