
前端本地搜索的架构设计从 Fuse.js 模糊匹配到 IndexedDB 全文检索一、前端搜索的需求演进当应用数据量从几十条增长到上万条时将搜索请求全部发往服务端不再是唯一选择。离线场景文档查阅、笔记检索、代码搜索要求本地完成匹配而网络延迟和 API 限流也推动开发者将搜索逻辑下沉到客户端。但前端搜索不是简单的Array.filter——它需要模糊匹配、中文分词、增量索引和持久化存储。具体而言前端搜索的核心需求主要涵盖四个方面模糊匹配需要容错拼写错误并支持拼音搜索中文分词涉及字、词、短语的三级切分增量索引要求数据变更时实时同步持久化存储则需实现离线可用和跨会话保持。针对这些需求技术实现路径通常包括使用 Fuse.js 进行加权匹配结合自定义分词器与倒排索引并最终依托 IndexedDB 构建全文检索引擎。纯 Fuse.js 方案在 1 万条以内数据时表现良好但超过此阈值后每次搜索都需要遍历全量数据集内存占用和延迟急剧上升。将倒排索引存储在 IndexedDB 中检索时只读取相关倒排链可以在 10 万条级别仍保持毫秒级响应。本文从 Fuse.js 出发逐步构建一个完整的 IndexedDB 全文检索引擎。二、Fuse.js 模糊匹配的局限与改进2.1 Fuse.js 的工作机制Fuse.js 使用 Bitap 算法计算字符串间的近似匹配分数支持权重配置和阈值控制。一个典型配置如下import Fuse from fuse.js; --- interface DocItem { id: string; title: string; content: string; tags: string[]; } const fuse new FuseDocItem(documents, { keys: [ { name: title, weight: 0.4 }, // 标题权重最高 { name: content, weight: 0.3 }, // 内容次之 { name: tags, weight: 0.3 }, // 标签同内容 ], threshold: 0.3, // 匹配阈值0 完全匹配1 匹配任何 distance: 100, // 匹配位置容忍距离 minMatchCharLength: 2, // 最短匹配字符数 ignoreLocation: true, // 忽略匹配位置长文本时必须开启 shouldSort: true, // 按匹配分数排序 }); const results fuse.search(前端搜索);2.2 性能瓶颈分析Fuse.js 的search方法每次执行时遍历全部数据集计算每条记录与查询的匹配分数。在 10 万条数据场景下实测数据量搜索耗时内存增量1,000~5ms~2MB10,000~50ms~20MB50,000~250ms~100MB100,000~500ms~200MB两个问题一是内存占用过大——全量数据集必须同时驻留内存二是延迟随数据量线性增长。改进方向有两个缩小遍历范围通过预索引跳过无关数据或将索引持久化到 IndexedDB 减少内存压力。2.3 中文分词适配Fuse.js 默认按字符级别匹配对中文来说这意味着一个字一个字地比对。前端搜索架构会被拆成前、端、搜、索、架、构六个字符单元而用户输入搜索架构时期望的是词级匹配。需要自定义分词器// 简易中文分词器按字和常见双字词切分 function tokenizeChinese(text: string): string[] { const tokens: string[] []; // 提取双字词常见中文词汇长度 const twoCharPattern /[\u4e00-\u9fff]{2}/g; let match: RegExpExecArray | null; while ((match twoCharPattern.exec(text)) ! null) { tokens.push(match[0]); } // 提取单字作为补充容错拼写 const singleCharPattern /[\u4e00-\u9fff]/g; while ((match singleCharPattern.exec(text)) ! null) { if (!tokens.includes(match[0])) { tokens.push(match[0]); } } // 提取英文单词和数字 const alphaPattern /[a-zA-Z0-9]/g; while ((match alphaPattern.exec(text)) ! null) { tokens.push(match[0].toLowerCase()); } return tokens; } // 将自定义分词器注入 Fuse.js const fuseWithTokenizer new FuseDocItem(documents, { keys: [ { name: title, weight: 0.4 }, { name: content, weight: 0.3 }, { name: tags, weight: 0.3 }, ], threshold: 0.3, tokenize: true, // 启用分词模式 tokenFn: tokenizeChinese, // 自定义分词函数Fuse.js v7 支持 }); 分词器让搜索架构能同时匹配搜索和架构两个词单元而不是要求完整连续匹配。但对于超过 10 万条的数据需要从算法层面转向倒排索引。 ## 三、倒排索引与 IndexedDB 存储设计 ### 3.1 倒排索引原理 倒排索引Inverted Index是搜索引擎的核心数据结构从词项 → 文档列表的映射而非文档 → 内容的正排结构。检索时只需读取查询词对应的倒排链跳过全部无关文档。 具体而言索引结构会将每个词项映射到包含该词的文档 ID 列表。例如搜索词项对应 doc_001、doc_005、doc_042架构词项对应 doc_001、doc_018、doc_042。查询搜索 架构时系统分别读取这两个词项的倒排链并通过求交集操作得到同时包含两词的文档即 doc_001 和 doc_042。这一过程只涉及少量倒排链的读取与总文档量无关。 ### 3.2 IndexedDB 存储模型 IndexedDB 提供事务性键值存储和索引查询能力适合持久化倒排索引。设计三张对象存储 typescript interface IndexDBSchema { // 文档存储原文数据 documents: {key: string; // docId value: DocItem; indexes: { byUpdatedAt: string }; // 按更新时间索引};// 倒排索引词项 → 文档列表invertedIndex: {key: string; // term分词后的词项value: InvertedIndexEntry;indexes: {}; // 主键即词项无需额外索引};// 词项频率文档 → 词项频率映射用于排序评分termFreq: {key: [string, string]; // [docId, term] 复合主键value: TermFreqEntry;indexes: { byDocId: string }; // 按文档ID索引};}interface InvertedIndexEntry {term: string;postings: Posting[]; // 倒排链docCount: number; // 包含该词项的文档总数}interface Posting {docId: string;positions: number[]; // 词项在原文中的位置用于短语查询tf: number; // 词项频率该词在文档中出现的次数}interface TermFreqEntry {docId: string;term: string;tf: number;docLength: number; // 文档总词数用于 BM25 评分}### 3.3 IndexedDB 初始化与索引构建 typescript import { openDB, IDBPDatabase } from idb; const DB_NAME localSearchEngine; const DB_VERSION 1; async function initSearchDB(): PromiseIDBPDatabaseIndexDBSchema { return openDBIndexDBSchema(DB_NAME, DB_VERSION, { upgrade(db) { // 文档存储 const docStore db.createObjectStore(documents, { keyPath: id }); docStore.createIndex(byUpdatedAt, updatedAt); // 倒排索引存储 db.createObjectStore(invertedIndex, { keyPath: term }); // 词项频率存储 const tfStore db.createObjectStore(termFreq, { keyPath: [docId, term], }); tfStore.createIndex(byDocId, docId); }, }); }数据库初始化一次后续查询和增量更新复用同一连接。四、全文检索引擎的实现4.1 索引构建流水线class LocalSearchEngine { private db: IDBPDatabaseIndexDBSchema; private tokenizer: (text: string) string[]; constructor(db: IDBPDatabaseIndexDBSchema, tokenizer tokenizeChinese) { this.db db; this.tokenizer tokenizer; } // 批量构建索引接收文档数组写入三张存储 async buildIndex(docs: DocItem[]): Promisevoid { const tx this.db.transaction( [documents, invertedIndex, termFreq], readwrite, ); const invertedMap new Mapstring, Posting[](); const tfEntries: TermFreqEntry[] []; for (const doc of docs) { // 写入文档存储 await tx.objectStore(documents).put(doc); // 对可搜索字段分词 const searchableText ${doc.title} ${doc.content} ${doc.tags.join( )}; const tokens this.tokenizer(searchableText); const docLength tokens.length; // 统计词项频率 const termFreqMap new Mapstring, { count: number; positions: number[] }(); tokens.forEach((token, position) { const entry termFreqMap.get(token) ?? { count: 0, positions: [] }; entry.count 1; entry.positions.push(position); termFreqMap.set(token, entry); }); // 构建倒排链和词频记录 for (const [term, freq] of termFreqMap) { // 倒排索引 const posting: Posting { docId: doc.id, positions: freq.positions, tf: freq.count, }; const existing invertedMap.get(term) ?? []; existing.push(posting); invertedMap.set(term, existing); // 词项频率用于 BM25 评分 tfEntries.push({ docId: doc.id, term, tf: freq.count, docLength, }); } } // 写入倒排索引 const invStore tx.objectStore(invertedIndex); for (const [term, postings] of invertedMap) { await invStore.put({ term, postings, docCount: postings.length, }); } // 写入词项频率 const tfStore tx.objectStore(termFreq); for (const entry of tfEntries) { await tfStore.put(entry); } await tx.done; } // 增量更新单文档索引刷新 async updateIndex(doc: DocItem): Promisevoid { // 先删除旧索引条目 await this.removeFromIndex(doc.id); // 再重新写入 await this.buildIndex([doc]); } // 从索引中移除文档 async removeFromIndex(docId: string): Promisevoid { const tx this.db.transaction( [documents, invertedIndex, termFreq], readwrite, ); // 删除文档 await tx.objectStore(documents).delete(docId); // 删除词频记录 const tfStore tx.objectStore(termFreq); const tfIndex tfStore.index(byDocId); let cursor await tfIndex.openCursor(docId); while (cursor) { await cursor.delete(); cursor await cursor.continue(); } // 更新倒排索引移除该文档的 Posting const invStore tx.objectStore(invertedIndex); // 遍历所有词项IndexedDB 不支持按值内字段查询需全量扫描 let invCursor await invStore.openCursor(); while (invCursor) { const entry: InvertedIndexEntry invCursor.value; entry.postings entry.postings.filter((p) p.docId ! docId); entry.docCount entry.postings.length; if (entry.docCount 0) { await invCursor.delete(); // 词项无人引用删除 } else { await invCursor.update(entry); } invCursor await invCursor.continue(); } await tx.done; } }removeFromIndex的倒排索引更新需要全量扫描这在词项数量很大时开销较高。优化策略是维护一张docId → terms的反向映射表删除时只需更新文档涉及的词项。4.2 BM25 评分与查询执行BM25 是信息检索领域最成熟的评分函数考虑了词项频率TF、逆文档频率IDF和文档长度归一化interface SearchOptions { limit?: number; // 返回结果数量上限 minScore?: number; // 最低匹配分数 exactMatch?: boolean; // 是否要求精确匹配所有词项 } interface SearchResult { docId: string; score: number; matchedTerms: string[]; doc: DocItem; } class LocalSearchEngine { // 总文档数从 documents 存储获取 private async getTotalDocCount(): Promisenumber { return this.db.count(documents); } // 平均文档长度 private async getAvgDocLength(): Promisenumber { const count await this.getTotalDocCount(); if (count 0) return 0; const tfStore this.db.transaction(termFreq).objectStore(termFreq); let totalLength 0; let cursor await tfStore.openCursor(); // 取每个文档第一条 tf 记录中的 docLength const seenDocs new Setstring(); while (cursor) { const entry: TermFreqEntry cursor.value; if (!seenDocs.has(entry.docId)) { totalLength entry.docLength; seenDocs.add(entry.docId); } cursor await cursor.continue(); } return totalLength / seenDocs.size; } // BM25 评分参数 private readonly BM25_K1 1.2; // 词项频率饱和参数 private readonly BM25_B 0.75; // 文档长度归一化参数 // 计算 BM25 分数 private calculateBM25( tf: number, docLength: number, avgDocLength: number, docCount: number, termDocCount: number, ): number { const idf Math.log( (docCount - termDocCount 0.5) / (termDocCount 0.5) 1, ); const tfNorm (tf * (this.BM25_K1 1)) / (tf this.BM25_K1 * (1 - this.BM25_B this.BM25_B * (docLength / avgDocLength))); return idf * tfNorm; } // 执行搜索 async search(query: string, options: SearchOptions {}): PromiseSearchResult[] { const queryTokens this.tokenizer(query); if (queryTokens.length 0) return []; const totalDocs await this.getTotalDocCount(); const avgLen await this.getAvgDocLength(); const limit options.limit ?? 20; const minScore options.minScore ?? 0.1; // 收集每个查询词项的倒排链 const termPostings new Mapstring, InvertedIndexEntry(); for (const token of queryTokens) { const entry await this.db.get(invertedIndex, token); if (entry) { termPostings.set(token, entry); } } // 如果要求精确匹配且有词项缺失直接返回空 if (options.exactMatch termPostings.size queryTokens.length) { return []; } // 计算每个文档的 BM25 总分 const scoreMap new Mapstring, { score: number; matchedTerms: Setstring }(); for (const [term, invEntry] of termPostings) { for (const posting of invEntry.postings) { // 获取文档长度 const tfEntry await this.db.get(termFreq, [posting.docId, term]); if (!tfEntry) continue; const bm25Score this.calculateBM25( posting.tf, tfEntry.docLength, avgLen, totalDocs, invEntry.docCount, ); const existing scoreMap.get(posting.docId) ?? { score: 0, matchedTerms: new Setstring(), }; existing.score bm25Score; existing.matchedTerms.add(term); scoreMap.set(posting.docId, existing); } } // 排序并返回结果 const results: SearchResult[] []; const sorted [...scoreMap.entries()].sort((a, b) b[1].score - a[1].score); for (const [docId, scoreInfo] of sorted) { if (scoreInfo.score minScore) continue; const doc await this.db.get(documents, docId); if (!doc) continue; // 文档可能已被删除 results.push({ docId, score: scoreInfo.score, matchedTerms: [...scoreInfo.matchedTerms], doc, }); if (results.length limit) break; } return results; } }BM25 的优势在于对高频词的自抑制IDF 衰减和对长文档的长度惩罚避免了 TF 线性累加导致的偏差。4.3 性能优化倒排链预交集与缓存// 搜索结果缓存避免重复查询相同关键词 class SearchCache { private cache new Mapstring, { results: SearchResult[]; timestamp: number }(); private maxAge 5 * 60 * 1000; // 5 分钟缓存有效期 get(key: string): SearchResult[] | null { const entry this.cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp this.maxAge) { this.cache.delete(key); return null; } return entry.results; } set(key: string, results: SearchResult[]): void { // LRU 淘汰超过 100 条缓存时删除最早的 if (this.cache.size 100) { const oldest [...this.cache.entries()].sort( (a, b) a[1].timestamp - b[1].timestamp, )[0]; this.cache.delete(oldest[0]); } this.cache.set(key, { results, timestamp: Date.now() }); } invalidate(docId: string): void { // 文档变更时移除可能受影响的缓存 // 简化策略清空全部缓存精确策略需要追踪每个缓存涉及的 docId this.cache.clear(); } }缓存命中率在用户重复搜索相似关键词时可达 30%-40%显著减少 IndexedDB 读取次数。对于增量更新场景invalidate清空缓存确保结果一致性。五、总结前端本地搜索从 Fuse.js 到 IndexedDB 全文检索的演进本质是从遍历匹配到索引检索的范式转换维度Fuse.js 方案IndexedDB 全文检索数据量上限~10,000 条~100,000 条搜索延迟O(N) 遍历O(K) 倒排链合并内存占用全量驻留仅索引驻留离线能力需预加载全量数据IndexedDB 持久化评分算法Bitap 近似分数BM25 信息检索评分中文支持需自定义分词分词器 倒排索引天然支持增量更新替换全量数据集单文档增删工程实践中推荐分层策略1 万条以内用 Fuse.js 快速落地1-10 万条使用 IndexedDB 倒排索引超过 10 万条考虑 WASM 加速的 Trie FSTFinite State Transducer压缩索引。本文的LocalSearchEngine实现了完整的索引构建、增量更新、BM25 评分和缓存机制可直接用于文档系统、笔记应用和离线知识库的搜索功能。