全栈独立产品搜索体验复盘:从 LIKE 到 Elasticsearch 的升级路径

全栈独立产品搜索体验复盘:从 LIKE 到 Elasticsearch 的升级路径

一、搜索体验的阶段性困境:LIKE 不等于搜索

独立产品最早期的搜索通常都用 SQL LIKE 实现:SELECT * FROM articles WHERE title LIKE '%关键词%' OR content LIKE '%关键词%'。在产品初期(数据量 < 1 万条、日搜索量 < 100 次),LIKE 查询完全够用------MySQL 在 title 列建立全文索引(FULLTEXT INDEX)后,1 万条数据内的查询延迟在 50ms 以内。没有人会因为搜索慢而流失。

但数据量和搜索量在跨过临界点后,LIKE 方案的体验会快速恶化:

  • 性能临界点 :10 万条数据以上,单次 LIKE '%keyword%' 的查询耗时 500ms~2s。这是因为前缀通配符 %keyword% 无法使用 B-Tree 索引,MySQL 必须做全表扫描。
  • 相关性临界点LIKE 返回的结果是"包含关键词的所有记录",按数据写入时间排序。用户搜索"React 性能优化",返回的第一条是 2019 年的"React 入门教程"(因为文中恰好提到了"性能优化"一词),而真正相关的 2024 年"React 18 并发特性下的性能优化策略"排在第 17 条。
  • 功能临界点 :用户期望的分词搜索(搜索"前端"能匹配"前端开发")、拼音搜索(输入"react"能匹配"React")、模糊匹配("prefomance"纠错为"performance")、高亮显示------这些在 LIKE 方案下全部不可实现。

二、阶段一:LIKE 的快速起步与死亡螺旋

2.1 LIKE 方案的实现

第一阶段用 LIKE 实现搜索的核心代码非常简短------这也是为什么它总是被选择作为起点:

sql 复制代码
-- 基础搜索
SELECT id, title, content, created_at
FROM articles
WHERE title LIKE CONCAT('%', ?, '%')
   OR content LIKE CONCAT('%', ?, '%')
ORDER BY created_at DESC
LIMIT 20;

2.2 LIKE 方案的三大硬伤

随着数据量和搜索频率的增长,三个硬伤逐一显现:

  1. 全表扫描的不可扩展性LIKE '%keyword%' 的第一个 % 是最致命的。它告诉 MySQL"这个关键词可能出现在字段的任何位置",MySQL 无法使用 B-Tree 索引,只能逐行扫描。10 万条数据 ≈ 10 万次字符串比较,这是不可扩展的。

  2. 结果排序的无意义性:按创建时间排序意味着一个 3 年前的文章如果恰好包含关键词,会排在 3 天前的高质量文章前面。《MySQL 入门》里提到了一句"数据安全",就能排在专讲"数据安全最佳实践"的文章前面。这是糟糕的相关性。

  3. 无分词的语义断裂 :搜索"前端开发"时,LIKE 只会匹配包含连续字符串"前端开发"的记录,不会匹配"前端"+"开发"两个独立词分别出现在不同位置的情况。中分词的缺失让搜索精度极低。

三、阶段二:MySQL FULLTEXT 的过渡期

3.1 FULLTEXT INDEX 与 ngram 分词

MySQL 5.7+ 支持 FULLTEXT INDEX 和 ngram 分词器,可以解决 LIKE 的性能问题:

sql 复制代码
-- 添加全文索引
ALTER TABLE articles ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;

-- 全文搜索(BOOLEAN MODE)
SELECT 
  id, title, content, created_at,
  MATCH(title, content) AGAINST(? IN BOOLEAN MODE) AS relevance
FROM articles
WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE)
ORDER BY relevance DESC
LIMIT 20;

FULLTEXT INDEX 将文本切分为 n-gram(2字词组),并建立倒排索引。查询时不需要全表扫描,而是走索引查找。10 万条数据的全文搜索从 LIKE 的 500ms 降到 50ms 以内。

3.2 FULLTEXT 方案的局限

但 FULLTEXT INDEX 在以下场景中仍然不够用:

  • 相关性打分粗糙MATCH...AGAINST 的评分算法基于词频(TF)和逆文档频率(IDF),但不考虑字段权重(标题匹配应该比正文匹配更相关)、词位置(关键词出现在开头比出现在末尾更相关)。
  • 缺少高亮:用户期望在搜索结果中看到关键词被标黄高亮,FULLTEXT 本身不提供这个能力。
  • 无聚合统计:搜索结果无法按分类、年份、作者聚合统计。
  • 无拼音/纠错:不支持拼音搜索和拼写纠错。

这些是搜索引擎(Elasticsearch)才能解决的需求。

四、阶段三:Elasticsearch 的全面升级

4.1 索引结构的迁移

从 MySQL 迁移到 ES 的关键是数据同步策略。对于独立产品来说,不需要 Canal + Kafka 的 CDC(Change Data Capture)方案。更务实的做法是应用层双写:

