- 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
189 lines
4.9 KiB
Go
189 lines
4.9 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type SemanticSearchResult struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
MediaType string `json:"media_type"`
|
|
Year *int `json:"year,omitempty"`
|
|
Score float64 `json:"score"`
|
|
Overview string `json:"overview,omitempty"`
|
|
}
|
|
|
|
type ollamaEmbedRequest struct {
|
|
Model string `json:"model"`
|
|
Prompt string `json:"prompt"`
|
|
}
|
|
|
|
type ollamaEmbedResponse struct {
|
|
Embedding []float64 `json:"embedding"`
|
|
}
|
|
|
|
type qdrantSearchRequest struct {
|
|
Vector []float64 `json:"vector"`
|
|
Limit int `json:"limit"`
|
|
WithPayload bool `json:"with_payload"`
|
|
}
|
|
|
|
type qdrantSearchResponse struct {
|
|
Result []qdrantHit `json:"result"`
|
|
}
|
|
|
|
type qdrantHit struct {
|
|
ID string `json:"id"`
|
|
Score float64 `json:"score"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
type qdrantPayload struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
MediaType string `json:"media_type"`
|
|
Year *int `json:"year,omitempty"`
|
|
Overview string `json:"overview,omitempty"`
|
|
}
|
|
|
|
type SemanticSearchService struct {
|
|
ollamaURL string
|
|
qdrantURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewSemanticSearchService(ollamaURL, qdrantURL string) *SemanticSearchService {
|
|
return &SemanticSearchService{
|
|
ollamaURL: ollamaURL,
|
|
qdrantURL: qdrantURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *SemanticSearchService) Search(ctx context.Context, query string, k int) ([]SemanticSearchResult, error) {
|
|
embedding, err := s.embed(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate embedding: %w", err)
|
|
}
|
|
|
|
results, err := s.searchQdrant(ctx, embedding, k)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search qdrant: %w", err)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *SemanticSearchService) embed(ctx context.Context, text string) ([]float64, error) {
|
|
reqBody := ollamaEmbedRequest{
|
|
Model: "nomic-embed-text",
|
|
Prompt: text,
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal embed request: %w", err)
|
|
}
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, s.ollamaURL+"/api/embeddings", bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create embed request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ollama request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
slog.Error("ollama embed returned non-200", "status", resp.StatusCode, "body", string(respBody))
|
|
return nil, fmt.Errorf("ollama returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var embedResp ollamaEmbedResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil {
|
|
return nil, fmt.Errorf("decode ollama response: %w", err)
|
|
}
|
|
|
|
if len(embedResp.Embedding) == 0 {
|
|
return nil, fmt.Errorf("ollama returned empty embedding")
|
|
}
|
|
|
|
return embedResp.Embedding, nil
|
|
}
|
|
|
|
func (s *SemanticSearchService) searchQdrant(ctx context.Context, embedding []float64, k int) ([]SemanticSearchResult, error) {
|
|
reqBody := qdrantSearchRequest{
|
|
Vector: embedding,
|
|
Limit: k,
|
|
WithPayload: true,
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal qdrant request: %w", err)
|
|
}
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
url := fmt.Sprintf("%s/collections/media/points/search", s.qdrantURL)
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create qdrant request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qdrant request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
slog.Error("qdrant search returned non-200", "status", resp.StatusCode, "body", string(respBody))
|
|
return nil, fmt.Errorf("qdrant returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var searchResp qdrantSearchResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
return nil, fmt.Errorf("decode qdrant response: %w", err)
|
|
}
|
|
|
|
results := make([]SemanticSearchResult, 0, len(searchResp.Result))
|
|
for _, hit := range searchResp.Result {
|
|
var payload qdrantPayload
|
|
if err := json.Unmarshal(hit.Payload, &payload); err != nil {
|
|
slog.Error("failed to unmarshal qdrant payload", "error", err)
|
|
continue
|
|
}
|
|
|
|
results = append(results, SemanticSearchResult{
|
|
ID: payload.ID,
|
|
Title: payload.Title,
|
|
MediaType: payload.MediaType,
|
|
Year: payload.Year,
|
|
Score: hit.Score,
|
|
Overview: payload.Overview,
|
|
})
|
|
}
|
|
|
|
return results, nil
|
|
}
|