Skip to content

Commit eed1a56

Browse files
authored
feat: semantic search and similar feedback API (#40)
* feat: embeddings * chore: additional tests and logging * chore: PR fix * chore: new embedding model & google ai support * chore: go mod tidy * chore: fix tests postgres string * chore: fix test * chore: change datatype to dynamic vector size instead of fixes size * chore: added indexes * chore: rename migrations * chore: fix migration * chore: migrated to half vecs; assorted changes * feat: embedding search * chore: clean defaults, no provider = no embeddings * chore: fmt * chore: switch to fixed vector dimension (768) * chore: fix migration * chore: size sanitation * chore: improved pagination * chore: fix backfill, normalization flag * chore: mantatory tenantID, refactor * chore: go mod tidy * chore: error when embeddings disabled * chore: fix tests * chore: updated openapi.yml * chore: updated openapi.yml * chore: small improvements * chore: additional error checking * chore: closing rows and snake case consistency * chore: improved cursor documenting * chore: make schemathesis can use env port now * chore: fix min length in fields * chore: hide new endpoints in schemathesis tests * chore: rename topk to limit, results to data * chore: improved error messages * chore: update .env.example * chore: return default score cut in case of invalid min score * chore: using library for case convertion * chore: comment * chore: change api contracts workflow * chore: fix makefile * chore: mandatory tenant_id * chore: changed the examples on openapi * chore: added "has more" in search results * chore: fix schemathesis workflow * chore: nitpicking... * chore: remove references to unsupported embedding providers
1 parent 115f6b9 commit eed1a56

36 files changed

Lines changed: 2421 additions & 96 deletions

.env.example

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ WEBHOOK_MAX_FAN_OUT_PER_EVENT=500
4343
# Max total webhooks allowed; creation returns 403 Forbidden when limit reached. Default: 500
4444
WEBHOOK_MAX_COUNT=500
4545

46-
# Embeddings are optional. No default for provider: if EMBEDDING_PROVIDER is unset, embeddings are disabled and no embedding jobs run.
47-
# To enable, set EMBEDDING_PROVIDER (e.g. openai or google). Model and API key are optional (e.g. local provider may not need them).
46+
# Embeddings are optional. To enable, set both EMBEDDING_PROVIDER and EMBEDDING_MODEL; if either is unset, embeddings are disabled and no embedding jobs run.
47+
# EMBEDDING_PROVIDER_API_KEY is required for openai and google.
4848
# Vector size is fixed (768) in code.
4949
# EMBEDDING_PROVIDER=openai
50-
# EMBEDDING_PROVIDER_API_KEY=sk-... (optional for some providers, e.g. local)
51-
# EMBEDDING_MODEL=<model name> (optional; provider may have its own default)
50+
# EMBEDDING_PROVIDER_API_KEY=sk-... (required for openai and google)
51+
# EMBEDDING_MODEL=<model name> (required to enable embeddings; no default)
5252

5353
# River UI basic auth (optional, used when running River UI via docker compose)
5454
# Defaults: admin / changeme if unset

.github/workflows/api-contract-tests.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,27 @@ jobs:
119119
checks: not_a_server_error,status_code_conformance,content_type_conformance,response_schema_conformance
120120
# Only run examples phase for fast, deterministic CI testing.
121121
# For deeper testing (stateful, fuzzing), run locally: make schemathesis
122+
# Exclude search endpoints: CI does not configure embeddings, so they return 503 (treated as failure by not_a_server_error).
123+
# Run locally with embeddings enabled to test these operations.
122124
args: >-
123125
--phases examples
124126
-H "Authorization: Bearer test-api-key-for-ci"
127+
--exclude-operation-id semantic-search-feedback-records
128+
--exclude-operation-id similar-feedback-records
129+
130+
- name: Run API contract tests (search endpoints)
131+
uses: schemathesis/action@1f15936316e0742005bf69657b5909ac68f04cb3
132+
with:
133+
schema: ./openapi.yaml
134+
base-url: http://localhost:8080
135+
version: "4.4.4"
136+
# Omit not_a_server_error: search endpoints return 503 when embeddings are disabled (documented behavior).
137+
checks: status_code_conformance,content_type_conformance,response_schema_conformance
138+
args: >-
139+
--phases examples
140+
-H "Authorization: Bearer test-api-key-for-ci"
141+
--include-operation-id semantic-search-feedback-records
142+
--include-operation-id similar-feedback-records
125143
126144
- name: Stop API server
127145
if: always()

Makefile

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
.PHONY: help tests tests-coverage build build-backfill-embeddings run init-db clean docker-up docker-down docker-clean deps install-tools fmt lint lint-new dev-setup test-all test-unit schemathesis install-hooks migrate-status migrate-validate river-migrate
1+
.PHONY: all test help tests tests-coverage build build-backfill-embeddings run run-backfill-embeddings init-db clean docker-up docker-down docker-clean deps install-tools fmt lint lint-new dev-setup test-all test-unit schemathesis install-hooks migrate-status migrate-validate river-migrate
2+
3+
# Aliases for checkmake/lint expectations
4+
all: build
5+
test: test-all
26

37
# Default target - show help
48
help:
@@ -8,6 +12,7 @@ help:
812
@echo " make build - Build the API server"
913
@echo " make build-backfill-embeddings - Build the backfill-embeddings command"
1014
@echo " make run - Run the API server"
15+
@echo " make run-backfill-embeddings - Run the backfill-embeddings command (enqueues embedding jobs; loads .env)"
1116
@echo " make test-unit - Run unit tests (fast, no database)"
1217
@echo " make tests - Run integration tests"
1318
@echo " make test-all - Run all tests (unit + integration)"
@@ -62,6 +67,11 @@ build-backfill-embeddings:
6267
go build -o bin/backfill-embeddings ./cmd/backfill-embeddings
6368
@echo "Binary created: bin/backfill-embeddings"
6469

70+
# Run the backfill-embeddings command (loads .env for DATABASE_URL etc.). Requires .env; fails fast if missing.
71+
run-backfill-embeddings:
72+
@if [ ! -f .env ]; then echo "Error: .env file required. Copy .env.example to .env and configure."; exit 1; fi && \
73+
(set -a && . ./.env && set +a && go run ./cmd/backfill-embeddings)
74+
6575
# Run the API server
6676
run:
6777
@echo "Checking for .env file..."
@@ -228,6 +238,7 @@ dev-setup: docker-up deps install-tools init-db install-hooks
228238
# This runs more thorough tests than CI to find edge-case bugs.
229239
# Requires: API server running (make run in another terminal)
230240
# Requires: uvx (install via: curl -LsSf https://astral.sh/uv/install.sh | sh)
241+
# Uses PORT from .env if set, otherwise 8080.
231242
schemathesis:
232243
@echo "Running Schemathesis API tests (all phases)..."
233244
@echo "This is deeper testing than CI - may find edge-case bugs."
@@ -238,7 +249,7 @@ schemathesis:
238249
echo "Warning: API_KEY not found in .env file, tests may fail authentication"; \
239250
fi && \
240251
uvx schemathesis run ./openapi.yaml \
241-
--url http://localhost:8080 \
252+
--url "http://localhost:$${PORT:-8080}" \
242253
--header "Authorization: Bearer $${API_KEY:-test-api-key-12345}" \
243254
--checks all \
244255
--phases examples,coverage,stateful,fuzzing \
@@ -250,7 +261,7 @@ schemathesis:
250261
exit 1; \
251262
fi && \
252263
uvx schemathesis run ./openapi.yaml \
253-
--url http://localhost:8080 \
264+
--url "http://localhost:$${PORT:-8080}" \
254265
--header "Authorization: Bearer $$API_KEY" \
255266
--checks all \
256267
--phases examples,coverage,stateful,fuzzing \

cmd/api/app.go

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"fmt"
77
"log/slog"
88
"net/http"
9+
"strings"
910
"time"
1011

12+
lru "github.com/hashicorp/golang-lru/v2"
1113
"github.com/jackc/pgx/v5"
1214
"github.com/jackc/pgx/v5/pgxpool"
1315
"github.com/riverqueue/river"
@@ -41,7 +43,10 @@ type App struct {
4143
metrics *observability.Metrics
4244
}
4345

44-
var errUnsupportedEmbeddingProvider = errors.New("unsupported embedding provider")
46+
var (
47+
errUnsupportedEmbeddingProvider = errors.New("unsupported embedding provider")
48+
errEmbeddingProviderAPIKeyRequired = errors.New("EMBEDDING_PROVIDER_API_KEY is required for this provider")
49+
)
4550

4651
const (
4752
embeddingProviderOpenAI = "openai"
@@ -55,22 +60,25 @@ var supportedEmbeddingProviders = map[string]struct{}{
5560

5661
const riverQueueDepthInterval = 15 * time.Second
5762

58-
// embeddingProviderAndModel returns (provider, model) when embeddings are enabled: EMBEDDING_PROVIDER
59-
// is set and supported. Model and API key are optional (e.g. local provider may not need them).
60-
// Otherwise returns ("", "") so no embedding provider or jobs run. Embeddings are optional; no defaults.
63+
// embeddingProviderAndModel returns (provider, model) when embeddings are enabled: both EMBEDDING_PROVIDER
64+
// and EMBEDDING_MODEL must be set and the provider must be supported. Otherwise returns ("", "") so no
65+
// embedding provider or jobs run. No default for model; embeddings are disabled if either is unset.
66+
// Provider name is normalized to lowercase so that "OpenAI", "openai", and "OPENAI" behave the same
67+
// (consistent with backfill-embeddings and EmbeddingPrefixForProvider).
6168
func embeddingProviderAndModel(cfg *config.Config) (provider, model string) {
62-
if cfg.EmbeddingProvider == "" {
69+
if cfg.EmbeddingProvider == "" || cfg.EmbeddingModel == "" {
6370
return "", ""
6471
}
6572

66-
if _, ok := supportedEmbeddingProviders[cfg.EmbeddingProvider]; !ok {
73+
providerCanonical := strings.ToLower(strings.TrimSpace(cfg.EmbeddingProvider))
74+
if _, ok := supportedEmbeddingProviders[providerCanonical]; !ok {
6775
slog.Info("embeddings disabled: unsupported EMBEDDING_PROVIDER",
6876
"provider", cfg.EmbeddingProvider, "model", cfg.EmbeddingModel)
6977

7078
return "", ""
7179
}
7280

73-
return cfg.EmbeddingProvider, cfg.EmbeddingModel
81+
return providerCanonical, cfg.EmbeddingModel
7482
}
7583

7684
// setupMetrics creates meter provider and hub metrics when metrics are enabled.
@@ -165,17 +173,19 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
165173
river.AddWorker(riverWorkers, webhookWorker)
166174

167175
queues := map[string]river.QueueConfig{
168-
river.QueueDefault: {MaxWorkers: cfg.WebhookDeliveryMaxConcurrent},
169-
service.EmbeddingsQueueName: {MaxWorkers: cfg.EmbeddingMaxConcurrent},
176+
river.QueueDefault: {MaxWorkers: cfg.WebhookDeliveryMaxConcurrent},
170177
}
171178

172179
feedbackRecordsRepo := repository.NewFeedbackRecordsRepository(db)
173180
embeddingsRepo := repository.NewEmbeddingsRepository(db)
174181
embeddingProviderName, embeddingModel := embeddingProviderAndModel(cfg)
175-
// Model for DB/jobs: required for embeddings.model column; use "default" when provider has no model name (e.g. local).
182+
// Model for DB/jobs: required for embeddings.model column; only set when embeddings are enabled (both provider and model set).
176183
embeddingModelForDB := embeddingModel
177-
if embeddingModelForDB == "" {
178-
embeddingModelForDB = "default"
184+
185+
var embeddingDocPrefix string
186+
if embeddingProviderName != "" {
187+
embeddingDocPrefix = service.EmbeddingPrefixForProvider(embeddingProviderName)
188+
queues[service.EmbeddingsQueueName] = river.QueueConfig{MaxWorkers: cfg.EmbeddingMaxConcurrent}
179189
}
180190

181191
feedbackRecordsService := service.NewFeedbackRecordsService(
@@ -188,17 +198,27 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
188198
cfg.EmbeddingMaxAttempts,
189199
)
190200

201+
var searchHandler *handlers.SearchHandler
202+
191203
if embeddingProviderName != "" {
204+
// Fail fast when a provider that requires an API key is configured without one (consistent with backfill-embeddings).
205+
if (embeddingProviderName == embeddingProviderOpenAI || embeddingProviderName == embeddingProviderGoogle) &&
206+
cfg.EmbeddingProviderAPIKey == "" {
207+
return nil, fmt.Errorf("%w: %s", errEmbeddingProviderAPIKeyRequired, embeddingProviderName)
208+
}
209+
192210
var embeddingClient service.EmbeddingClient
193211

194212
switch embeddingProviderName {
195213
case embeddingProviderOpenAI:
196214
embeddingClient = openai.NewClient(cfg.EmbeddingProviderAPIKey,
197215
openai.WithModel(embeddingModel),
216+
openai.WithNormalize(cfg.EmbeddingNormalize),
198217
)
199218
case embeddingProviderGoogle:
200219
googleClient, err := googleai.NewClient(context.Background(), cfg.EmbeddingProviderAPIKey,
201220
googleai.WithModel(embeddingModel),
221+
googleai.WithNormalize(cfg.EmbeddingNormalize),
202222
)
203223
if err != nil {
204224
return nil, fmt.Errorf("create google embedding client: %w", err)
@@ -209,8 +229,33 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
209229
return nil, fmt.Errorf("%w: %s", errUnsupportedEmbeddingProvider, embeddingProviderName)
210230
}
211231

212-
embeddingWorker := workers.NewFeedbackEmbeddingWorker(feedbackRecordsService, embeddingClient, embeddingMetrics)
232+
embeddingWorker := workers.NewFeedbackEmbeddingWorker(
233+
feedbackRecordsService, embeddingClient, embeddingDocPrefix, embeddingMetrics)
213234
river.AddWorker(riverWorkers, embeddingWorker)
235+
236+
const searchQueryCacheSize = 1000
237+
238+
queryCache, err := lru.New[string, []float32](searchQueryCacheSize)
239+
if err != nil {
240+
return nil, fmt.Errorf("create search query cache: %w", err)
241+
}
242+
243+
var cacheMetrics observability.CacheMetrics
244+
if metrics != nil {
245+
cacheMetrics = metrics.Cache
246+
}
247+
248+
searchService := service.NewSearchService(service.SearchServiceParams{
249+
EmbeddingClient: embeddingClient,
250+
EmbeddingsRepo: embeddingsRepo,
251+
Model: embeddingModel,
252+
QueryCache: queryCache,
253+
CacheMetrics: cacheMetrics,
254+
Logger: slog.Default(),
255+
})
256+
searchHandler = handlers.NewSearchHandler(searchService)
257+
} else {
258+
searchHandler = handlers.NewSearchHandler(nil) // 503 when embeddings disabled
214259
}
215260

216261
riverClient, err := river.NewClient(riverpgxv5.New(db), &river.Config{
@@ -252,6 +297,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
252297
embeddingModelForDB,
253298
service.EmbeddingsQueueName,
254299
cfg.EmbeddingMaxAttempts,
300+
embeddingDocPrefix,
255301
embeddingMetrics,
256302
)
257303
messageManager.RegisterProvider(embeddingProv)
@@ -264,7 +310,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
264310
healthHandler := handlers.NewHealthHandler()
265311

266312
server := newHTTPServer(
267-
cfg, healthHandler, feedbackRecordsHandler, webhooksHandler,
313+
cfg, healthHandler, feedbackRecordsHandler, webhooksHandler, searchHandler,
268314
meterProvider, tracerProvider,
269315
)
270316

@@ -287,6 +333,7 @@ func newHTTPServer(
287333
health *handlers.HealthHandler,
288334
feedback *handlers.FeedbackRecordsHandler,
289335
webhooks *handlers.WebhooksHandler,
336+
search *handlers.SearchHandler,
290337
meterProvider *sdkmetric.MeterProvider,
291338
tracerProvider *sdktrace.TracerProvider,
292339
) *http.Server {
@@ -307,6 +354,10 @@ func newHTTPServer(
307354
protected.HandleFunc("PATCH /v1/webhooks/{id}", webhooks.Update)
308355
protected.HandleFunc("DELETE /v1/webhooks/{id}", webhooks.Delete)
309356

357+
// Search endpoints are always registered; when embeddings are disabled, the handler returns 503.
358+
protected.HandleFunc("POST /v1/feedback-records/search/semantic", search.SemanticSearch)
359+
protected.HandleFunc("GET /v1/feedback-records/{id}/similar", search.SimilarFeedback)
360+
310361
protectedWithAuth := middleware.Auth(cfg.APIKey)(protected)
311362
mux := http.NewServeMux()
312363
mux.Handle("/v1/", protectedWithAuth)

0 commit comments

Comments
 (0)