从输入框到智能匹配:一文读懂搜索功能的完整实现

从输入框到智能匹配:一文读懂搜索功能的完整实现

搜索是每个应用中"使用频率最高、用户期望值最大"的功能。用户不会花时间理解你的数据分类,他们只会往框里打字,然后期望第一屏就看到想要的东西。

本文以本项目为例,从前端 UI 到后端向量检索,拆解一个完整搜索功能的实现全过程。重点包括三部分:防抖输入封装前端 Mock 关键词匹配 ,以及基于 OpenAI Embedding 的语义化搜索


一、整体架构一览

scss 复制代码
用户输入 "React 状态管理"
        │
        ▼
┌─────────────────┐
│  SearchPage.tsx  │  ← 输入框 + 防抖
└────────┬────────┘
         │ debouncedKeyword (500ms 后触发)
         ▼
┌─────────────────┐
│ useSearchStore   │  ← Zustand 状态管理
└────────┬────────┘
         │ doSearch(keyword)
         ▼
┌─────────────────┐
│   API 层         │  ← GET /search?keyword=xxx
└────────┬────────┘
         │
    ┌────┴────┐
    ▼         ▼
┌───────┐ ┌──────────┐
│ Mock  │ │ 后端 AI   │  ← 两条路径
│ 关键词 │ │ 语义检索  │
│ 匹配  │ │           │
└───────┘ └──────────┘

前端开发阶段走 Vite Mock 路径,后端联调时走 NestJS AI 语义检索 路径。两条路径共享同一套前端代码,仅切换请求地址。


二、防抖输入:别让每次按键都发请求

2.1 为什么需要防抖?

用户的打字速度大约是 100~300ms 一个汉字。如果每次 onChange 都发送一次 API 请求,搜索"前端框架"四个字就会产生 8 次请求(含拼音输入法中间态),其中前 7 次的结果用户根本看不到就被丢弃了。

防抖的核心思想:等用户停下来,再发请求

2.2 useDebounce Hook 实现

useDebounce.ts 是整个防抖逻辑的封装,源码仅 22 行:

typescript 复制代码
export function useDebounce<T>(value: T, delay: number): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => {
            clearTimeout(handler);
        }
    }, [value, delay]);

    return debouncedValue;
}

工作原理(逐行拆解)

  1. 初始化useState(value) 用首次传入的值初始化防抖后的状态。

  2. 核心------setTimeout + cleanup

    • 每次 value 变化,useEffect 重新执行
    • setTimeout 注册一个定时器,delay 毫秒后更新 debouncedValue
    • 关键return () => clearTimeout(handler) 是 React 的 cleanup 函数------在下一次 effect 执行前被调用,取消上一个还没到时间的定时器
  3. 实际效果图示

makefile 复制代码
时间轴:  0ms     100ms    200ms    300ms    500ms    800ms   1000ms
按键:     R       e        a         c        t
                                          ↑最后一次按键
定时器1:  ⏳──────✗ (被取消)
定时器2:          ⏳──────✗ (被取消)
定时器3:                  ⏳──────✗ (被取消)
定时器4:                           ⏳──────✗ (被取消)
定时器5:                                    ⏳──────────✅ (触发请求)
                                                         ↑ 500ms后

每一次新按键都会"杀死"上一个定时器,只有最后一次按键后安静了 500ms,定时器才真正触发,更新 debouncedValue。这就是"防抖(Debounce)"名称的由来------好比按压弹簧,不再施加压力后,它才会稳定在最终位置。

  1. 泛型设计<T> 让这个 Hook 可以防抖任何类型的值------字符串、数字、对象均可,不必为每种类型单独写一遍逻辑。

2.3 在 SearchPage 中使用

SearchPage.tsx:17-18 中的使用非常简洁:

typescript 复制代码
const [keyword, setKeyword] = useState('');       // 原始值,绑定到 Input
const debouncedKeyword = useDebounce<string>(keyword, 500); // 防抖值,用来发请求

useEffect(() => {
    if (debouncedKeyword.trim()) {
        search(debouncedKeyword);  // 只有防抖后的值才触发搜索
    }
}, [debouncedKeyword]);

keyword 负责跟 UI 绑定(保证输入框即时响应),debouncedKeyword 负责跟 API 绑定(保证不发送多余请求)。两者分工明确。