typescript 复制代码
/**
 * 搜索服务:应用层双写 + Elasticsearch 查询
 * 写操作同时写入 MySQL 和 ES,读操作走 ES
 */
interface SearchDocument {
  id: string;
  title: string;
  content: string;
  summary: string;
  category: string;
  tags: string[];
  author: string;
  createdAt: number;
  updatedAt: number;
  viewCount: number;
}

class SearchService {
  private esHost: string;
  private esIndex: string;

  constructor(host: string, index: string) {
    this.esHost = host;
    this.esIndex = index;
  }

  /**
   * 创建/更新文档
   * 在应用层同时写入 MySQL 和 ES
   */
  async indexDocument(doc: SearchDocument): Promise<boolean> {
    try {
      // 1. 先写 MySQL(主数据源)
      await this.saveToMySQL(doc);

      // 2. 再同步到 ES(允许失败,后续补同步)
      const response = await fetch(
        `${this.esHost}/${this.esIndex}/_doc/${doc.id}`,
        {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(doc),
        }
      );

      if (!response.ok) {
        console.error(`[ES] 索引文档失败: ${response.status}`);
        // 标记为待同步
        await this.markForResync(doc.id);
        // ES 写入失败不影响主流程
      }

      return true;
    } catch (err) {
      console.error('[ES] 索引文档异常:', err);
      await this.markForResync(doc.id);
      return true; // 主流程(MySQL)已成功
    }
  }

  /**
   * 搜索文档
   * 使用 ES 的多字段匹配 + 高亮 + 聚合
   */
  async search(query: string, options: SearchOptions): Promise<SearchResult> {
    try {
      const esQuery = this.buildESQuery(query, options);

      const response = await fetch(`${this.esHost}/${this.esIndex}/_search`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(esQuery),
      });

      if (!response.ok) {
        throw new Error(`ES search error: ${response.status}`);
      }

      const data = await response.json();
      return this.parseESResponse(data);
    } catch (err) {
      console.error('[ES] 搜索失败,降级到 MySQL 搜索:', err);
      // 降级:走 MySQL FULLTEXT 或 LIKE 兜底
      return this.fallbackSearch(query, options);
    }
  }

  /**
   * 构建 ES 查询 DSL
   */
  private buildESQuery(query: string, options: SearchOptions) {
    return {
      from: (options.page - 1) * options.pageSize,
      size: options.pageSize,
      query: {
        bool: {
          should: [
            // 标题匹配权重最高
            {
              match: {
                title: {
                  query,
                  boost: 3.0,
                  fuzziness: 'AUTO',
                  operator: 'and',
                },
              },
            },
            // 摘要匹配权重中
            {
              match: {
                summary: {
                  query,
                  boost: 1.5,
                },
              },
            },
            // 正文匹配权重低
            {
              match: {
                content: {
                  query,
                  boost: 1.0,
                },
              },
            },
            // 标签精确匹配
            {
              terms: {
                tags: [query],
              },
            },
          ],
          minimum_should_match: 1,
          filter: this.buildFilters(options),
        },
      },
      highlight: {
        fields: {
          title: { number_of_fragments: 0 },
          summary: {
            fragment_size: 150,
            number_of_fragments: 1,
          },
          content: {
            fragment_size: 200,
            number_of_fragments: 1,
          },
        },
        pre_tags: ['<mark>'],
        post_tags: ['</mark>'],
      },
      aggs: {
        categories: {
          terms: { field: 'category', size: 20 },
        },
      },
      sort: options.sortBy === 'relevance'
        ? [{ _score: 'desc' }]
        : [{ createdAt: 'desc' }],
    };
  }

  /**
   * 构建过滤条件
   */
  private buildFilters(options: SearchOptions): Record<string, unknown>[] {
    const filters: Record<string, unknown>[] = [];

    if (options.category) {
      filters.push({ term: { category: options.category } });
    }
    if (options.tags && options.tags.length > 0) {
      filters.push({ terms: { tags: options.tags } });
    }

    return filters;
  }

  /**
   * 解析 ES 返回结果
   */
  private parseESResponse(data: any): SearchResult {
    return {
      total: data.hits.total.value,
      items: data.hits.hits.map((hit: any) => ({
        id: hit._id,
        score: hit._score,
        source: hit._source,
        highlights: {
          title: hit.highlight?.title?.[0],
          summary: hit.highlight?.summary?.[0],
          content: hit.highlight?.content?.[0],
        },
      })),
      aggregations: {
        categories: data.aggregations?.categories?.buckets?.map(
          (b: any) => ({ key: b.key, count: b.doc_count })
        ) ?? [],
      },
    };
  }

  /**
   * ES 不可用时的降级搜索
   */
  private async fallbackSearch(
    query: string,
    options: SearchOptions
  ): Promise<SearchResult> {
    // 降级:走 MySQL 全文搜索
    const sql = `
      SELECT id, title, content, category, tags, created_at
      FROM articles
      WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE)
      ORDER BY created_at DESC
      LIMIT ?, ?
    `;

    // 返回简化版结果(无高亮、无聚合)
    return { total: 0, items: [], aggregations: { categories: [] } };
  }

  /**
   * 补同步:将标记为待同步的文档重新索引
   */
  async resyncStaleDocuments(): Promise<void> {
    const staleIds = await this.getStaleDocumentIds();
    for (const id of staleIds) {
      const doc = await this.getDocumentFromMySQL(id);
      if (doc) {
        await this.indexDocument(doc);
      }
    }
  }

  // Stub 方法
  private async saveToMySQL(_doc: SearchDocument): Promise<void> {}
  private async markForResync(_id: string): Promise<void> {}
  private async getStaleDocumentIds(): Promise<string[]> { return []; }
  private async getDocumentFromMySQL(_id: string): Promise<SearchDocument | null> { return null; }
}

