开发

给博客加了一个搜索功能

基于meilisearch的,支持关键词和语义搜索的简易搜索模块

作者首次尝试使用PGroonga搜索插件,但由于中日文搜索需匹配完整词汇而放弃,转向向量搜索,但倚赖AI的嵌入矢量会带来成本和准确度问题。最终选择meilisearch,结合关键词和语义搜索,实现多语言分词、内容推荐和向量数据生成,打造一个综合搜索系统。

  1. #搜索功能
  2. #PGroonga
  3. #Vector Search
  4. #meilisearch
  5. #分词
  6. #语义搜索
  7. #技术博客

325

A white arrow points to the right, pointing towards a blue background with six gears in various colors, including red, yellow, green, and blue.A white arrow points to the right, pointing towards a blue background with six gears in various colors, including red, yellow, green, and blue.

新版博客上线时并没有搜索功能,但搜索一直是计划中的。本周花了两天时间,就完成了。比预想的快很多。

PGroonga

最开始打算用Postgresql插件PGroonga实现搜索。PGroonga可以对非拉丁文字的语言进行索引和检索。这个方案案例众多,文档详尽,很好实现。

我的博客有三类内容,我希望一次搜索的时候能同时检索这三个类型。

于是我创建了一个Materialized View,把文章、摄影、想法这三类内容需要检索的字段放在了同一个视图,在数据表有更新的时候trigger视图刷新。

最后给该混合视图创建index,就能实现搜索了。

前端实现起来也不难。

实际测试发现,中日文的确能搜索。但只能完整匹配。

就是说,假如有内容是“中华人民共和国”,你搜“中国”就不会有结果,必须是“中华”、“人民”这样才行。这显然不是个好用的搜索。

PGroonga可以搜索多个词,那么如果将搜索词进行拆分,就可以实现上面的需求了。

但更大的问题来了:怎么分词?

的确有很多库可以实现分词,但难道要为中日文分别采用不同的库?还得加一道判断语言的程序?太麻烦了。

虽然功能都已经写完,但我还是坚决放弃了这个路线。

Vector Search

向量搜索,就是把文本转换成向量,这个过程叫embedding。而后可以对比二者在多维空间上的距离,来判断这两段文本在语义上的相似度。

举个例子,如果你给一段文本打上标签和分数,比如“生活:0.1,技术:0.5,旅行:0.1”,而另一段文本的分数是“生活:0.1,技术:0.7,旅行:0.2”。那么这两段文本很可能在内容上是接近的,都是技术类内容。

实际的向量数多达上千,这只是一个简化的理解。

向量搜索的优势是不再局限于特定关键词的匹配,而是从语义上进行检索。比如一个菜谱数据库,你在搜索“大盘鸡”的时候,可能也想看看其他新疆的菜谱,或者其他以鸡为主料的菜。这些向量搜索都可以实现。

虽然embedding的过程需要借助AI(本站使用OpenAI的text-embedding-ada-002模型),有成本。但同样的向量数据也可以用来做内容推荐,这也是我未来要加的功能。

借助Supabase Edge Function,可以很容易实现自动生成向量数据并存储的功能。

虽然向量搜索可以搜索内容的含义,但有些时候准确度不如关键词搜索。拿上面的例子来说,也许我就是想要“大盘鸡”的信息。但向量搜索会给出一堆不是大盘鸡的菜谱。

最好的方案,还是以关键词为主,语义搜索为辅。这时候meilisearch再次进入我的视线。

meilisearch

其实一开始就考虑过meilisearch,但当时秉持能简单就简单的思想,暂时搁置了。经过一番摸索,发现meilisearch还是当前最优方案:

  1. 自带多语言分词和索引,你只管添加数据,不用管具体实现;

  2. 可以使用OpenAI的API生成向量数据,实现语义搜索;

  3. 可以搜索相似内容,用于内容推荐。

并且设置了OpenAI的key后,并不需要你手动处理embedding的过程,都是自动的。

