feat(search): improve ranking with ILIKE, identifier search, multi-word support (#601)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang
2026-04-10 02:43:33 +08:00
committed by GitHub
parent 25080c6719
commit 02cdfcb93f
3 changed files with 330 additions and 210 deletions

View File

@@ -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()

View File

@@ -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),

View File

@@ -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).