三、前端 Mock:关键词匹配搜索

3.1 数据准备

mock/search.js 内嵌了 30+ 篇技术文章的数据,每条包含 titlecategory

javascript 复制代码
const posts = [
    { "title": "如何使用 React Hooks 构建可复用的组件", "category": "前端开发" },
    { "title": "使用 Nest.js 和 GraphQL 构建一个简单的 GraphQL 服务", "category": "后端开发" },
    // ...共 30+ 条
];

3.2 URL 路由设计

Mock 注册的 URL 为 /api/search,使用查询参数传递关键词:

ini 复制代码
GET /api/search?keyword=React

这里有一个容易踩的坑:很多初学者会把关键词放在路径里(/api/search/React),导致 Mock 的 URL 匹配规则 url: '/api/search' 无法命中。Query String 方式的好处是 URL 结构清晰,Mock 和真实后端可共用同一套接口约定。

3.3 匹配逻辑

javascript 复制代码
const url = new URL(req.url, 'http://localhost:5173');
const keyword = url.searchParams.get('keyword') || '';
const lowerKeyword = keyword.toLowerCase();

filteredPosts = posts.filter(post =>
    post.title.toLowerCase().includes(lowerKeyword) ||
    post.category.toLowerCase().includes(lowerKeyword)
).map(post => post.title);

三步走

  1. URL 构造函数解析请求中的 query string,提取 keyword
  2. 统一转小写,实现大小写不敏感匹配
  3. 同时搜索 titlecategory 两个字段,用 includes 做子串匹配

3.4 响应格式与状态管理

Mock 返回标准化结构:

json 复制代码
{
    "code": 0,
    "msg": "success",
    "data": ["如何使用 React Hooks 构建可复用的组件", ...],
    "total": 3
}

前端 Zustand Store(search.ts)负责消费这个响应:

typescript 复制代码
const res = await doSearch(keyword);          // axios 拦截器已解包 res.data
const data = res.data || [];                  // 取 data 字段 → 文章标题数组
set({ suggestions: data });
get().addHistory(keyword.trim());             // 同步保存搜索历史

注意 :Store 使用了 zustand/middlewarepersist 中间件,partialize 只持久化 history 字段到 localStorage,suggestionsloading 不持久化。这意味着刷新页面后搜索历史依然存在,但搜索结果会被清空------这是合理的行为。


四、后端语义化搜索:从"匹配字符"到"理解语义"

关键词匹配有一个致命缺陷:它能找到包含"Vue 状态管理"的文章,但找不到讲"Vuex"的文章------虽然两者语义几乎完全相同。

语义化搜索解决的就是这个问题:把文本转成数学向量,在向量空间里找"意思最接近"的结果。

4.1 核心概念:词嵌入(Word Embedding)

词嵌入是一种将文本映射到高维向量空间的 NLP 技术。它的核心思想是:

语义相似的文本,在向量空间中距离更近。

OpenAI 的 text-embedding-ada-002 模型能将任意文本转换为一个 1536 维的浮点数向量。举个例子:

scss 复制代码
"React 状态管理" → [0.014, -0.009, 0.023, ..., 0.031] (1536维)
"Vuex 状态管理"  → [0.012, -0.007, 0.021, ..., 0.029] (1536维)
"今天天气真好"   → [-0.031, 0.017, -0.014, ..., 0.008] (1536维)

"React 状态管理"和"Vuex 状态管理"的向量在 1536 维空间中的夹角会很小(余弦值接近 1),而它们与"今天天气真好"的夹角会接近 90 度(余弦值接近 0)。

4.2 第一步:生成嵌入向量

create-embedding.mjs 负责预先生成所有文章的向量数据:

javascript 复制代码
import { client } from './app.service.mjs';

for (const { title, category } of posts) {
    const response = await client.embeddings.create({
        model: 'text-embedding-ada-002',
        input: `标题:${title} 分类:${category}`,   // 拼接标题和分类作为输入
    });
    postsWithEmbedding.push({
        title,
        category,
        embedding: response.data[0].embedding,     // 1536维向量
    });
}

await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));

关键设计 :把 标题分类 拼接成 标题:如何使用 React Hooks 分类:前端开发 再送入嵌入模型。这样做的好处是向量同时编码了文章主题和技术领域信息,搜索"前端"时,分类为"前端开发"的文章会天然获得更高的相似度。

