[{"data":1,"prerenderedAt":25},["ShallowReactive",2],{"$f2oekg6x1ddr86":3},{"data":4},{"id":5,"slug":6,"title":7,"description":8,"excerpt":8,"category":9,"tags":10,"author":20,"image":21,"content":22,"publishedAt":23,"createdAt":24,"updatedAt":24},37,"yehangzhou\u002Fnotes\u002Fpostgresql-fulltext-search-complete-guide","PostgreSQL 全文检索完全指南：从 LIKE 到中文分词，一篇就够了，Nuxt + Prisma 完整实战","PostgreSQL 提供了从简单的 LIKE 到专业全文搜索的多种方案。本文涵盖 ILIKE、pg_trgm、tsvector、zhparser 中文分词以及混合搜索，并附有完整的 Nuxt + Prisma 实战代码，帮你找到最适合业务场景的搜索方案。","notes",[11,12,13,14,15,16,17,18,19],"PostgreSQL","全文检索","数据库","性能优化","中文分词","pg_trgm","zhparser","Nuxt","Prisma","yehangzhou",null,"> 从 1 万条到百万级数据，选对方案，搜索效率提升 10 倍\n\n## 概述\n\nPostgreSQL 作为功能最强大的开源关系型数据库，内置了丰富的全文检索能力。从最简单的 `LIKE` 模糊匹配，到专业的 `tsvector` 全文搜索，再到支持中文分词的 `zhparser` 扩展，PostgreSQL 覆盖了从个人博客到企业级应用的各类搜索场景。\n\n选择哪种方案？这取决于你的**数据量**、**语言类型**和**业务需求**。本文逐一拆解每种方案的特点、适用场景和完整实现代码，助你做出最适合的技术选型。\n\n---\n\n## 一、LIKE \u002F ILIKE：最简单的搜索\n\n> **适用场景**：数据量小于 1 万条，追求实现简单\n\n这是最基础的字符串匹配方式，不需要任何额外配置。\n\n**优点**：\n- 实现极其简单\n- 无需安装扩展\n- 支持中文（直接匹配字符）\n\n**缺点**：\n- 数据量大时全表扫描，性能差\n- 无法使用标准 B-Tree 索引（可用 `pg_trgm` 或 `GIN` 部分优化）\n- 不支持相关性排序\n\n### SQL 示例\n\n```sql\n-- 区分大小写\nSELECT * FROM posts WHERE title LIKE '%nuxt%';\n\n-- 不区分大小写（推荐）\nSELECT * FROM posts WHERE title ILIKE '%nuxt%';\n\n-- 多字段搜索\nSELECT * FROM posts \nWHERE title ILIKE '%nuxt%' \n   OR description ILIKE '%nuxt%' \n   OR content ILIKE '%nuxt%';\n```\n\n### Prisma 实现\n\n```typescript\nawait prisma.posts.findMany({\n  where: {\n    OR: [\n      { title: { contains: 'nuxt', mode: 'insensitive' } },\n      { description: { contains: 'nuxt', mode: 'insensitive' } },\n      { content: { contains: 'nuxt', mode: 'insensitive' } },\n    ]\n  }\n})\n```\n\n---\n\n## 二、pg_trgm：模糊匹配与拼写纠错\n\n> **适用场景**：用户可能存在拼写错误、拼音输入场景\n\n`pg_trgm` 是 PostgreSQL 的官方扩展，基于三元组（trigram）算法实现相似度匹配，非常适合处理用户输入不精确的场景。\n\n**优点**：\n- 支持模糊匹配，容错能力强\n- 可以按相似度排序\n- 适合拼音输入、拼写纠错\n\n**缺点**：\n- 需要单独安装扩展\n- GIN 索引占用空间较大\n\n### 安装与配置\n\n```sql\n-- 1. 安装扩展\nCREATE EXTENSION IF NOT EXISTS pg_trgm;\n\n-- 2. 创建索引（大幅提升性能）\nCREATE INDEX posts_title_trgm_idx ON posts USING GIN (title gin_trgm_ops);\nCREATE INDEX posts_content_trgm_idx ON posts USING GIN (content gin_trgm_ops);\n```\n\n### SQL 示例\n\n```sql\n-- 相似度搜索，按匹配程度排序\nSELECT \n  id, \n  title, \n  similarity(title, 'nuxt框架') as sim \nFROM posts \nWHERE title % 'nuxt框架'  -- % 表示相似度超过阈值\nORDER BY sim DESC \nLIMIT 20;\n```\n\n### Prisma 实现（使用原始 SQL）\n\n```typescript\nawait prisma.$queryRaw`\n  SELECT id, title, similarity(title, ${searchTerm}) as sim\n  FROM posts\n  WHERE title % ${searchTerm}\n  ORDER BY sim DESC\n  LIMIT 20\n`;\n```\n\n---\n\n## 三、PostgreSQL 全文搜索（tsvector）\n\n> **适用场景**：英文或多语言搜索，对搜索质量有要求\n\n这是 PostgreSQL 内置的专业全文搜索功能，通过将文本转换为 `tsvector` 向量，再与 `tsquery` 进行匹配，实现高效的关键词搜索。\n\n**优点**：\n- 性能优异，支持 GIN 索引加速\n- 内置排名算法（`ts_rank`）\n- 支持权重控制（标题权重 > 内容权重）\n- 无需额外扩展，开箱即用\n\n**缺点**：\n- 中文支持需额外配置分词\n- 配置相对复杂\n\n### 核心函数速查\n\n| 函数 | 作用 |\n| :--- | :--- |\n| `to_tsvector()` | 将文本转为 tsvector 向量 |\n| `to_tsquery()` | 将查询词转为 tsquery（支持 &、\\|、! 操作符） |\n| `plainto_tsquery()` | 将用户输入转为 tsquery，自动处理空格（推荐） |\n| `ts_rank()` | 计算相关性分数 |\n| `setweight()` | 设置权重等级（A\u002FB\u002FC\u002FD） |\n| `coalesce()` | 处理 NULL 值 |\n| `@@` | 匹配操作符 |\n\n### 安装与配置\n\n```sql\n-- 1. 添加搜索向量列\nALTER TABLE posts ADD COLUMN search_vector tsvector;\n\n-- 2. 填充数据（设置权重：标题 A，描述 B，内容 C）\nUPDATE posts SET search_vector = \n  setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||\n  setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||\n  setweight(to_tsvector('simple', coalesce(content, '')), 'C');\n\n-- 3. 创建 GIN 索引\nCREATE INDEX posts_search_idx ON posts USING GIN (search_vector);\n\n-- 4. （可选）使用触发器自动更新\nCREATE TRIGGER posts_search_vector_update\n  BEFORE INSERT OR UPDATE ON posts\n  FOR EACH ROW EXECUTE FUNCTION\n  tsvector_update_trigger(search_vector, 'pg_catalog.simple', title, description, content);\n```\n\n### SQL 示例\n\n```sql\n-- 基础搜索\nSELECT * FROM posts \nWHERE search_vector @@ to_tsquery('simple', 'nuxt & framework');\n\n-- 带排名排序\nSELECT \n  id, \n  title, \n  ts_rank(search_vector, to_tsquery('simple', 'nuxt')) as rank \nFROM posts \nWHERE search_vector @@ to_tsquery('simple', 'nuxt') \nORDER BY rank DESC;\n\n-- 动态权重搜索（推荐，灵活调整字段权重）\nSELECT \n  id, \n  title,\n  ts_rank(\n    setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||\n    setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||\n    setweight(to_tsvector('simple', coalesce(content, '')), 'C'),\n    plainto_tsquery('simple', '搜索关键词')\n  ) as rank\nFROM posts\nWHERE \n  setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||\n  setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||\n  setweight(to_tsvector('simple', coalesce(content, '')), 'C')\n  @@ plainto_tsquery('simple', '搜索关键词')\nORDER BY rank DESC;\n```\n\n### Prisma 实现\n\n```typescript\nawait prisma.$queryRaw`\n  SELECT \n    id, title, description, excerpt, \n    ts_rank(search_vector, plainto_tsquery('simple', ${searchTerm})) as rank\n  FROM posts\n  WHERE search_vector @@ plainto_tsquery('simple', ${searchTerm})\n  ORDER BY rank DESC\n  LIMIT 20\n`;\n```\n\n---\n\n## 四、zhparser：中文分词搜索\n\n> **适用场景**：中文项目，需要精准的中文分词能力\n\n`zhparser` 是 PostgreSQL 的中文分词扩展，基于 SCWS（简易中文分词系统），能够将中文句子切分成有意义的词语。\n\n**优点**：\n- 原生中文分词，搜索准确度高\n- 支持自定义词典\n\n**缺点**：\n- 需要超级用户权限安装\n- 部分云数据库（如 RDS）可能不支持\n- 安装配置相对复杂\n\n### 安装与配置\n\n```sql\n-- 1. 安装扩展（需要 superuser）\nCREATE EXTENSION IF NOT EXISTS zhparser;\n\n-- 2. 创建中文分词配置\nCREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);\n\n-- 3. 映射词性（n:名词, v:动词, a:形容词, i:成语, e:叹词, l:习语）\nALTER TEXT SEARCH CONFIGURATION chinese \n  ADD MAPPING FOR n,v,a,i,e,l WITH simple;\n\n-- 4. 创建索引\nCREATE INDEX posts_content_chinese_idx \n  ON posts USING GIN (to_tsvector('chinese', content));\n```\n\n### SQL 示例\n\n```sql\n-- 中文搜索\nSELECT * FROM posts \nWHERE to_tsvector('chinese', content) \n  @@ to_tsquery('chinese', 'nuxt 框架');\n```\n\n---\n\n## 五、混合搜索：兼顾精确与模糊（最佳实践）\n\n> **适用场景**：对搜索质量要求高，同时需要处理用户拼写错误\n\n将全文搜索的精确性和 `pg_trgm` 的容错能力结合，取长补短。\n\n**优点**：\n- 精确匹配 + 模糊匹配双重保障\n- 用户输入不精确时仍能返回结果\n\n**缺点**：\n- SQL 较复杂\n- 性能开销略大\n\n### SQL 示例\n\n```sql\nWITH fulltext AS (\n  SELECT \n    id, title, \n    1.0 as match_type,\n    ts_rank(search_vector, plainto_tsquery('simple', 'nuxt框架')) as score\n  FROM posts\n  WHERE search_vector @@ plainto_tsquery('simple', 'nuxt框架')\n  LIMIT 20\n),\nfuzzy AS (\n  SELECT \n    id, title, \n    0.5 as match_type,\n    similarity(title, 'nuxt框架') as score\n  FROM posts\n  WHERE title % 'nuxt框架'\n  LIMIT 20\n)\nSELECT * FROM fulltext\nUNION ALL\nSELECT * FROM fuzzy\nORDER BY score DESC, match_type DESC\nLIMIT 20;\n```\n\n---\n\n## 六、Nuxt + Prisma 完整实战\n\n### 6.1 搜索服务层\n\n```typescript\n\u002F\u002F server\u002Fservices\u002Fposts.service.ts\n\n\u002F\u002F ========================\n\u002F\u002F 全文搜索（推荐）\n\u002F\u002F ========================\nasync searchFulltext(keyword: string, page: number = 1, pageSize: number = 20) {\n  const skip = (page - 1) * pageSize;\n  \n  if (!keyword?.trim()) {\n    return { data: [], total: 0 };\n  }\n  \n  const results = await prisma.$queryRaw`\n    SELECT \n      id, slug, title, description, excerpt, \n      category, tags, author, image, \n      published_at, created_at, updated_at,\n      ts_rank(\n        setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||\n        setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||\n        setweight(to_tsvector('simple', coalesce(content, '')), 'C'),\n        plainto_tsquery('simple', ${keyword})\n      ) as rank\n    FROM posts\n    WHERE \n      setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||\n      setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||\n      setweight(to_tsvector('simple', coalesce(content, '')), 'C')\n      @@ plainto_tsquery('simple', ${keyword})\n    ORDER BY rank DESC\n    LIMIT ${pageSize}\n    OFFSET ${skip}\n  `;\n  \n  const countResult = await prisma.$queryRaw`\n    SELECT COUNT(*) as total\n    FROM posts\n    WHERE \n      setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||\n      setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||\n      setweight(to_tsvector('simple', coalesce(content, '')), 'C')\n      @@ plainto_tsquery('simple', ${keyword})\n  `;\n  \n  return { \n    data: results, \n    total: Number(countResult[0].total) \n  };\n}\n\n\u002F\u002F ========================\n\u002F\u002F 简单搜索（ILIKE 方式）\n\u002F\u002F ========================\nasync searchSimple(keyword: string, page: number = 1, pageSize: number = 20) {\n  const skip = (page - 1) * pageSize;\n  \n  const [data, total] = await Promise.all([\n    prisma.posts.findMany({\n      where: {\n        OR: [\n          { title: { contains: keyword, mode: 'insensitive' } },\n          { description: { contains: keyword, mode: 'insensitive' } },\n          { excerpt: { contains: keyword, mode: 'insensitive' } },\n          { content: { contains: keyword, mode: 'insensitive' } },\n          { author: { contains: keyword, mode: 'insensitive' } },\n          { category: { contains: keyword, mode: 'insensitive' } },\n        ]\n      },\n      skip,\n      take: pageSize,\n      orderBy: { createdAt: 'desc' },\n      select: {\n        id: true,\n        slug: true,\n        title: true,\n        description: true,\n        excerpt: true,\n        category: true,\n        tags: true,\n        author: true,\n        image: true,\n        publishedAt: true,\n        createdAt: true\n      }\n    }),\n    prisma.posts.count({\n      where: {\n        OR: [\n          { title: { contains: keyword, mode: 'insensitive' } },\n          { description: { contains: keyword, mode: 'insensitive' } },\n          { excerpt: { contains: keyword, mode: 'insensitive' } },\n          { content: { contains: keyword, mode: 'insensitive' } },\n          { author: { contains: keyword, mode: 'insensitive' } },\n          { category: { contains: keyword, mode: 'insensitive' } },\n        ]\n      }\n    })\n  ]);\n  \n  return { \n    data, \n    total,\n    page,\n    pageSize,\n    totalPages: Math.ceil(total \u002F pageSize)\n  };\n}\n```\n\n### 6.2 API 端点\n\n```typescript\n\u002F\u002F server\u002Fapi\u002Fsearch\u002Findex.get.ts\nimport { postsService } from '..\u002F..\u002Fservices\u002Fposts.service'\n\nexport default defineEventHandler(async (event) => {\n  const query = getQuery(event)\n  const keyword = query.q as string\n  const page = parseInt(query.page as string) || 1\n  const pageSize = parseInt(query.pageSize as string) || 20\n  const useFulltext = query.fulltext === 'true'\n  \n  if (!keyword?.trim()) {\n    return { data: [], total: 0 }\n  }\n  \n  try {\n    if (useFulltext) {\n      return await postsService.searchFulltext(keyword, page, pageSize)\n    }\n    return await postsService.searchSimple(keyword, page, pageSize)\n  } catch (error) {\n    console.error('搜索失败:', error)\n    throw createError({ \n      status: 500, \n      message: '搜索失败，请稍后重试' \n    })\n  }\n})\n```\n\n### 6.3 前端搜索组件\n\n```vue\n\u003C!-- components\u002FSearchBar.vue -->\n\u003Ctemplate>\n  \u003Cdiv class=\"search-container\">\n    \u003Cdiv class=\"search-input-wrapper\">\n      \u003Cinput \n        v-model=\"keyword\" \n        type=\"text\" \n        placeholder=\"搜索文章...\"\n        @input=\"handleSearch\"\n        @keydown.enter=\"handleSearch\"\n        class=\"search-input\"\n      \u002F>\n      \u003Cbutton @click=\"handleSearch\" class=\"search-btn\">搜索\u003C\u002Fbutton>\n    \u003C\u002Fdiv>\n    \n    \u003C!-- 加载状态 -->\n    \u003Cdiv v-if=\"loading\" class=\"loading\">\n      \u003Cspan>搜索中...\u003C\u002Fspan>\n    \u003C\u002Fdiv>\n    \n    \u003C!-- 空状态 -->\n    \u003Cdiv v-else-if=\"results.length === 0 && keyword\" class=\"empty\">\n      未找到与「{{ keyword }}」相关的文章\n    \u003C\u002Fdiv>\n    \n    \u003C!-- 搜索结果 -->\n    \u003Cdiv v-else-if=\"results.length > 0\" class=\"results\">\n      \u003Cdiv v-for=\"post in results\" :key=\"post.id\" class=\"result-item\">\n        \u003CNuxtLink :to=\"`\u002Fposts\u002F${post.slug}`\" class=\"result-link\">\n          \u003Ch3 class=\"result-title\">{{ post.title }}\u003C\u002Fh3>\n          \u003Cp class=\"result-excerpt\">{{ post.excerpt || post.description }}\u003C\u002Fp>\n          \u003Cdiv class=\"result-meta\">\n            \u003Cspan>{{ post.category }}\u003C\u002Fspan>\n            \u003Cspan>{{ post.author }}\u003C\u002Fspan>\n            \u003Cspan>{{ new Date(post.createdAt).toLocaleDateString() }}\u003C\u002Fspan>\n          \u003C\u002Fdiv>\n        \u003C\u002FNuxtLink>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup lang=\"ts\">\nimport { ref, debounce } from 'vue'\n\nconst keyword = ref('')\nconst results = ref([])\nconst loading = ref(false)\n\n\u002F\u002F 防抖搜索，300ms 延迟\nconst handleSearch = debounce(async () => {\n  if (!keyword.value.trim()) {\n    results.value = []\n    return\n  }\n  \n  loading.value = true\n  try {\n    const { data } = await $fetch(`\u002Fapi\u002Fsearch?q=${encodeURIComponent(keyword.value)}&fulltext=true`)\n    results.value = data || []\n  } catch (error) {\n    console.error('搜索失败:', error)\n  } finally {\n    loading.value = false\n  }\n}, 300)\n\u003C\u002Fscript>\n\n\u003Cstyle scoped>\n.search-container {\n  max-width: 800px;\n  margin: 0 auto;\n  padding: 20px;\n}\n\n.search-input-wrapper {\n  display: flex;\n  gap: 12px;\n  margin-bottom: 20px;\n}\n\n.search-input {\n  flex: 1;\n  padding: 12px 16px;\n  border: 2px solid #e0e0e0;\n  border-radius: 8px;\n  font-size: 16px;\n  transition: border-color 0.2s;\n}\n\n.search-input:focus {\n  outline: none;\n  border-color: #4a9eff;\n}\n\n.search-btn {\n  padding: 12px 24px;\n  background: #4a9eff;\n  color: white;\n  border: none;\n  border-radius: 8px;\n  font-size: 16px;\n  cursor: pointer;\n  transition: background 0.2s;\n}\n\n.search-btn:hover {\n  background: #3a8aef;\n}\n\n.results {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.result-item {\n  padding: 16px;\n  border: 1px solid #eee;\n  border-radius: 8px;\n  transition: box-shadow 0.2s;\n}\n\n.result-item:hover {\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);\n}\n\n.result-link {\n  text-decoration: none;\n  color: inherit;\n}\n\n.result-title {\n  margin: 0 0 8px 0;\n  color: #1a1a1a;\n}\n\n.result-excerpt {\n  margin: 0 0 8px 0;\n  color: #666;\n  font-size: 14px;\n  line-height: 1.6;\n}\n\n.result-meta {\n  display: flex;\n  gap: 16px;\n  font-size: 12px;\n  color: #999;\n}\n\n.loading,\n.empty {\n  text-align: center;\n  padding: 40px;\n  color: #999;\n}\n\u003C\u002Fstyle>\n```\n\n---\n\n## 七、性能优化建议\n\n| 优化策略 | 具体做法 |\n| :--- | :--- |\n| **索引** | 为搜索字段创建 GIN 索引（tsvector 或 pg_trgm） |\n| **限制结果数** | 使用 `LIMIT` 限制返回数量，避免大数据量传输 |\n| **分页** | 使用 `OFFSET + LIMIT` 或游标分页 |\n| **缓存** | 对热门搜索词使用 Redis 缓存，减少数据库压力 |\n| **搜索权重** | 标题权重 > 描述权重 > 内容权重，提升相关性 |\n| **索引覆盖** | 只搜索必要字段，减少 I\u002FO 开销 |\n| **VACUUM** | 定期执行 `VACUUM ANALYZE`，更新统计信息 |\n\n---\n\n## 八、各方案对比一览\n\n| 方案 | 数据量 | 中文支持 | 性能 | 复杂度 | 适用场景 |\n| :--- | :--- | :--- | :--- | :--- | :--- |\n| **ILIKE** | \u003C 1 万 | ✅ | ⭐⭐ | ⭐ | 简单搜索、小数据量 |\n| **pg_trgm** | \u003C 10 万 | ✅ | ⭐⭐⭐ | ⭐⭐ | 拼写错误、拼音搜索 |\n| **全文搜索 (tsvector)** | \u003C 100 万 | ⚠️ 需配置 | ⭐⭐⭐⭐ | ⭐⭐⭐ | 英文\u002F多语言搜索 |\n| **中文分词 (zhparser)** | \u003C 100 万 | ✅ 原生 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 中文搜索 |\n| **Elasticsearch** | 百万级以上 | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 大规模搜索引擎 |\n\n---\n\n## 九、总结与选型建议\n\n根据你的项目情况，选择合适的方案：\n\n| 你的场景 | 推荐方案 |\n| :--- | :--- |\n| 个人博客、小型项目 | **ILIKE + Prisma contains**，简单够用 |\n| 一般项目、英文为主 | **PostgreSQL 全文搜索 (tsvector)**，性能和功能均衡 |\n| 中文项目、数据量不大 | **ILIKE 或 pg_trgm**，无需复杂配置 |\n| 中文项目、数据量大 | **中文分词 (zhparser) + 全文搜索**，精准高效 |\n| 大规模搜索（百万级+） | **Elasticsearch \u002F Meilisearch**，专业搜索引擎 |\n\n### 我的个人建议\n\n对于大多数技术博客、内容型网站，**PostgreSQL 全文搜索（tsvector）** 是性价比最高的选择。它不需要额外引入 Elasticsearch 这样的重型组件，就能提供 80% 以上的搜索体验。配合 `pg_trgm` 做拼写容错，完全可以满足日常需求。\n\n如果未来数据量增长到百万级以上，再考虑迁移到 Elasticsearch 也不迟——PostgreSQL 作为数据主库，配合 ES 做搜索增强，是很多大厂的标准架构。\n\n---\n","2026-08-02T06:19:02.000Z","2026-08-01T23:21:49.097Z",1785632043155]