GoWindGoWind
Home
Framework
GoWind Toolkit
  • Open Source

    • GoWind Admin (Admin Platform)
    • GoWind CMS (Content Management)
    • GoWind UBA (Behavior Analytics)
    • GoWind Shop (E-commerce)
    • GoWind Kanban (Kanban Boards)
    • GoWind Ledger (Personal Finance)
    • GoWind ShortPlay (Short Drama)
    • GoWind Ops (Operations Platform)
    • RushWind (Rust Ecosystem)
  • Open Core (Community + Enterprise)

    • GoWind OA (Office Collaboration)
    • GoWind ERP (Procure-to-Pay)
    • GoWind WMS (Warehousing)
  • Commercial

    • GoWind IM (Instant Messaging)
    • GoWind IoT (IoT Platform)
    • GoWind Edu (AI Education)
    • GoWind MES (Manufacturing Execution)
    • GoWind CDC (Data Pipeline)
    • GoWind SDP (Zero-Trust Gateway)
    • GoWind Quant (Quant Research)
    • GoWind Trade Radar (Export Sourcing)
    • GoWind Game Radar (Game Greenlight)
  • 简体中文
  • English
GitHub
Home
Framework
GoWind Toolkit
  • Open Source

    • GoWind Admin (Admin Platform)
    • GoWind CMS (Content Management)
    • GoWind UBA (Behavior Analytics)
    • GoWind Shop (E-commerce)
    • GoWind Kanban (Kanban Boards)
    • GoWind Ledger (Personal Finance)
    • GoWind ShortPlay (Short Drama)
    • GoWind Ops (Operations Platform)
    • RushWind (Rust Ecosystem)
  • Open Core (Community + Enterprise)

    • GoWind OA (Office Collaboration)
    • GoWind ERP (Procure-to-Pay)
    • GoWind WMS (Warehousing)
  • Commercial

    • GoWind IM (Instant Messaging)
    • GoWind IoT (IoT Platform)
    • GoWind Edu (AI Education)
    • GoWind MES (Manufacturing Execution)
    • GoWind CDC (Data Pipeline)
    • GoWind SDP (Zero-Trust Gateway)
    • GoWind Quant (Quant Research)
    • GoWind Trade Radar (Export Sourcing)
    • GoWind Game Radar (Game Greenlight)
  • 简体中文
  • English
GitHub
  • Introduction

    • GoWind CMS Product Introduction
    • GoWind CMS Installation Guide
  • Backend

    • CMS Backend Architecture Overview
    • CMS Backend Modules Overview
    • CMS Protobuf API Definitions
    • CMS Configuration and Deployment Guide
    • Backend Extension Mechanisms Guide
  • Frontend

    • CMS Frontend Architecture
    • CMS Frontend Modules Overview
  • Getting Started

    • Adding a Content Type, Full Stack in Practice
    • Multi-Client API Client Code Generation Tutorial
  • Core Tutorials

    • Content Multilingual Translation Tutorial
    • Content Publishing Workflow Tutorial
    • Section Editor in Practice
    • Headless API Multi-Client Integration in Practice
    • Frontend Application Development in Practice
  • Intermediate Tutorials

    • Multi-Site Management in Practice
    • Media Asset Management in Practice
    • Comment System Tutorial
    • Event Bus Architecture in Practice
    • Lua Script Extensions in Practice
  • Advanced Topics

    • Permission System in Practice
    • Dual-Client Login Security in Practice
    • Encryption and Security Toolkit Tutorial
    • Full-Text Search in Practice
    • Task Scheduling in Practice
    • Real-Time Message Push (SSE) in Practice
    • Performance Monitoring in Practice
    • Dictionary Management System Tutorial
  • Full-Stack Guides

    • Full-Stack Integration in Practice
    • Three-Service Deployment Tutorial

Full-Text Search in Practice

GoWind CMS integrates the OpenSearch search engine to provide the content platform with high-performance full-text search capabilities. This tutorial explains the search architecture design, index management, the search API, and the implementation of frontend search features.

Prerequisites

  • You have read CMS Backend Architecture Overview
  • The OpenSearch service is running locally (default port 9200)

1. Search Architecture

1.1 Why Full-Text Search Is Needed