生成后的数据保存在 posts-embedding.json 中,每条记录的结构为:

json 复制代码
{
    "title": "如何在 React 中实现无限滚动",
    "category": "前端开发",
    "embedding": [0.0145, 0.0049, 0.0062, -0.0090, ...(共1536维)]
}

NestJS 启动时,AiService 的 loadPosts() 方法将这个 JSON 文件加载到内存中,之后所有搜索都不再需要调用 OpenAI API------直接在内存中做向量运算。

4.3 第二步:余弦相似度------向量之间的"距离尺"

余弦相似度(Cosine Similarity)衡量两个向量在方向上的接近程度,公式为:

css 复制代码
cos(θ) = (A · B) / (|A| × |B|)

其中:

  • A · B 是向量 A 与 B 的点积(dot product):对应元素相乘再求和
  • |A| 是向量 A 的(magnitude):各维度的平方和再开方

返回值范围 [-1, 1]:

  • 1:方向完全相同,语义高度相似
  • 0:正交,两段文本互不相关
  • -1:方向完全相反

ai.service.ts:33-38 中的实现:

typescript 复制代码
export function cosineSimilarity(v1: number[], v2: number[]): number {
    const dotProduct = v1.reduce((sum, val, i) => sum + val * v2[i], 0);
    const normV1 = Math.sqrt(v1.reduce((sum, val) => sum + val * val, 0));
    const normV2 = Math.sqrt(v2.reduce((sum, val) => sum + val * val, 0));
    return dotProduct / (normV1 * normV2);
}

为什么用余弦相似度而不是欧几里得距离?

欧几里得距离衡量的是两点之间的"直线长度",它会受向量长度的影响。假设两篇文章的主题完全相同,只是一篇长、一篇短------它们的嵌入向量在方向上一致,但长度不同。余弦相似度只看方向不看长度,能正确识别它们语义相同;欧几里得距离则会因为长度差异而误判。

4.4 第三步:搜索检索流程

ai.service.ts:92-107 实现了完整的搜索方法:

typescript 复制代码
async search(keyword: string, topK = 3) {
    // 1. 将用户输入转为向量
    const vector = await this.embeddings.embedQuery(keyword);

    // 2. 计算每篇文章与查询向量的余弦相似度
    const results = this.posts
        .map(post => ({
            ...post,
            similarity: cosineSimilarity(vector, post.embedding),
        }))
        // 3. 按相似度降序排列
        .sort((a, b) => b.similarity - a.similarity)
        // 4. 取前 topK 条(默认3条)
        .slice(0, topK)
        // 5. 返回标题列表
        .map(item => item.title);

    return { code: 0, data: results };
}

五个步骤

  1. embeddings.embedQuery(keyword) --- 调用 OpenAI API 将搜索词实时转为 1536 维向量
  2. .map --- 遍历内存中所有文章,逐一与查询向量计算余弦相似度
  3. .sort --- 按相似度从大到小排序
  4. .slice(0, topK) --- 取相似度最高的 K 条
  5. 返回文章标题数组

时间复杂度分析:N 篇文章,每篇 M 维向量。点积复杂度 O(N×M)。以本项目为例------35 篇文章 × 1536 维 = 约 5.4 万次浮点运算,在 Node.js 中耗时约 1~3ms,远小于一次网络请求的延迟,完全可以接受。

4.5 语义搜索 vs 关键词搜索:直观对比

维度 关键词匹配 (Mock) 语义搜索 (AI)
搜索"Vue 状态管理" 能找到带"Vue"和"状态管理"的文章 能额外找到 Vuex 相关文章
搜索"API 接口" 只能精确匹配 "RESTful API"、"GraphQL"都会上榜
搜索"移动端" 依赖分类字段 "React Native"、"Flutter"、"Kotlin"均能召回
错别字 完全搜不到 向量接近,有一定容错
延迟 即时 ~200ms(需调 OpenAI API)
适用场景 开发调试、简单搜索 用户真实搜索、内容发现

两种方式并非"谁替代谁"的关系------关键词匹配简单可靠,语义搜索智能灵活。生产环境中常见的做法是混合检索:先用语义搜索扩大召回范围,再用关键词匹配做精确过滤和排序加权。


五、控制器层的收尾工作

AIController 负责接收前端请求并透传给 AiService:

