Phase 3.1: Help Center semantic search with pgvector

- ArticleEmbeddingRepo (new): Upsert, GetByArticleID, DeleteByArticleID,
  SearchByEmbedding using pgvector cosine distance (vector_embedding column)
- ArticleEmbedding model: add VectorEmbedding pgvector.Vector field
  alongside existing JSONB Embedding (backward compatible)
- ArticleService: add SemanticSearch() — generates query embedding via LLM,
  searches articles by cosine similarity; add GenerateEmbedding() — creates
  and stores article embedding from title+description+content
- ArticleHandler: add SemanticSearch endpoint
  GET /portals/:portal_id/articles/semantic_search?query=...
- bootstrap.go: inject articleEmbeddingRepo + llmProvider into ArticleService
- router.go: register /articles/semantic_search route
- migration 000049: add vector(1536) column to article_embeddings table,
  create ivfflat index, migrate existing JSONB data to vector format

Verified: go build + go vet + go test all pass
This commit is contained in:
2026-07-08 15:38:08 +08:00
parent b769b9a3e4
commit c72e359e48
8 changed files with 268 additions and 3 deletions
@@ -405,6 +405,45 @@ func (h *ArticleHandler) Search(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)})
}
// SemanticSearch performs AI-powered semantic search on help center articles.
// Uses LLM embeddings + pgvector cosine similarity to find articles by meaning,
// not just keyword matching.
//
// GET /portals/:portal_id/articles/semantic_search?query=how+to+reset+password
func (h *ArticleHandler) SemanticSearch(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, ok := h.resolvePortal(c, accountID)
if !ok {
return
}
query := strings.TrimSpace(c.Query("query"))
if query == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "query parameter is required")
return
}
limit := 10
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
}
articles, err := h.svc.SemanticSearch(c.Request.Context(), portal.ID, query, limit)
if err != nil {
applogger.L().Errorf("SemanticSearch articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to perform semantic search")
return
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles)})
}
// StatusCounts returns article counts grouped by status.
// GET /portals/:portal_id/articles/status_counts
func (h *ArticleHandler) StatusCounts(c *gin.Context) {