From 02cdfcb93fbc6f6eb1f02233c0dc50fbff6dc76e Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 10 Apr 2026 02:43:33 +0800 Subject: [PATCH] feat(search): improve ranking with ILIKE, identifier search, multi-word support (#601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): improve ranking with ILIKE, identifier search, multi-word support - Replace LIKE with ILIKE for case-insensitive matching - Support identifier search (e.g. "MUL-123" or bare "123") - Refine sorting tiers: number match > exact title > title starts with > title contains > all words in title > description > comment - Add status-based tiebreaker (active issues rank higher) - Support multi-word search where all terms must match somewhere - Move search query from sqlc to dynamic SQL for flexibility Co-Authored-By: Claude Opus 4.6 (1M context) * fix(search): fix parameter type error for single-word queries Only allocate per-term SQL parameters when there are multiple search terms. For single-word queries, the phrase parameter already covers the search — unused term params caused PostgreSQL error "could not determine data type of parameter $3". Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- server/internal/handler/issue.go | 381 +++++++++++++++++++++++---- server/pkg/db/generated/issue.sql.go | 121 --------- server/pkg/db/queries/issue.sql | 38 +-- 3 files changed, 330 insertions(+), 210 deletions(-) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 5fafb9834..82bbb7f56 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -3,12 +3,15 @@ package handler import ( "context" "encoding/json" + "fmt" "io" "log/slog" "net/http" + "regexp" "strconv" "strings" "time" + "unicode" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" @@ -128,18 +131,20 @@ func extractSnippet(content, query string) string { queryRunes := []rune(strings.ToLower(query)) idx := -1 - for i := 0; i <= len(lowerRunes)-len(queryRunes); i++ { - match := true - for j := range queryRunes { - if lowerRunes[i+j] != queryRunes[j] { - match = false + if len(queryRunes) > 0 && len(lowerRunes) >= len(queryRunes) { + for i := 0; i <= len(lowerRunes)-len(queryRunes); i++ { + match := true + for j := range queryRunes { + if lowerRunes[i+j] != queryRunes[j] { + match = false + break + } + } + if match { + idx = i break } } - if match { - idx = i - break - } } if idx < 0 { @@ -174,6 +179,263 @@ func escapeLike(s string) string { return s } +// splitSearchTerms splits a query into individual search terms, filtering empty strings. +func splitSearchTerms(q string) []string { + fields := strings.FieldsFunc(q, func(r rune) bool { + return unicode.IsSpace(r) + }) + terms := make([]string, 0, len(fields)) + for _, f := range fields { + if f != "" { + terms = append(terms, f) + } + } + return terms +} + +// identifierNumberRe matches patterns like "MUL-123" or "ABC-45". +var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z]+-(\d+)$`) + +// parseQueryNumber extracts an issue number from the query if it looks like +// an identifier (e.g. "MUL-123") or a bare number (e.g. "123"). +func parseQueryNumber(q string) (int, bool) { + q = strings.TrimSpace(q) + // Check for identifier pattern like "MUL-123" + if m := identifierNumberRe.FindStringSubmatch(q); m != nil { + if n, err := strconv.Atoi(m[1]); err == nil && n > 0 { + return n, true + } + } + // Check for bare number + if n, err := strconv.Atoi(q); err == nil && n > 0 { + return n, true + } + return 0, false +} + +// searchResult holds a raw row from the dynamic search query. +type searchResult struct { + issue db.Issue + totalCount int64 + matchSource string + matchedCommentContent string +} + +// buildSearchQuery builds a dynamic SQL query for issue search. +// It supports ILIKE matching, identifier search, multi-word search, +// and refined ranking. +func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool) (string, []any) { + // Parameter index tracker + argIdx := 1 + args := []any{} + nextArg := func(val any) string { + args = append(args, val) + s := fmt.Sprintf("$%d", argIdx) + argIdx++ + return s + } + + escapedPhrase := escapeLike(phrase) + phraseParam := nextArg(escapedPhrase) // $1 + phraseContains := "'%' || " + phraseParam + " || '%'" + phraseStartsWith := phraseParam + " || '%'" + + wsParam := nextArg(nil) // $2 — workspace_id, will be filled by caller position + + // Build per-term ILIKE conditions only for multi-word search. + // For single-word queries, the phrase parameter already covers the term. + var termParams []string + if len(terms) > 1 { + for _, t := range terms { + et := escapeLike(t) + termParams = append(termParams, nextArg(et)) + } + } + + // --- WHERE clause --- + var whereParts []string + + // Full phrase match: title, description, or comment + phraseMatch := fmt.Sprintf( + "(i.title ILIKE %s OR COALESCE(i.description, '') ILIKE %s OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.content ILIKE %s))", + phraseContains, phraseContains, phraseContains, + ) + whereParts = append(whereParts, phraseMatch) + + // Multi-word AND match (each term must appear somewhere) + if len(termParams) > 1 { + var termConditions []string + for _, tp := range termParams { + tc := "'%' || " + tp + " || '%'" + termConditions = append(termConditions, fmt.Sprintf( + "(i.title ILIKE %s OR COALESCE(i.description, '') ILIKE %s OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.content ILIKE %s))", + tc, tc, tc, + )) + } + whereParts = append(whereParts, "("+strings.Join(termConditions, " AND ")+")") + } + + // Number match + numParam := "" + if hasNum { + numParam = nextArg(queryNum) + whereParts = append(whereParts, fmt.Sprintf("i.number = %s", numParam)) + } + + whereClause := "(" + strings.Join(whereParts, " OR ") + ")" + + if !includeClosed { + whereClause += " AND i.status NOT IN ('done', 'cancelled')" + } + + // --- ORDER BY clause --- + // Build ranking CASE with fine-grained tiers. + var rankCases []string + + // Tier 0: Identifier exact match + if hasNum { + rankCases = append(rankCases, fmt.Sprintf("WHEN i.number = %s THEN 0", numParam)) + } + + // Tier 1: Exact title match + rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(i.title) = LOWER(%s) THEN 1", phraseParam)) + + // Tier 2: Title starts with phrase + rankCases = append(rankCases, fmt.Sprintf("WHEN i.title ILIKE %s THEN 2", phraseStartsWith)) + + // Tier 3: Title contains phrase + rankCases = append(rankCases, fmt.Sprintf("WHEN i.title ILIKE %s THEN 3", phraseContains)) + + // Tier 4: Title matches all words (multi-word only) + if len(termParams) > 1 { + var titleTerms []string + for _, tp := range termParams { + titleTerms = append(titleTerms, fmt.Sprintf("i.title ILIKE '%s' || %s || '%s'", "%", tp, "%")) + } + rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 4", strings.Join(titleTerms, " AND "))) + } + + // Tier 5: Description contains phrase + rankCases = append(rankCases, fmt.Sprintf("WHEN COALESCE(i.description, '') ILIKE %s THEN 5", phraseContains)) + + // Tier 6: Description matches all words (multi-word only) + if len(termParams) > 1 { + var descTerms []string + for _, tp := range termParams { + descTerms = append(descTerms, fmt.Sprintf("COALESCE(i.description, '') ILIKE '%s' || %s || '%s'", "%", tp, "%")) + } + rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 6", strings.Join(descTerms, " AND "))) + } + + rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 7 END" + + // Status priority: active issues first + statusRank := `CASE i.status + WHEN 'in_progress' THEN 0 + WHEN 'in_review' THEN 1 + WHEN 'todo' THEN 2 + WHEN 'blocked' THEN 3 + WHEN 'backlog' THEN 4 + WHEN 'done' THEN 5 + WHEN 'cancelled' THEN 6 + ELSE 7 + END` + + // --- match_source expression --- + matchSourceExpr := fmt.Sprintf(`CASE + WHEN i.title ILIKE %s THEN 'title' + WHEN COALESCE(i.description, '') ILIKE %s THEN 'description' + ELSE 'comment' + END`, phraseContains, phraseContains) + + // For multi-word: also check if all terms match in title/description + if len(termParams) > 1 { + var titleTerms []string + var descTerms []string + for _, tp := range termParams { + titleTerms = append(titleTerms, fmt.Sprintf("i.title ILIKE '%s' || %s || '%s'", "%", tp, "%")) + descTerms = append(descTerms, fmt.Sprintf("COALESCE(i.description, '') ILIKE '%s' || %s || '%s'", "%", tp, "%")) + } + matchSourceExpr = fmt.Sprintf(`CASE + WHEN i.title ILIKE %s THEN 'title' + WHEN (%s) THEN 'title' + WHEN COALESCE(i.description, '') ILIKE %s THEN 'description' + WHEN (%s) THEN 'description' + ELSE 'comment' + END`, + phraseContains, strings.Join(titleTerms, " AND "), + phraseContains, strings.Join(descTerms, " AND "), + ) + } + + // --- matched_comment_content subquery --- + // Find the most recent matching comment for comment-source matches. + commentSubquery := fmt.Sprintf(`CASE + WHEN i.title ILIKE %s THEN '' + WHEN COALESCE(i.description, '') ILIKE %s THEN '' + ELSE COALESCE( + (SELECT c.content FROM comment c + WHERE c.issue_id = i.id AND c.content ILIKE %s + ORDER BY c.created_at DESC LIMIT 1), + '' + ) + END`, phraseContains, phraseContains, phraseContains) + + // For multi-word, also find comment matching individual terms + if len(termParams) > 1 { + var titleTerms []string + var descTerms []string + var commentTerms []string + for _, tp := range termParams { + titleTerms = append(titleTerms, fmt.Sprintf("i.title ILIKE '%s' || %s || '%s'", "%", tp, "%")) + descTerms = append(descTerms, fmt.Sprintf("COALESCE(i.description, '') ILIKE '%s' || %s || '%s'", "%", tp, "%")) + commentTerms = append(commentTerms, fmt.Sprintf("c.content ILIKE '%s' || %s || '%s'", "%", tp, "%")) + } + commentSubquery = fmt.Sprintf(`CASE + WHEN i.title ILIKE %s THEN '' + WHEN (%s) THEN '' + WHEN COALESCE(i.description, '') ILIKE %s THEN '' + WHEN (%s) THEN '' + ELSE COALESCE( + (SELECT c.content FROM comment c + WHERE c.issue_id = i.id AND (c.content ILIKE %s OR (%s)) + ORDER BY c.created_at DESC LIMIT 1), + '' + ) + END`, + phraseContains, strings.Join(titleTerms, " AND "), + phraseContains, strings.Join(descTerms, " AND "), + phraseContains, strings.Join(commentTerms, " AND "), + ) + } + + limitParam := nextArg(nil) // placeholder + offsetParam := nextArg(nil) // placeholder + + query := fmt.Sprintf(`SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, + i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, + i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, + i.due_date, i.created_at, i.updated_at, i.number, i.project_id, + COUNT(*) OVER() AS total_count, + %s AS match_source, + %s AS matched_comment_content + FROM issue i + WHERE i.workspace_id = %s AND %s + ORDER BY %s, %s, i.updated_at DESC + LIMIT %s OFFSET %s`, + matchSourceExpr, + commentSubquery, + wsParam, + whereClause, + rankExpr, + statusRank, + limitParam, + offsetParam, + ) + + return query, args +} + func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceID := resolveWorkspaceID(r) @@ -203,38 +465,77 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { includeClosed := r.URL.Query().Get("include_closed") == "true" wsUUID := parseUUID(workspaceID) - queryText := strToText(escapeLike(q)) + terms := splitSearchTerms(q) + queryNum, hasNum := parseQueryNumber(q) - rows, err := h.Queries.SearchIssues(ctx, db.SearchIssuesParams{ - WorkspaceID: wsUUID, - Query: queryText, - SearchLimit: int32(limit), - SearchOffset: int32(offset), - IncludeClosed: includeClosed, - }) + sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed) + // Fill placeholder args: $2 = workspace_id, last two = limit, offset + args[1] = wsUUID + args[len(args)-2] = limit + args[len(args)-1] = offset + + rows, err := h.DB.Query(ctx, sqlQuery, args...) if err != nil { slog.Warn("search issues failed", "error", err, "workspace_id", workspaceID, "query", q) writeError(w, http.StatusInternalServerError, "failed to search issues") return } + defer rows.Close() + + var results []searchResult + for rows.Next() { + var sr searchResult + if err := rows.Scan( + &sr.issue.ID, + &sr.issue.WorkspaceID, + &sr.issue.Title, + &sr.issue.Description, + &sr.issue.Status, + &sr.issue.Priority, + &sr.issue.AssigneeType, + &sr.issue.AssigneeID, + &sr.issue.CreatorType, + &sr.issue.CreatorID, + &sr.issue.ParentIssueID, + &sr.issue.AcceptanceCriteria, + &sr.issue.ContextRefs, + &sr.issue.Position, + &sr.issue.DueDate, + &sr.issue.CreatedAt, + &sr.issue.UpdatedAt, + &sr.issue.Number, + &sr.issue.ProjectID, + &sr.totalCount, + &sr.matchSource, + &sr.matchedCommentContent, + ); err != nil { + slog.Warn("search issues scan failed", "error", err) + writeError(w, http.StatusInternalServerError, "failed to search issues") + return + } + results = append(results, sr) + } + if err := rows.Err(); err != nil { + slog.Warn("search issues rows error", "error", err) + writeError(w, http.StatusInternalServerError, "failed to search issues") + return + } var total int64 - if len(rows) > 0 { - total = rows[0].TotalCount + if len(results) > 0 { + total = results[0].totalCount } prefix := h.getIssuePrefix(ctx, wsUUID) - resp := make([]SearchIssueResponse, len(rows)) - for i, row := range rows { + resp := make([]SearchIssueResponse, len(results)) + for i, sr := range results { sir := SearchIssueResponse{ - IssueResponse: issueToResponse(searchRowToIssue(row), prefix), - MatchSource: row.MatchSource, + IssueResponse: issueToResponse(sr.issue, prefix), + MatchSource: sr.matchSource, } - if row.MatchSource == "comment" { - if content, ok := row.MatchedCommentContent.(string); ok && content != "" { - snippet := extractSnippet(content, q) - sir.MatchedSnippet = &snippet - } + if sr.matchSource == "comment" && sr.matchedCommentContent != "" { + snippet := extractSnippet(sr.matchedCommentContent, q) + sir.MatchedSnippet = &snippet } resp[i] = sir } @@ -246,30 +547,6 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { }) } -func searchRowToIssue(row db.SearchIssuesRow) db.Issue { - return db.Issue{ - ID: row.ID, - WorkspaceID: row.WorkspaceID, - Title: row.Title, - Description: row.Description, - Status: row.Status, - Priority: row.Priority, - AssigneeType: row.AssigneeType, - AssigneeID: row.AssigneeID, - CreatorType: row.CreatorType, - CreatorID: row.CreatorID, - ParentIssueID: row.ParentIssueID, - AcceptanceCriteria: row.AcceptanceCriteria, - ContextRefs: row.ContextRefs, - Position: row.Position, - DueDate: row.DueDate, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - Number: row.Number, - ProjectID: row.ProjectID, - } -} - func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 4d43b866e..0024b6f8b 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -427,127 +427,6 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) return items, nil } -const searchIssues = `-- name: SearchIssues :many -SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, - COUNT(*) OVER() AS total_count, - CASE - WHEN i.title LIKE '%' || $1 || '%' THEN 'title' - WHEN COALESCE(i.description, '') LIKE '%' || $1 || '%' THEN 'description' - ELSE 'comment' - END AS match_source, - CASE - WHEN i.title LIKE '%' || $1 || '%' THEN '' - WHEN COALESCE(i.description, '') LIKE '%' || $1 || '%' THEN '' - ELSE COALESCE( - (SELECT c.content FROM comment c - WHERE c.issue_id = i.id AND c.content LIKE '%' || $1 || '%' - ORDER BY c.created_at DESC LIMIT 1), - '' - ) - END AS matched_comment_content -FROM issue i -WHERE i.workspace_id = $2 - AND ( - i.title LIKE '%' || $1 || '%' - OR COALESCE(i.description, '') LIKE '%' || $1 || '%' - OR EXISTS ( - SELECT 1 FROM comment c - WHERE c.issue_id = i.id AND c.content LIKE '%' || $1 || '%' - ) - ) - AND ($3::boolean OR i.status NOT IN ('done', 'cancelled')) -ORDER BY - CASE - WHEN i.title LIKE '%' || $1 || '%' THEN 0 - WHEN COALESCE(i.description, '') LIKE '%' || $1 || '%' THEN 1 - ELSE 2 - END, - i.updated_at DESC -LIMIT $5 OFFSET $4 -` - -type SearchIssuesParams struct { - Query pgtype.Text `json:"query"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - IncludeClosed bool `json:"include_closed"` - SearchOffset int32 `json:"search_offset"` - SearchLimit int32 `json:"search_limit"` -} - -type SearchIssuesRow struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Status string `json:"status"` - Priority string `json:"priority"` - AssigneeType pgtype.Text `json:"assignee_type"` - AssigneeID pgtype.UUID `json:"assignee_id"` - CreatorType string `json:"creator_type"` - CreatorID pgtype.UUID `json:"creator_id"` - ParentIssueID pgtype.UUID `json:"parent_issue_id"` - AcceptanceCriteria []byte `json:"acceptance_criteria"` - ContextRefs []byte `json:"context_refs"` - Position float64 `json:"position"` - DueDate pgtype.Timestamptz `json:"due_date"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Number int32 `json:"number"` - ProjectID pgtype.UUID `json:"project_id"` - TotalCount int64 `json:"total_count"` - MatchSource string `json:"match_source"` - MatchedCommentContent interface{} `json:"matched_comment_content"` -} - -func (q *Queries) SearchIssues(ctx context.Context, arg SearchIssuesParams) ([]SearchIssuesRow, error) { - rows, err := q.db.Query(ctx, searchIssues, - arg.Query, - arg.WorkspaceID, - arg.IncludeClosed, - arg.SearchOffset, - arg.SearchLimit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []SearchIssuesRow{} - for rows.Next() { - var i SearchIssuesRow - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Title, - &i.Description, - &i.Status, - &i.Priority, - &i.AssigneeType, - &i.AssigneeID, - &i.CreatorType, - &i.CreatorID, - &i.ParentIssueID, - &i.AcceptanceCriteria, - &i.ContextRefs, - &i.Position, - &i.DueDate, - &i.CreatedAt, - &i.UpdatedAt, - &i.Number, - &i.ProjectID, - &i.TotalCount, - &i.MatchSource, - &i.MatchedCommentContent, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const updateIssue = `-- name: UpdateIssue :one UPDATE issue SET title = COALESCE($2, title), diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index cbaf996d9..b2ee2554e 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -80,40 +80,4 @@ SELECT * FROM issue WHERE parent_issue_id = $1 ORDER BY position ASC, created_at DESC; --- name: SearchIssues :many -SELECT i.*, - COUNT(*) OVER() AS total_count, - CASE - WHEN i.title LIKE '%' || @query || '%' THEN 'title' - WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN 'description' - ELSE 'comment' - END AS match_source, - CASE - WHEN i.title LIKE '%' || @query || '%' THEN '' - WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN '' - ELSE COALESCE( - (SELECT c.content FROM comment c - WHERE c.issue_id = i.id AND c.content LIKE '%' || @query || '%' - ORDER BY c.created_at DESC LIMIT 1), - '' - ) - END AS matched_comment_content -FROM issue i -WHERE i.workspace_id = @workspace_id - AND ( - i.title LIKE '%' || @query || '%' - OR COALESCE(i.description, '') LIKE '%' || @query || '%' - OR EXISTS ( - SELECT 1 FROM comment c - WHERE c.issue_id = i.id AND c.content LIKE '%' || @query || '%' - ) - ) - AND (@include_closed::boolean OR i.status NOT IN ('done', 'cancelled')) -ORDER BY - CASE - WHEN i.title LIKE '%' || @query || '%' THEN 0 - WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN 1 - ELSE 2 - END, - i.updated_at DESC -LIMIT @search_limit OFFSET @search_offset; +-- SearchIssues: moved to handler (dynamic SQL for multi-word search support).