全文搜索这功能,听着简单,真正踩进去才知道水有多深。尤其是在 Vue 3 项目里,数据量一旦过万,一个简单的filter就能让你在输入框里每敲一个字就卡一下。过去半年我在做内部知识库和商品中台搜索,先后对比了四种主流做法:原生filter线性扫描、Fuse.js 模糊搜索、MiniSearch 全文检索引擎、后端搜索服务(以 Meilisearch 为例)。从性能、体验、维护成本三个角度折腾了一轮,今天把四套方案的实现思路、测量要点、踩坑记录都整理出来,给正准备在 Vue 3 里做全文搜索的朋友一个可以参考的选型清单。这套对比的思路不限于某一个业务,知识库、商品搜索、日志检索、文档站都可以套用。
1. 四种搜索方案,我在什么样的情况下才会选它
搜索方案选型不能光看“能不能搜到”,还要看数据量、部署成本、团队维护能力。我把整个决策拆成四个层次:纯前端原生扫描、前端模糊匹配库、前端全文索引库、后端搜索引擎。每往上一层,搜索能力更强、规模上限更高,但引入的依赖、部署的复杂度也同步上升。
1.1 方案一:原生 filter 线性扫描
原理是把数组整个过一遍,用includes、indexOf或者正则去匹配字符串。这是最简单也最容易想到的方案,零依赖、零构建、零学习成本。
它适合什么场景?我见过很多后台管理系统的搜索框,比如用户列表按姓名过滤、订单列表按订单号过滤,这种字段值短、数据量几百到几千条的场景,直接filter完全够用。还有一个隐含优势:它是响应式的,computed里写一句就能联动视图,前端新手也能马上上手。
但它的瓶颈非常明显。一是时间复杂度和数据量线性相关,数据量从五千涨到五万、十万,搜索耗时跟着线性涨。二是匹配策略太粗糙,includes只能做子串匹配,想做大小写不敏感、忽略标点、按空格划分多个关键词,全部要自己处理。三是没有“相关性”概念,结果顺序要么是原始顺序,要么得手动按某个字段排序。超过一定数据量之后,它就不是“够用”而是“硬撑”。
1.2 方案二:Fuse.js 模糊搜索
Fuse.js 解决的核心痛点是“用户记不住准确关键词”。它基于编辑距离算法做模糊匹配,即使关键词写错一两个字,也能把候选结果捞出来。实际体验上,这个“容错”能力特别适合搜索和程序员相关的技术名词,比如用户想搜“Vue 3”却打成“Vus 3”,Fuse.js 能根据字母相似度匹配到对应结果。
它自带了权重系统,可以指定标题字段权重更高、正文字段权重更低,也能返回匹配位置、匹配片段,前端拿来做高亮很方便。代价是包体积相对较大(gzip 后约 20KB 左右),而且模糊匹配本身的计算开销不小,数据量越大,单次查询越慢。Fuse.js 提供了createIndex预建索引的能力,但只能加速候选筛选,匹配计算仍然躲不掉。实测下来一万条数据时带索引查询在 40-70ms 左右,能接受,但到五万条会明显吃力。
1.3 方案三:MiniSearch 全文检索引擎
MiniSearch 走的是和 Fuse.js 完全不同的路子。它在内存里构建倒排索引,把文本拆成 token,每个 token 映射到一批文档。查询时直接根据 token 取文档集合,而不是逐条扫描。加上内置了 BM25 相关度评分,结果天然按“最相关”排序,体验非常接近后端搜索引擎。
它对中文场景的适配也比想象中好,支持自定义tokenize函数,能用分词器把中文切成词或词组再建索引。和 Fuse.js 对比,MiniSearch 强在数据量越大优势越明显,五万条、十万条数据查询也在毫秒级,而且包体积小(gzip 后通常在几 KB 到十几 KB 之间)。缺点是它需要管理索引生命周期:数据新增、修改、删除时都得调用对应 API 维护索引,比单纯filter复杂一些。
1.4 方案四:后端搜索服务(HTTP API)
当数据量到百万级,或者需要多租户权限过滤、同义词扩展、实时同步数据库等能力时,纯前端方案就扛不住了。此时把搜索拆成独立的服务,前端只负责发请求和渲染结果。最常见的后端方案是 Elasticsearch、Meilisearch、Typesense,体验和性能都很强。
代价也很明确:需要额外部署和维护服务,前端要处理接口延迟、loading、错误态、请求竞态。如果是个人项目或小型团队,后端运维成本可能比前端开发成本还高。所以我的原则是“能用前端解决就别上后端”,除非数据规模真的到了前端撑不住的程度。
四种方案的定位差异可以看这个表:
| 方案 | 搜索能力 | 建议数据规模 | 前端包体积增量 | 维护成本 |
|---|---|---|---|---|
| 原生 filter | 子串匹配 | < 5000 条 | 0 | 最低 |
| Fuse.js | 模糊匹配 | < 1 万条 | 约 20KB | 低 |
| MiniSearch | 全文索引 + BM25 | < 50 万条 | 约 10KB | 中 |
| 后端搜索服务 | 分布式全文检索 | 百万级以上 | 极小 | 高 |
2. 性能基准测试:一万到十万条数据,谁扛得住
讲完原理,上一波实测数据。先说环境:生产构建版本 Vite + Vue 3.4,Chrome 120,MacBook Pro M1。数据是随机生成的 5 万条商品数据,包含标题(10-30 字)、分类名、描述(100-300 字)。这个量级很接近真实的知识库、商品库场景。测量方式上,所有耗时都用performance.now()在搜索函数外层计算,并且强制浏览器完成渲染后才取下一轮数据,避免把异步渲染时间误算进搜索结果里。
2.1 构建索引耗时与包体积
这一轮只测“进入页面到可以搜索”的初始化成本。原生filter没有建索引的概念,随便搜;Fuse.js 一次性把全量文本读进内存做预处理;MiniSearch 需要调用addAll批量建倒排索引;后端方案把建索引的耗时转移到了服务端,前端只关心接口是否可用。
我实测的数据如下:
| 方案 | 构建索引耗时 | 前端包体积增量(gzip) |
|---|---|---|
| 原生 filter | 0ms | 0KB |
| Fuse.js(含 createIndex) | 约 320ms | 约 21KB |
| MiniSearch | 约 410ms | 约 8KB |
| Meilisearch API | 服务端处理 | 约 1KB |
结论很清楚:除了原生扫描,其余方案首次进入都有毫秒级到几百毫秒不等的索引构建时间。好消息是这些构建都只发生在数据加载或数据更新时,不会持续卡用户。如果页面数据本身很大,建议用requestIdleCallback或setTimeout分片构建索引,避免阻塞首屏渲染。
2.2 查询延迟实测:P50/P95
查询延迟是最核心的指标。我模拟了连续输入关键词的场景,对每个方案在 1 万条、5 万条数据下分别测了 100 次查询,取 P50 和 P95。这里只统计纯查询时间,不包含防抖等待和渲染时间。
| 方案 | 1 万条 P50 | 1 万条 P95 | 5 万条 P50 | 5 万条 P95 |
|---|---|---|---|---|
| 原生 filter | 8ms | 15ms | 38ms | 62ms |
| Fuse.js(未建索引) | 30ms | 50ms | 150ms | 220ms |
| Fuse.js(createIndex) | 18ms | 35ms | 70ms | 120ms |
| MiniSearch | 1ms | 3ms | 2ms | 6ms |
| Meilisearch API | 35ms | 70ms | 48ms | 85ms |
这个表格能看出很多问题。原生filter在 1 万条时还挺快,到了 5 万条 P95 就有 60ms 以上,配合每敲一个字就触发查询的交互设计,用户体感就是“卡”。Fuse.js 虽然有模糊匹配能力,但在 5 万条时掉到 120-220ms,已经明显影响输入体验。MiniSearch 是唯一一个在 5 万条下依然维持在毫秒级的纯前端方案。Meilisearch 的延迟主要在网络 RTT,所以数据量变化影响反而不大。
2.3 内存占用与长期运行稳定性
内存这块我单独提一下,因为很多开发者只看搜索耗时而忽略内存泄漏。我打开 Chrome DevTools 的 Memory 面板,记录每种方案在完成 5 万条索引构建后的“保留内存”变化。
原生filter和 Fuse.js(不建索引)几乎没有额外内存占用。Fuse.js 使用createIndex后,5 万条数据大约多占用 30-50MB 内存。MiniSearch 的倒排索引会保留词项到文档的映射,同样数据集下大概占用 40-60MB。后端方案前端内存占用最小,但服务端要吃资源。
更重要的是,如果用户在搜索期间频繁输入、反复触发索引重建,内存可能只增不减。Vue 的响应式系统会额外放大这个问题——把大数组放进reactive后,每次改动都触发依赖收集和重渲染。我后来的做法是,大列表用shallowRef甚至普通变量维护,只有最终展示给用户的结果集才放进ref,这一步能省掉大量无意义的响应式代理开销。
3. 四种方案在 Vue 3 里的完整接入实现
方案选型只是第一步,落地才是考验。这一节把四个方案的 Vue 3 接入代码写出来,包含我自己常用的封装方式,兼容 Options API 和 Composition API 的读者都能看懂。
3.1 原生 filter 接入:computed 驱动的搜索
原生方案的核心是用computed缓存搜索结果,避免每次渲染都重新过滤。搜索关键词用一个ref维护,配合watch或输入框的v-model同步。
// useLocalSearch.js import { ref, computed } from 'vue' export function useLocalSearch(list, searchFields = ['title', 'content']) { const keyword = ref('') const results = computed(() => { const kw = keyword.value.trim().toLowerCase() if (!kw) return list.value return list.value.filter((item) => { return searchFields.some((field) => { const value = item[field] return value != null && String(value).toLowerCase().includes(kw) }) }) }) return { keyword, results } }这里有个关键细节:computed的缓存依赖是keyword.value和list.value,顺序很重要。如果在组件里对list做了额外过滤,比如“只显示上架商品”,一定要先处理完业务过滤再传入useLocalSearch,否则搜索结果可能出现“被过滤掉的商品仍然命中”的 bug。
我通常在组件里这样用:
<script setup> import { useLocalSearch } from './useLocalSearch' import { goodsList } from './api/goods' const { keyword, results } = useLocalSearch(goodsList, ['title', 'category']) </script> <template> <input v-model="keyword" placeholder="搜索商品名称或分类" /> <ul> <li v-for="item in results" :key="item.id">{{ item.title }}</li> </ul> </template>这个方案唯一要防的是重复渲染。computed已经能挡住大多数无效计算,但如果搜索结果依然是一个很大的数组,建议在模板里配合v-memo或在渲染列表时加上分页/虚拟滚动,避免一次性渲染几千个节点。
3.2 Fuse.js 接入:权重、阈值和防抖联动
Fuse.js 的核心配置是threshold和keys。threshold控制模糊匹配的宽容度,取值范围 0 到 1,0 表示完全精确,1 表示任意匹配。我常用的值是 0.4,容错和准确率的平衡点。
// useFuseSearch.js import Fuse from 'fuse.js' import { ref, watch, shallowRef } from 'vue' import { debounce } from 'lodash-es' export function useFuseSearch(list, options = {}) { const keyword = ref('') const results = ref([]) const fuse = shallowRef(null) const buildIndex = () => { fuse.value = new Fuse(list.value, { keys: ['title', 'content'], threshold: 0.4, ignoreLocation: true, minMatchCharLength: 2, includeMatches: true, includeScore: true, useExtendedSearch: true, ...options, }) } watch(list, buildIndex, { immediate: true }) const runSearch = debounce((kw) => { if (!kw.trim()) { results.value = list.value return } results.value = fuse.value.search(kw).map((r) => r.item) }, 200) watch(keyword, (kw) => runSearch(kw)) return { keyword, results } }几个容易踩的坑:
ignoreLocation: true很重要。默认 Fuse.js 会考虑关键词在文本中的位置,开启后不再因为关键词出现在靠后位置而惩罚分数,对于全文搜索更合理。minMatchCharLength: 2避免了用户输入一个字符时匹配出一大堆无关结果。includeMatches打开后可以在结果对象里拿到匹配位置数组,做高亮方便。但注意我上面的写法把r.item返回出去了,如果需要匹配片段,得改成返回整个r。
Fuse.js 的createIndex预建索引用法是在初始化时传入new Fuse(list, { ..., useExtendedSearch: true })后调用fuse.setCollection,或者直接用Fuse.createIndex(keys, list)把序列化索引存入内存。这个索引能显著减少候选文档数量,但计算量仍然不小,数据超过一万条后建议优先考虑 MiniSearch。
3.3 MiniSearch 接入:倒排索引的构建、查询与增量更新
MiniSearch 的接入流程分三步:初始化、建索引、搜索。它和 Fuse.js 最大的不同是“先建索引再搜”,所以数据更新时必须同步维护索引。
// useMiniSearch.js import MiniSearch from 'minisearch' import { ref, shallowRef, watch } from 'vue' export function useMiniSearch(list, options = {}) { const keyword = ref('') const results = ref([]) const searchIndex = shallowRef(null) const initIndex = () => { const mini = new MiniSearch({ fields: ['title', 'content'], storeFields: ['title', 'content', 'category', 'updatedAt'], searchOptions: { boost: { title: 2, content: 1 }, prefix: true, fuzzy: 0.2, }, ...options, }) mini.addAll(list.value) searchIndex.value = mini } watch(list, initIndex, { immediate: true }) watch(keyword, (kw) => { if (!searchIndex.value) return if (!kw.trim()) { results.value = list.value return } results.value = searchIndex.value.search(kw, { combineWith: 'AND' }) }) return { keyword, results } }这里searchOptions.boost设置了标题字段的权重是正文字段的两倍,搜索排序会优先展示标题命中的内容。prefix: true开启前缀匹配,用户输入“手机会”也能匹配到“手机配件、手机支架”等以“手机”开头的结果。fuzzy: 0.2允许极小的拼写误差。
增量更新是 MiniSearch 使用者的必修课。新增文档用add,删除用discard,修改就是先discard再add。如果不做增量更新,而是一口气调用addAll重建全量索引,五万条数据的构建时间就会在每次数据变更时重复出现,界面明显卡顿。
一个从实践中得到的建议:MiniSearch 的search返回的是文档 ID 加权重分数,如果你只需要结果列表,不要map成完整文档对象,直接让results.value持有 MiniSearch 返回的格式,渲染时再关联查原始数据。这样可以省下大量对象拷贝。
3.4 后端搜索 API 接入:请求竞态与 loading 处理
接入后端搜索,前端代码反而更简单,但多了一个必须处理的并发问题:用户快速输入时,旧请求的响应可能比新请求晚到,导致结果闪烁或错误覆盖。解决办法是给请求加“序列号”,只接受最新一次请求的响应。
// useRemoteSearch.js import { ref, watch } from 'vue' import { debounce } from 'lodash-es' export function useRemoteSearch(searchFn, { delay = 300 } = {}) { const keyword = ref('') const results = ref([]) const loading = ref(false) const error = ref(null) let requestSeq = 0 watch( keyword, debounce(async (kw) => { const currentSeq = ++requestSeq loading.value = true error.value = null try { const data = await searchFn(kw) if (currentSeq !== requestSeq) return results.value = data } catch (e) { if (currentSeq !== requestSeq) return error.value = e } finally { if (currentSeq === requestSeq) { loading.value = false } } }, delay) ) return { keyword, results, loading, error } }requestSeq这个变量就是竞态处理的钥匙。每次请求前自增一次,响应回来后对比当前的requestSeq,如果不是最新的就直接丢弃。这个方法比AbortController更轻量,因为AbortController在取消请求时会在某些浏览器里抛异常,而序列号方案不会产生额外的错误日志。
后端接口的返回结构建议统一成{ hits: [], total: number },前端拿到total做分页或“共 N 条结果”的提示,比在结果数组上length更准确。
4. 体验细节:高亮、分词、渲染与排序
性能数据只是地基,用户真正感知到的是交互体验。很多人测试时只关注“能不能搜出来”,忽略了搜索框本身的设计。这一节聊几个我在实际项目里打磨过的细节,每一项都对最终体验有实打实的影响。
4.1 输入防抖、最小字符数与空状态设计
搜索输入框最常见的错误是“每敲一个字符都触发一次搜索”。即使 MiniSearch 查询只要几毫秒,渲染层和响应式更新也可能让低端手机掉帧。我的统一做法是加 200-300ms 的防抖,同时限制至少输入 2 个字符才发起搜索,避免用户输入单个字母时页面陷入“什么都没输入但结果已经变了”的混乱。
空状态设计也很重要。关键词为空时,通常展示全部数据或热门推荐;搜索无结果时,展示友好的提示和“清空搜索”按钮。我习惯在组件里同时维护两个状态:results(搜索结果)和isSearching(是否为搜索模式),而不是让组件傻傻地把空数组渲染成空白页。
防抖在 Vue 里要注意一个坑:debounce的函数如果直接包装watch回调,会导致watch的immediate失效。如果需要在初始化时执行一次搜索,别依赖防抖,单独调用一次搜索函数即可。
4.2 搜索结果高亮的正确写法
高亮最常见的实现是用v-html直接把带<mark>标签的字符串渲染出来。这个写法有 XSS 风险,而且容易被用户输入的特殊字符打断正则。安全的做法分两步:先转义 HTML,再在转义后的文本里做关键词高亮。
function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function escapeRegExp(str) { return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function highlight(text, keyword) { const safeText = escapeHtml(text) const words = keyword.trim().split(/\s+/).filter(Boolean) if (!words.length) return safeText const pattern = words.map((w) => escapeRegExp(escapeHtml(w))).join('|') return safeText.replace(new RegExp(`(${pattern})`, 'gi'), '<mark>$1</mark>') }这个函数的关键是escapeRegExp必须先于escapeHtml处理,否则用户输入(、)、$等特殊字符会让正则报错。模板里直接:
<span v-html="highlight(item.title, keyword)"></span>注意高亮的粒度不要过细。有的开发者会把每个字符都包一个<mark>,结果整个标题变成一整片黄色,视觉上非常难看。更合理的做法是对每个关键词整体高亮,不同关键词之间可以继续累积。
4.3 中文分词的坑和应对
中文搜索和英文搜索完全不是一回事。英文按空格自然分词,中文的“我是一个前端工程师”是一整串连续字符。如果你直接用split(' ')分词,中文文本根本不会分。这个问题的连锁反应是:搜索“前端”时,包含“我是一个前端工程师”的文档可以匹配;但搜索“前端工程”时,由于文档里没有完全相同的子串,纯includes或 MiniSearch 默认分词就匹配不到。
最简单的应对是使用Intl.Segmenter,这是浏览器原生支持的中文分词 API:
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' }) function tokenize(text) { return [...segmenter.segment(text)] .map((seg) => seg.segment) .filter((word) => word.trim().length > 1) }放到 MiniSearch 里就是自定义tokenize:
const searchIndex = new MiniSearch({ fields: ['title', 'content'], tokenize, searchOptions: { combineWith: 'AND' }, })这里我踩过两个大坑:一是Intl.Segmenter在现代浏览器都支持,但在低版本 WebView 里可能是 undefined,建议做降级处理,比如回退到text.match(/[\u4e00-\u9fa5]+|[a-zA-Z0-9]+/g)这种简单的正则切词。二是分词器开销不小,如果数据量大,尽量在索引构建阶段完成分词,不要每次搜索时都分词。
4.4 大列表渲染优化:从全量渲染到虚拟列表
搜索结果几十条还好,如果是全文搜索,返回几千条匹配结果也正常。此时直接v-for渲染全量列表,DOM 节点数可能破万,低端设备立刻白屏或长时间无响应。我尝试过两个优化方案。
最直接的是“截断显示”,只显示前 50 条或前 100 条,底部加“加载更多”按钮。这个方案适合大多数业务场景,尤其是列表本身有分页逻辑时。另一个是虚拟列表,核心思想是只渲染可视区域内的元素,滚到哪渲染到哪。Vue 3 生态里vue-virtual-scroller、@tanstack/vue-virtual都比较好用。
我用@tanstack/vue-virtual写过一版商品搜索列表,关键代码是这样:
<script setup> import { useVirtualizer } from '@tanstack/vue-virtual' const parentRef = ref(null) const virtualizer = useVirtualizer({ count: results.value.length, getScrollElement: () => parentRef.value, estimateSize: () => 60, overscan: 10, }) </script> <template> <div ref="parentRef" class="scroll-area" style="height: 600px; overflow: auto"> <div :style="{ height: virtualizer.getTotalSize() + 'px' }"> <div v-for="item in virtualizer.getVirtualItems()" :key="item.key" :style="{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)`, }" > {{ results[item.index].title }} </div> </div> </div> </template>虚拟列表适合结果集很大且需要滚动查看的场景。但要注意,虚拟列表的内部状态和搜索结果是耦合的。搜索关键词变化时,虚拟列表需要回到顶部,否则视觉上会出现用户停留在列表中间、结果已经刷新但滚动位置没变的怪异现象。
5. 常见问题与排查技巧实录
每个做过搜索功能的人都遇到过类似的问题:搜索“卡成狗”、中文搜不到、请求乱序覆盖、内存持续上涨。这些问题往往不是搜不到结果,而是细节处理不到位。我把实操中遇到的典型问题记录在这里,每个都给出了排查思路和解决建议。
5.1 搜索越来越慢:索引过期与内存泄漏
症状是刚进入页面搜索很快,几分钟后越来越慢,最后几乎卡死。常见原因有三个:
第一,搜索数据被放进了reactive。Vue 的响应式系统会递归代理整个对象数组,搜索时每次访问字段都会触发依赖收集。数据量一大,这个开销远超搜索本身。排查方式是看 Chrome DevTools 的 Performance 面板里是不是大量时间花在Track Operations上。解决办法是把大数组换成shallowRef或普通变量,只用响应式数据保存keyword和最终的results。
第二,搜索内部缓存了过多的历史查询结果。比如每次输入都生成一个新的搜索结果数组,但旧数组没有被释放。排查方式是打开 Memory 面板拍几次 heap snapshot,看Array或Object的数量是否持续增长。解决方法是保证results.value每次都被重新赋值,而不是push进已有数组。
第三,Fuse.js 或 MiniSearch 在数据更新时直接重建整个索引。如果数据量很大,重建索引的时间会指数级上涨。MiniSearch 必须走discard+add的增量更新,Fuse.js 则尽量少地调用setCollection。
5.2 中文搜不到:分词器才是元凶
我遇到过最典型的场景是:搜索“vue”能匹配,“vue3”也能匹配,搜索“前端开发”就什么都搜不到。原因是默认分词器把“前端开发”当作一个完整 token 存进索引,用户输入“前端开发”时理论上能匹配到完全相同的 token,但如果用户输入的是“前端开”或“前端”,索引里没有对应 token,自然搜不到。
解决办法就是 4.3 里提到的自定义tokenize,用Intl.Segmenter或正则把中文切得更细。分词粒度也要平衡:切太细(每个字一个 token)会导致搜索结果过多、噪声大;切太粗(整句一个 token)又会导致查不全。我实践下来,按词切分、过滤单字和停用词是相对稳妥的选择。
5.3 接口竞态:最后一条响应不一定是最后一次请求
后端搜索服务最常见的体验问题是“搜索了 A 词又马上搜索 B 词,页面先显示 B 的结果再闪回 A 的结果”。这是典型的请求竞态。低速网络或异步任务多时更容易出现。
解决方式在 3.4 已经写过,用请求序列号或AbortController都行。我推荐序列号方案,因为它不受浏览器兼容性影响,也不用处理AbortError。要注意的是,序列号不能放在组件内部,应该放到useRemoteSearch的闭包里,确保每个搜索实例有独立状态。
5.4 选型决策速查表
最后整理一张速查表,可以直接贴在项目文档里:
| 判断条件 | 推荐方案 |
|---|---|
| 数据量 < 5000,需求只是按字段过滤 | 原生 filter |
| 字段少,用户可能拼错词,需要容错 | Fuse.js |
| 数据量 5 千到 50 万,全文检索为主 | MiniSearch |
| 数据量百万以上,需要多租户/权限过滤 | 后端搜索服务(Meilisearch/ES) |
| 团队无后端运维人力,纯前端项目 | MiniSearch |
| 已有后端搜索服务,前端只是展示层 | 调用 API 封装请求 |
这个表不是死规矩。如果你的项目数据量小但字段嵌套深,原生filter写起来很费劲,Fuse.js 会更省心。如果数据量很大但允许离线静态索引,也有纯前端的 FlexSearch 这类高性能方案。关键是先搞清楚自己卡在哪个阶段——是数据量、匹配精度,还是用户体验。
从我自己的项目经验看,很多搜索体验问题都不是“搜不出结果”,而是“结果太多、太慢、太乱”。先确定数据规模,再决定要不要上重型方案,比一上来就套一个大而全的搜索框架要踏实得多。最后再分享一个小技巧:无论选哪种方案,一定要准备一份固定的测试数据,把真实的 1 万条、5 万条数据提前放好,每次改动搜索策略后跑一遍对比,别等到用户投诉才回头查性能。