feat: semantic search with Qdrant + Ollama embeddings

- Add SemanticSearchService with embed() + searchQdrant() methods
- Add GET /api/search/semantic endpoint (?q=query&k=5)
- Wire SemanticSearchService into router and cmd/server/main.go
- Add SemanticSearch React page with results + similarity scores
- Add 'Semantic Search' nav link in App.tsx
- Add unit tests with mocked Ollama + Qdrant HTTP servers (4 tests, all passing)
- Add GitHub issue templates (bug report, feature request)
- Add pull request template
This commit is contained in:
Christopher Mayor
2026-04-24 11:13:50 -07:00
parent 97c502a5f9
commit 468519fde1
9 changed files with 646 additions and 0 deletions

View File

@@ -35,6 +35,7 @@ type Services struct {
Discover *service.DiscoverService
MediaDetail *service.MediaDetailService
Calendar *service.CalendarService
SemanticSearch *service.SemanticSearchService
}
func NewRouter(cfg *config.Config, svc *Services) *echo.Echo {
@@ -148,6 +149,11 @@ func NewRouter(cfg *config.Config, svc *Services) *echo.Echo {
// Calendar route
g.GET("/calendar", listCalendarEvents(svc.Calendar))
// Semantic search route
if svc.SemanticSearch != nil {
g.GET("/search/semantic", semanticSearch(svc.SemanticSearch))
}
// Request routes — protected by API key auth
apiKeyAuth := newAPIKeyAuth(svc.User)
g.GET("/requests", listRequests(svc.Request, svc.User), apiKeyAuth)

View File

@@ -54,6 +54,35 @@ func searchReleases(svc *service.SearchService) echo.HandlerFunc {
}
}
func semanticSearch(svc *service.SemanticSearchService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
query := c.QueryParam("q")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "q parameter is required"})
}
k := 5
if kStr := c.QueryParam("k"); kStr != "" {
if kVal, err := strconv.Atoi(kStr); err == nil && kVal > 0 && kVal <= 50 {
k = kVal
}
}
results, err := svc.Search(ctx, query, k)
if err != nil {
slog.Error("semantic search failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
})
}
}
func grabRelease(svc *service.SearchService, dcSvc *service.DownloadClientService, queueSvc *service.QueueService, safetySvc *service.SafetyService, activitySvc *service.ActivityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)