typescript 复制代码
@Get('search')
async search(@Query() dto: SearchDto) {
    const { keyword } = dto;
    let decode = decodeURIComponent(keyword);  // URL 解码(中文被 encodeURIComponent 处理过)
    return this.aiService.search(decode);
}

SearchDto 使用 class-validator 装饰器做参数校验:

typescript 复制代码
export class SearchDto {
    @IsString({ message: 'keyword 必须是字符串' })
    @IsNotEmpty({ message: 'keyword 不能为空' })
    keyword: string;
}

配合 ValidationPipe(在 main.ts 中全局启用),前端传入的 keyword 如果不是字符串或为空,会在到达 Controller 方法体之前就被拦截,返回 400 错误。这就是 NestJS 的**管道(Pipe)**机制------在请求进入业务逻辑之前完成校验和转换。


六、前端搜索页面的 UI 状态管理

SearchPage.tsx 实现了三种 UI 状态的切换:

ini 复制代码
┌──────────────────────────────────┐
│  keyword 为空 + 有历史记录         │
│  → 显示"搜索历史"卡片              │
│  → 点击历史标签可重新搜索           │
├──────────────────────────────────┤
│  keyword 不为空 + loading = true  │
│  → 显示"搜索中..."                │
├──────────────────────────────────┤
│  keyword 不为空 + loading = false │
│  → suggestions 有值:渲染列表      │
│  → suggestions 为空:"暂无搜索结果" │
└──────────────────────────────────┘

三种状态各自独立渲染,互不干扰。搜索结果通过 ScrollArea 组件包裹,在移动端提供原生的滚动体验。


七、完整数据流回顾

以用户搜索"React 状态管理"为例,端到端走一遍:

perl 复制代码
1. 用户在 SearchPage 输入框键入 "React 状态管理"
        │
2. useDebounce 等待 500ms,期间每次新按键都会取消之前的定时器
        │
3. 防抖值变更 → useEffect 触发 search("React 状态管理")
        │
4. Zustand Store 设置 loading = true,UI 显示"搜索中..."
        │
5. doSearch() → GET /search?keyword=React%20%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86
        │
6. [开发环境] Mock 拦截 → 关键词匹配 title + category → 返回标题数组
   [生产环境] NestJS → AiService.search() → embedQuery → 余弦相似度计算 → 返回标题数组
        │
7. Store 接收响应,set({ suggestions: data }),loading = false
        │
8. SearchPage 重新渲染,suggestions.map() 渲染搜索结果列表
        │
9. 用户点击某条结果 → navigate('/') 返回首页

八、总结

本文介绍的搜索系统包含三条技术主线:

  1. 防抖(Debounce) :用 setTimeout + React cleanup 函数实现的通用 Hook,将高频输入收敛为低频请求,减少无效 API 调用
  2. 关键词匹配 :基于 JavaScript includes 的简单子串检索,适合开发阶段 Mock 数据
  3. 语义化搜索:基于 OpenAI Embedding 将文本转为高维向量,用余弦相似度在向量空间中检索语义相近的内容------这才是让搜索"理解用户意图"的关键

三者组合在一起,构成了一个从输入框到智能匹配的完整搜索闭环。从防抖优化用户体验,到向量计算实现语义理解,每一层都有明确的分工和数学原理支撑。理解这些原理后,你可以根据实际业务需求调整参数------比如调大 delay 减少请求频率、增加 topK 返回更多结果,或者换成其他嵌入模型(如 text-embedding-3-small)来平衡精度和成本。

相关推荐
朝阳392 小时前
React【面试】
前端·react.js·面试
漓漾li2 小时前
每日面试题(2026-05-15)- 前端
前端·vue.js·react.js
进击切图仔2 小时前
RAG 加载 pdf 文档
linux·前端·pdf
小小小小宇2 小时前
git 大仓库拉取卡顿问题
前端
前端那点事2 小时前
告别低级冗余!10个前端原生高阶技巧,让代码更优雅、性能更出众
前端·vue.js
hexu_blog2 小时前
前端vue后端java如何实现证件照功能
前端·javascript·vue.js
豹哥学前端2 小时前
前端 LocalStorage 实战:从入门到熟练,一篇就够了
前端·javascript·面试
用户40189933422842 小时前
第 11 章 MCP 协议与集成
前端