ScenarioDatabase LIKE QueryOpenSearch Full-Text Search
Keyword searchSlow (full table scan)Fast (inverted index)
Chinese tokenizationNot supportedSupported (IK analyzer)
Relevance rankingNot supportedTF-IDF / BM25
Fuzzy matchingOnly LIKE %keyword%Spelling correction supported
HighlightingNot supportedSearch result highlighting supported
Aggregation statisticsSlowFast (Aggregation)

1.2 Search Architecture

1.3 Index Design

IndexDocument TypeDescription
cms_postsPostPost full-text index
cms_pagesPagePage index
cms_categoriesCategoryCategory index
cms_tagsTagTag index

2. Index Mappings

2.1 Post Index Mapping

{
  "mappings": {
    "properties": {
      "id": { "type": "integer" },
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "summary": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "content": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "slug": { "type": "keyword" },
      "status": { "type": "integer" },
      "categories": {
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "keyword" }
        }
      },
      "tags": {
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "keyword" }
        }
      },
      "author": { "type": "keyword" },
      "language_code": { "type": "keyword" },
      "created_at": { "type": "date" },
      "updated_at": { "type": "date" }
    }
  },
  "settings": {
    "analysis": {
      "analyzer": {
        "ik_max_word": { "type": "ik_max_word" },
        "ik_smart": { "type": "ik_smart" }
      }
    }
  }
}

2.2 Chinese Tokenization Configuration

OpenSearch requires the IK Analysis plugin for Chinese tokenization:

# 安装 IK 分词器插件
./bin/opensearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v2.12.0/elasticsearch-analysis-ik-2.12.0.zip

# 重启 OpenSearch
docker-compose restart opensearch

3. Index Synchronization

3.1 Real-Time Synchronization

Content changes are automatically synchronized to OpenSearch:

// app/core/service/internal/service/post_service.go
func (s *PostService) Create(ctx context.Context, req *contentV1.CreatePostRequest) (*contentV1.Post, error) {
    // 1. 写入数据库
    post, err := s.postRepo.Create(ctx, req)
    if err != nil {
        return nil, err
    }

    // 2. 同步到 OpenSearch(异步任务)
    s.task.Enqueue(ctx, task.TypeIndexPost, &task.IndexPostPayload{
        PostId: post.Id,
        Action: "create",
    })

    return post, nil
}

func (s *PostService) Update(ctx context.Context, req *contentV1.UpdatePostRequest) (*contentV1.Post, error) {
    post, err := s.postRepo.Update(ctx, req)
    if err != nil {
        return nil, err
    }

    // 同步更新索引
    s.task.Enqueue(ctx, task.TypeIndexPost, &task.IndexPostPayload{
        PostId: post.Id,
        Action: "update",
    })

    return post, nil
}

func (s *PostService) Delete(ctx context.Context, req *contentV1.DeletePostRequest) (*emptypb.Empty, error) {
    if err := s.postRepo.Delete(ctx, req); err != nil {
        return nil, err
    }

    // 删除索引
    s.task.Enqueue(ctx, task.TypeIndexPost, &task.IndexPostPayload{
        PostId: req.GetId(),
        Action: "delete",
    })

    return &emptypb.Empty{}, nil
}

3.2 Index Task Handler

// app/core/service/internal/server/asynq_server.go
func HandleIndexPost(ctx context.Context, t *asynq.Task) error {
    var payload task.IndexPostPayload
    if err := json.Unmarshal(t.Payload(), &payload); err != nil {
        return err
    }

    switch payload.Action {
    case "create", "update":
        // 从数据库获取最新数据
        post, err := postRepo.Get(ctx, payload.PostId)
        if err != nil {
            return err
        }
        // 索引到 OpenSearch
        return searchClient.Index(ctx, "cms_posts", post)

    case "delete":
        // 从 OpenSearch 删除
        return searchClient.Delete(ctx, "cms_posts", fmt.Sprint(payload.PostId))
    }

    return nil
}

3.3 Full Index Rebuild

