全文搜索与 Algolia 集成

数据库的 LIKE '%keyword%' 在数据量超过 10 万条后会明显变慢,且不支持分词、拼写纠错、同义词等高级搜索特性。全文搜索引擎解决这个问题。本章对比三种方案——Algolia(SaaS)、MeiliSearch(自托管)、PostgreSQL 全文搜索(零依赖)——并给出完整的 Nuxt4 集成实战,包括索引同步、搜索 UI 和高亮显示。

1. 方案对比

特性AlgoliaMeiliSearchPostgreSQL FTS
部署SaaS(无需运维)自托管(Docker)数据库内置
中文支持✅(付费)✅(内置)⚠️ 需要 zhparser
延迟< 50ms(CDN)< 50ms(本地)取决于数据量
高亮
分面搜索
拼写纠错
价格免费 10K 请求/月免费开源免费
适用规模任意< 1000 万条< 100 万条

选型建议

  • 小项目 / 数据量小 → PostgreSQL FTS
  • 中型项目 / 自控数据 → MeiliSearch
  • 追求极致搜索体验 / 不想运维 → Algolia

2. PostgreSQL 全文搜索(零依赖方案)

2.1 添加搜索列

// database/schema/videos.ts
export const videos = pgTable('videos', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: varchar('title', { length: 200 }).notNull(),
  description: text('description'),
  // 全文搜索向量列
  searchVector: tsvector('search_vector'),
}, (table) => ({
  searchIdx: index('videos_search_idx').using('gin', table.searchVector),
}))
-- migration: 自动更新搜索向量
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('simple', coalesce(NEW.title, '')), 'A') ||
    setweight(to_tsvector('simple', coalesce(NEW.description, '')), 'B');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER videos_search_update
  BEFORE INSERT OR UPDATE ON videos
  FOR EACH ROW EXECUTE FUNCTION update_search_vector();

2.2 搜索 API

// server/api/search.get.ts
export default defineEventHandler(async (event) => {
  const { q, limit = 20 } = getQuery(event)
  if (!q || typeof q !== 'string') return { results: [] }
 
  const results = await db.execute(sql`
    SELECT id, title, description,
           ts_headline('simple', title, plainto_tsquery('simple', ${q}),
             'StartSel=<mark>, StopSel=</mark>') as highlighted_title,
           ts_rank(search_vector, plainto_tsquery('simple', ${q})) as rank
    FROM videos
    WHERE search_vector @@ plainto_tsquery('simple', ${q})
    ORDER BY rank DESC
    LIMIT ${limit}
  `)
 
  return { results }
})

3. MeiliSearch 集成

3.1 Docker 部署

# docker-compose.yml
services:
  meilisearch:
    image: getmeili/meilisearch:v1.7
    ports:
      - "7700:7700"
    volumes:
      - meili_data:/meili_data
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_ENV: production
 
volumes:
  meili_data:

3.2 索引同步

// server/utils/search.ts
import { MeiliSearch } from 'meilisearch'
 
const client = new MeiliSearch({
  host: process.env.MEILI_HOST || 'http://localhost:7700',
  apiKey: process.env.MEILI_MASTER_KEY,
})
 
export async function indexVideo(video: Video) {
  await client.index('videos').addDocuments([{
    id: video.id,
    title: video.title,
    description: video.description,
    category: video.categoryName,
    author: video.authorName,
    createdAt: video.createdAt,
    viewCount: video.viewCount,
  }])
}
 
export async function removeVideoFromIndex(id: string) {
  await client.index('videos').deleteDocument(id)
}
 
export async function searchVideos(query: string, options?: {
  limit?: number
  offset?: number
  filter?: string
  facets?: string[]
}) {
  return client.index('videos').search(query, {
    limit: options?.limit ?? 20,
    offset: options?.offset ?? 0,
    filter: options?.filter,
    facets: options?.facets,
    attributesToHighlight: ['title', 'description'],
    highlightPreTag: '<mark>',
    highlightPostTag: '</mark>',
  })
}

3.3 配置索引设置

// server/tasks/setup-search-index.ts
export default defineTask({
  meta: { name: 'search:setup' },
  async run() {
    const index = client.index('videos')
 
    await index.updateSettings({
      searchableAttributes: ['title', 'description', 'category', 'author'],
      filterableAttributes: ['category', 'createdAt', 'viewCount'],
      sortableAttributes: ['createdAt', 'viewCount'],
      rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
    })
 
    return { result: 'Index configured' }
  },
})

3.4 搜索 API

// server/api/search.get.ts
export default defineEventHandler(async (event) => {
  const { q, category, page = 1, limit = 20 } = getQuery(event)
  if (!q) return { hits: [], totalHits: 0 }
 
  const filter = category ? `category = "${category}"` : undefined
 
  const results = await searchVideos(q as string, {
    limit: Number(limit),
    offset: (Number(page) - 1) * Number(limit),
    filter,
    facets: ['category'],
  })
 
  return {
    hits: results.hits,
    totalHits: results.estimatedTotalHits,
    facets: results.facetDistribution,
    processingTimeMs: results.processingTimeMs,
  }
})

3.5 数据同步钩子

// server/api/videos/index.post.ts
export default defineEventHandler(async (event) => {
  const user = await requireAuth(event)
  const body = await readValidatedBody(event, schema.parse)
 
  const [video] = await db.insert(videos).values({
    ...body,
    authorId: user.id,
  }).returning()
 
  // 同步到搜索索引
  await indexVideo({ ...video, authorName: user.name })
 
  return video
})

