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