当前方案

写了一个Edge Function,在对应的表发生了INSERT、UPDATE、DELETE时触发。前两个操作会把新增的数据发送给meilisearch服务器,后一个则是删除对应的数据。

typescript

import { serve } from "https://deno.land/[email protected]/http/server.ts"; const MEILI_URL = Deno.env.get('MEILI_URL'); const MEILI_KEY = Deno.env.get('MEILI_KEY'); interface Record { id: string; lang?: string; slug?: string; title?: string; subtitle?: string; abstract?: string; content_text?: string; topic?: string; is_draft?: boolean; } interface Payload { type: 'INSERT' | 'UPDATE' | 'DELETE'; table: string; schema: string; record?: Record; old_record?: Record; } async function handleMeilisearch(payload: Payload) { const { type, table, record, old_record } = payload; let url = `${MEILI_URL}/indexes/${table}/documents`; let method = 'POST'; let body; if (type === 'DELETE' || (type === 'UPDATE' && !old_record.is_draft && record.is_draft)) { url = `${url}/${old_record.id}`; method = 'DELETE'; } else if (type === 'INSERT' || type === 'UPDATE') { if (record.is_draft) { console.log(`跳过索引操作:${table} 是草稿状态`); return { skipped: true, reason: 'Draft' }; } const fields = { article: ['id', 'lang', 'slug', 'title', 'subtitle', 'abstract', 'content_text', 'topic'], photo: ['id', 'slug', 'lang', 'title', 'abstract', 'content_text', 'topic'], thought: ['id', 'slug', 'content_text', 'topic'] }; body = JSON.stringify([ fields[table as keyof typeof fields].reduce((obj, field) => { if (record[field as keyof Record] !== undefined) { obj[field] = record[field as keyof Record]; } return obj; }, {} as Record) ]); method = type === 'UPDATE' ? 'PUT' : 'POST'; } const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${MEILI_KEY}` }, body }); if (!response.ok) { throw new Error(`Meilisearch操作失败: ${response.statusText}`); } return response.json(); } serve(async (req) => { try { const payload: Payload = await req.json(); const result = await handleMeilisearch(payload); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('处理请求时发生错误:', error); return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } });

等到数据都传输到meilisearch存储为document,等待索引完成,就可以进行搜索了。

你可以自行设置如何搜索,设置高亮的字段,设定向量搜索所占的比重等等。将下面的代码作为body发送给meilisearch的/multi-search endpoint,具体该怎么搜索可以看文档来决定:

typescript

queries: [ { indexUid: "article", q: query, limit: 10, attributesToCrop: ["abstract", "content_text"], cropLength: 24, cropMarker: "...", attributesToHighlight: ["title", "abstract", "content_text", "topic"], highlightPreTag: "<span class=\"text-violet-600\">", highlightPostTag: "</span>", showRankingScore: true, hybrid: { embedder: "default", semanticRatio: 0.4 } }, { indexUid: "photo", q: query, limit: 15, attributesToCrop: ["abstract", "content_text"], cropLength: 24, cropMarker: "...", attributesToHighlight: ["title", "abstract", "content_text", "topic"], highlightPreTag: "<span class=\"text-violet-600\">", highlightPostTag: "</span>", showRankingScore: true, hybrid: { embedder: "default", semanticRatio: 0.5 } }, { indexUid: "thought", q: query, limit: 5, attributesToCrop: ["content_text"], cropLength: 24, cropMarker: "...", attributesToHighlight: ["content_text", "topic"], highlightPreTag: "<span class=\"text-violet-600\">", highlightPostTag: "</span>", showRankingScore: true, hybrid: { embedder: "default", semanticRatio: 0.5 } } ]

最后你可以在前端过滤一下结果,按照rankingScore进行排序。这样一个支持关键词和模糊搜索、能检索多个类型内容的简易搜索引擎就好了。

具体效果可以点击导航栏搜索图标体验。