4. Algolia 集成

4.1 安装

pnpm add algoliasearch vue-instantsearch

4.2 服务端索引

// server/utils/algolia.ts
import algoliasearch from 'algoliasearch'
 
const client = algoliasearch(
  useRuntimeConfig().algoliaAppId,
  useRuntimeConfig().algoliaAdminKey
)
const index = client.initIndex('videos')
 
export async function algoliaIndexVideo(video: Video) {
  await index.saveObject({
    objectID: video.id,
    title: video.title,
    description: video.description,
    category: video.categoryName,
    thumbnail: video.thumbnail,
    viewCount: video.viewCount,
  })
}
 
export async function algoliaRemoveVideo(id: string) {
  await index.deleteObject(id)
}

4.3 客户端搜索 UI

<!-- components/SearchDialog.client.vue -->
<script setup lang="ts">
import algoliasearch from 'algoliasearch/lite'
import { AisInstantSearch, AisSearchBox, AisHits, AisHighlight, AisRefinementList } from 'vue-instantsearch/vue3/es'
 
const config = useRuntimeConfig()
const searchClient = algoliasearch(config.public.algoliaAppId, config.public.algoliaSearchKey)
</script>
 
<template>
  <AisInstantSearch :search-client="searchClient" index-name="videos">
    <AisSearchBox placeholder="搜索视频..." />
 
    <div class="flex gap-6 mt-4">
      <aside class="w-48">
        <h3 class="font-semibold mb-2">分类</h3>
        <AisRefinementList attribute="category" />
      </aside>
 
      <main class="flex-1">
        <AisHits>
          <template #item="{ item }">
            <NuxtLink :to="`/video/${item.objectID}`" class="flex gap-4 p-3 hover:bg-gray-50 rounded">
              <img :src="item.thumbnail" class="w-32 h-20 object-cover rounded" />
              <div>
                <AisHighlight attribute="title" :hit="item" class="font-medium" />
                <AisHighlight attribute="description" :hit="item" class="text-sm text-gray-500 line-clamp-2" />
              </div>
            </NuxtLink>
          </template>
        </AisHits>
      </main>
    </div>
  </AisInstantSearch>
</template>

5. 搜索 UI 组件

5.1 通用搜索框(不依赖特定引擎)

<!-- components/SearchInput.vue -->
<script setup lang="ts">
const query = ref('')
const results = ref<SearchResult[]>([])
const isOpen = ref(false)
 
const debouncedSearch = useDebounceFn(async (q: string) => {
  if (!q.trim()) { results.value = []; return }
  const data = await $fetch('/api/search', { query: { q } })
  results.value = data.hits
}, 300)
 
watch(query, (q) => debouncedSearch(q))
 
// Cmd+K 快捷键打开搜索
onMounted(() => {
  document.addEventListener('keydown', (e) => {
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
      e.preventDefault()
      isOpen.value = true
    }
  })
})
</script>
 
<template>
  <UModal v-model="isOpen">
    <div class="p-4">
      <input
        v-model="query"
        type="search"
        placeholder="搜索视频... (⌘K)"
        class="w-full px-4 py-2 border rounded-lg"
        autofocus
      />
      <ul class="mt-4 max-h-96 overflow-y-auto">
        <li v-for="hit in results" :key="hit.id">
          <NuxtLink
            :to="`/video/${hit.id}`"
            class="flex gap-3 p-2 hover:bg-gray-50 rounded"
            @click="isOpen = false"
          >
            <img :src="hit.thumbnail" class="w-20 h-12 object-cover rounded" />
            <div>
              <p class="font-medium" v-html="hit._formatted?.title ?? hit.title" />
              <p class="text-sm text-gray-500">{{ hit.category }}</p>
            </div>
          </NuxtLink>
        </li>
        <li v-if="query && !results.length" class="text-center text-gray-400 py-8">
          无搜索结果
        </li>
      </ul>
    </div>
  </UModal>
</template>

5.2 搜索结果高亮

// composables/useSearchHighlight.ts
export function useSearchHighlight() {
  function highlightText(text: string, query: string): string {
    if (!query) return text
    const regex = new RegExp(`(${escapeRegex(query)})`, 'gi')
    return text.replace(regex, '<mark>$1</mark>')
  }
 
  function escapeRegex(str: string): string {
    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  }
 
  return { highlightText }
}

6. 全量索引同步

// server/api/admin/reindex.post.ts
export default defineEventHandler(async (event) => {
  await requireAdmin(event)
 
  const batchSize = 500
  let offset = 0
  let total = 0
 
  while (true) {
    const batch = await db.select().from(videosTable)
      .limit(batchSize)
      .offset(offset)
 
    if (batch.length === 0) break
 
    await client.index('videos').addDocuments(
      batch.map(v => ({
        id: v.id,
        title: v.title,
        description: v.description,
      }))
    )
 
    total += batch.length
    offset += batchSize
  }
 
  return { indexed: total }
})

本章小结

  • PostgreSQL FTS:零依赖、适合小数据量(< 100 万)、无额外运维成本
  • MeiliSearch:自托管、中文支持好、亚毫秒响应、适合中型项目
  • Algolia:SaaS 免运维、分面搜索开箱即用、vue-instantsearch UI 组件丰富
  • 索引同步:数据写入时同步推送到搜索引擎 + 全量重建 API 兜底
  • 搜索 UI:Cmd+K 快捷键 + 防抖搜索 + 高亮显示 + 分面过滤