interface SearchOptions {
  query: string;
  page: number;
  pageSize: number;
  category?: string;
  tags?: string[];
  sortBy: 'relevance' | 'date';
}

interface SearchResult {
  total: number;
  items: SearchResultItem[];
  aggregations: {
    categories: { key: string; count: number }[];
  };
}

interface SearchResultItem {
  id: string;
  score: number;
  source: SearchDocument;
  highlights: {
    title?: string;
    summary?: string;
    content?: string;
  };
}

4.2 前端搜索 UI 的配套升级

ES 的搜索结果包含了前端可以消费的丰富数据:

  • 高亮片段<mark>关键词</mark> 直接渲染在搜索结果中。
  • 聚合数据:按分类聚合的结果数,在搜索框下方以标签形式展示("前端开发(42)"、"后端开发(18)")。
  • 相关性分数 :用于决定搜索结果是否展示"无相关结果"的提示(score 低于阈值时)。

4.3 ES 方案的成本控制

独立产品引入 ES 最常见的顾虑是"太重了"------ES 本身需要 512MB~2GB 内存,对于一个小服务器来说是巨大的开销。折中方案:

  • 使用 ES 云服务(如 Elastic Cloud、阿里云 ES):按量付费,最低配置每月约 100~200 元。
  • 使用 Meilisearch 替代:Meilisearch 是一个轻量级的开源搜索引擎,Rust 编写,内存占用 100~200MB,内置了中文分词、容错、高亮等功能,部署成本远低于 ES。对于独立产品来说,Meilisearch 往往是比 ES 更务实的选择。

五、总结

搜索体验的升级是一个"三段跳"的过程:

第一阶段(LIKE):适用于数据量 < 1 万条的场景。实现简单,一个 SQL 语句搞定。超出临界点后性能和相关性迅速恶化。

第二阶段(MySQL FULLTEXT):用 ngram 分词 + 倒排索引解决 LIKE 的全表扫描性能问题。10 万条数据的查询从 500ms 降到 50ms。但相关性打分粗糙,缺少高亮和聚合。

第三阶段(Elasticsearch/Meilisearch):多字段权重打分(标题 3x、摘要 1.5x、正文 1x)、IK 中文分词、高亮、聚合、拼音搜索、拼写纠错。搜索结果的相关性和丰富度质变。

落地建议:如果数据量还没到 5 万条,先用 MySQL FULLTEXT 兜底。当搜索成为核心功能时(日搜索量 > 500 次),优先考虑 Meilisearch(部署成本低、内置中文支持),而不是直接上 ES。

相关推荐
IT_陈寒3 小时前
被Java的final坑惨了,这些细节你可能也忽略了
前端·人工智能·后端
星辰AI3 小时前
全栈项目代码质量治理复盘:ESLint+Prettier+Husky的渐进式引入策略
人工智能·ai·语言模型
pangtout3 小时前
70年底蕴老国企,如何跑出AI新速度?
人工智能·erp·智能体·用友yonsuite
继续商行3 小时前
Elasticsearch 查询性能优化:从 8 秒聚合到 120ms 的全链路调优复盘
人工智能
大家的林语冰3 小时前
✌️ 字节太牛了,爽用 Trae Work 取代小龙虾,AI 自动设计封面和数据可视化~
人工智能·ai编程·trae
咖啡星人k4 小时前
2026 文生视频:让 AI 把文字变成电影,MonkeyCode 免费上手
人工智能·深度学习·机器学习·计算机视觉·自然语言处理
ZGIAI4 小时前
ZGI 父子分块:连接检索片段与完整上下文
人工智能·架构
ZGIAI4 小时前
ZGI 文件解析:知识入库前的质量门
人工智能·架构
算AI4 小时前
基于LLM的无人机仿真测试:新方法竞赛夺佳绩
人工智能·深度学习·算法·机器学习·ai
小白说大模型4 小时前
AI驱动的个性化学习路径:知识图谱与知识点关联的存储与推理
大数据·人工智能·学习·mysql·机器学习·prompt·知识图谱