给博客加了一个搜索功能

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

开发

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

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

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服务器,后一个则是删除对应的数据。

             import { serve } from "https://deno.land/std@0.224.0/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,具体该怎么搜索可以看文档来决定:

             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进行排序。这样一个支持关键词和模糊搜索、能检索多个类型内容的简易搜索引擎就好了。

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