func (s *PostService) RebuildIndex(ctx context.Context) error {
    // 1. 删除旧索引
    s.searchClient.DeleteIndex(ctx, "cms_posts")

    // 2. 创建新索引
    s.searchClient.CreateIndex(ctx, "cms_posts", postMapping)

    // 3. 分批导入数据
    pageSize := 100
    page := 1
    for {
        posts, err := s.postRepo.List(ctx, &pagination.PagingRequest{
            Page: uint32(page), PageSize: uint32(pageSize),
        })
        if err != nil {
            return err
        }

        // 批量索引
        s.searchClient.BulkIndex(ctx, "cms_posts", posts.Items)

        if len(posts.Items) < pageSize {
            break
        }
        page++
    }

    return nil
}

4. Search API

4.1 App API Search Endpoint

Frontend search is implemented through the search parameter of the Post List endpoint:

GET /app/v1/posts?search=GoWind&locale=zh-CN&page=1&pageSize=10

4.2 Advanced Search Endpoint

A custom search endpoint can be added:

// app/service/v1/i_search.proto
service SearchService {
  rpc Search(SearchRequest) returns (SearchResponse) {
    option (google.api.http) = { get: "/app/v1/search" };
  }
}

message SearchRequest {
  string query = 1;                          // 搜索关键词
  optional string locale = 2;                // 语言
  optional uint32 category_id = 3;           // 分类过滤
  repeated string tag_names = 4;             // 标签过滤
  optional int32 page = 10;
  optional int32 page_size = 11;
  optional string sort_by = 12;              // 排序:relevance / date
}

message SearchResponse {
  repeated SearchHit hits = 1;
  uint64 total = 2;
  optional uint64 took_ms = 3;               // 耗时(毫秒)
}

message SearchHit {
  string type = 1;                           // 结果类型:post / page
  uint32 id = 2;
  string title = 3;
  string summary = 4;
  string url = 5;
  optional string highlight = 6;             // 高亮片段
  optional float score = 7;                  // 相关度分数
}

4.3 Search Query Implementation

// app/core/service/internal/service/search_service.go
func (s *SearchService) Search(ctx context.Context, req *searchV1.SearchRequest) (*searchV1.SearchResponse, error) {
    query := opensearch.NewBoolQuery()

    // 全文搜索
    if req.GetQuery() != "" {
        multiMatch := opensearch.NewMultiMatchQuery(req.GetQuery()).
            Field("title", 3).      // 标题权重 3
            Field("summary", 2).    // 摘要权重 2
            Field("content", 1).    // 正文权重 1
            Type("best_fields").
            Analyzer("ik_smart")
        query.Must(multiMatch)
    }

    // 语言过滤
    if locale := req.GetLocale(); locale != "" {
        query.Filter(opensearch.NewTermQuery("language_code", locale))
    }

    // 分类过滤
    if categoryId := req.GetCategoryId(); categoryId > 0 {
        query.Filter(opensearch.NewTermQuery("categories.id", categoryId))
    }

    // 标签过滤
    for _, tagName := range req.GetTagNames() {
        query.Filter(opensearch.NewTermQuery("tags.name", tagName))
    }

    // 只搜索已发布内容
    query.Filter(opensearch.NewTermQuery("status", 1))

    // 构建搜索请求
    searchSource := opensearch.NewSearchSource().
        Query(query).
        From(int((req.GetPage() - 1) * req.GetPageSize())).
        Size(int(req.GetPageSize())).
        Highlight(opensearch.NewHighlight().
            Field("title").
            Field("summary").
            PreTags("<em>").
            PostTags("</em>"))

    // 排序
    if req.GetSortBy() == "date" {
        searchSource.Sort("created_at", false)
    } else {
        searchSource.Sort("_score", false)
    }

    // 执行搜索
    result, err := s.searchClient.Search(ctx, "cms_posts", searchSource)
    if err != nil {
        return nil, err
    }

    // 构建响应
    hits := make([]*searchV1.SearchHit, len(result.Hits))
    for i, hit := range result.Hits {
        hits[i] = &searchV1.SearchHit{
            Type:      "post",
            Id:        hit.Source.Id,
            Title:     hit.Source.Title,
            Summary:   hit.Source.Summary,
            Url:       fmt.Sprintf("/posts/%s", hit.Source.Slug),
            Highlight: hit.Highlight["title"],
            Score:     hit.Score,
        }
    }

    return &searchV1.SearchResponse{
        Hits:   hits,
        Total:  result.Total,
        TookMs: result.Took,
    }, nil
}

5. Frontend Search

5.1 React Search Component

// src/components/search/SearchBar.tsx
'use client';

import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';

