开发
给博客加了一个搜索功能
基于meilisearch的,支持关键词和语义搜索的简易搜索模块
作者首次尝试使用PGroonga搜索插件,但由于中日文搜索需匹配完整词汇而放弃,转向向量搜索,但倚赖AI的嵌入矢量会带来成本和准确度问题。最终选择meilisearch,结合关键词和语义搜索,实现多语言分词、内容推荐和向量数据生成,打造一个综合搜索系统。
- #搜索功能
- #PGroonga
- #Vector Search
- #meilisearch
- #分词
- #语义搜索
- #技术博客
325
新版博客上线时并没有搜索功能,但搜索一直是计划中的。本周花了两天时间,就完成了。比预想的快很多。
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还是当前最优方案:
自带多语言分词和索引,你只管添加数据,不用管具体实现;
可以使用OpenAI的API生成向量数据,实现语义搜索;
可以搜索相似内容,用于内容推荐。
并且设置了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进行排序。这样一个支持关键词和模糊搜索、能检索多个类型内容的简易搜索引擎就好了。
具体效果可以点击导航栏搜索图标体验。