export function SearchBar({ locale }: { locale: string }) {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState([]);
  const [isOpen, setIsOpen] = useState(false);
  const router = useRouter();
  const debounceRef = useRef<NodeJS.Timeout>();

  // 防抖搜索建议
  useEffect(() => {
    if (!query.trim() || query.length < 2) {
      setSuggestions([]);
      return;
    }

    debounceRef.current = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&locale=${locale}&pageSize=5`);
      const data = await res.json();
      setSuggestions(data.hits || []);
      setIsOpen(true);
    }, 300);

    return () => clearTimeout(debounceRef.current);
  }, [query, locale]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (query.trim()) {
      router.push(`/${locale}/search?q=${encodeURIComponent(query)}`);
      setIsOpen(false);
    }
  };

  return (
    <div className="relative">
      <form onSubmit={handleSubmit}>
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="搜索文章..."
          className="w-full px-4 py-2 border rounded"
          onFocus={() => suggestions.length > 0 && setIsOpen(true)}
          onBlur={() => setTimeout(() => setIsOpen(false), 200)}
        />
      </form>

      {/* 搜索建议下拉 */}
      {isOpen && suggestions.length > 0 && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg z-50">
          {suggestions.map((hit) => (
            <a
              key={hit.id}
              href={hit.url}
              className="block px-4 py-2 hover:bg-gray-100"
            >
              <div dangerouslySetInnerHTML={{ __html: hit.highlight || hit.title }} />
              <p className="text-sm text-gray-500">{hit.summary}</p>
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

5.2 Search Results Page

// src/app/[locale]/search/page.tsx
export default async function SearchPage({
  params: { locale },
  searchParams: { q, page = '1' },
}: {
  params: { locale: string };
  searchParams: { q: string; page?: string };
}) {
  const results = await fetch(
    `${API_BASE}/search?query=${encodeURIComponent(q)}&locale=${locale}&page=${page}&pageSize=10`,
  ).then((res) => res.json());

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-2">搜索结果</h1>
      <p className="text-gray-500 mb-6">找到约 {results.total} 条「{q}」相关结果</p>

      <div className="space-y-6">
        {results.hits.map((hit) => (
          <article key={hit.id} className="border-b pb-4">
            <a href={hit.url}>
              <h2
                className="text-xl font-semibold hover:text-blue-600"
                dangerouslySetInnerHTML={{ __html: hit.highlight || hit.title }}
              />
              <p className="text-gray-600 mt-1">{hit.summary}</p>
            </a>
          </article>
        ))}
      </div>

      <Pagination
        current={Number(page)}
        total={results.total}
        pageSize={10}
      />
    </div>
  );
}

6. Search Optimization

6.1 Search Weight Tuning

// 标题匹配权重最高,摘要次之,正文最低
multiMatch := opensearch.NewMultiMatchQuery(query).
    Field("title^3").        // 权重 3
    Field("title.prefix^2"). // 前缀匹配权重 2
    Field("summary^2").
    Field("content^1").
    Type("best_fields").
    TieBreaker(0.3)

6.2 Synonym Configuration

{
  "settings": {
    "analysis": {
      "filter": {
        "synonym_filter": {
          "type": "synonym",
          "synonyms": [
            "GoWind,gowind,go-wind",
            "CMS,content management system,内容管理系统",
            "前端,frontend,front-end"
          ]
        }
      },
      "analyzer": {
        "synonym_analyzer": {
          "tokenizer": "ik_max_word",
          "filter": ["synonym_filter", "lowercase"]
        }
      }
    }
  }
}

7. Checklist

Check ItemDescription
OpenSearch serviceStarted with Docker Compose
IK analyzerChinese tokenization plugin installed
Index mappingsFields + analyzer configuration
Index syncReal-time async task synchronization
Full rebuildBatch import feature
Search APIAdvanced search endpoint
Frontend searchSearch bar + results page + highlighting
Search optimizationWeights + synonyms

Related Documents

  • CMS Backend Architecture Overview
  • Task Scheduling in Practice
  • Headless API Multi-Client Integration in Practice
  • Frontend Application Development in Practice
Edit this page
Last Updated:: 11/3/26, 2:14 PM
Contributors: Bobo
Prev
Encryption and Security Toolkit Tutorial
Next
Task Scheduling in Practice