06 - Trocando o dummy embeddings por algo que consigamos plugar facilmente
Continuação do artigo:
05 - Entender o que até agora foi feito
Vamos trocar o “dummy embeddings” por algo que você consiga plugar facilmente:
-
OpenAI Embeddings
-
Sua própria API de embeddings em Go (exposta via HTTP)
E já deixo tudo no estilo dos seus projetos: hexagonal, env vars, providers configuráveis.
Vou focar só nos pontos que mudam – o resto da estrutura (domain/app/onnx/observability/http) você já tem.
1. Porta de embeddings (domínio) – já está OK
Só relembrando, continuamos com isso em:
internal/domain/category/ports.go:
package category
import "context"
// Porta para o modelo de categorização
type CategoryPredictorPort interface {
PredictCategory(ctx context.Context, input PredictionInput) (PredictionResult, error)
}
// Porta para geração de embeddings a partir de descrição
type EmbeddingsPort interface {
EmbedProductDescription(ctx context.Context, description string) ([]float32, error)
}
Nada muda aqui.
2. Service continua igual – só usa a porta
internal/app/service.go já está no padrão certo (usa EmbeddingsPort), então mantemos como está:
package app
import (
"context"
"errors"
"github.com/farsoft-apps/product-category-api/internal/domain/category"
)
var (
ErrInvalidFeaturesLen = errors.New("quantidade de features inválida; esperado 16")
ErrEmptyDescription = errors.New("descrição do produto não pode ser vazia")
)
const ExpectedFeaturesLen = 16
type CategoryService struct {
predictor category.CategoryPredictorPort
embedder category.EmbeddingsPort
}
func NewCategoryService(p category.CategoryPredictorPort, e category.EmbeddingsPort) *CategoryService {
return &CategoryService{
predictor: p,
embedder: e,
}
}
func (s *CategoryService) PredictProductCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
if len(input.Features) != ExpectedFeaturesLen {
return category.PedictionResult{}, ErrInvalidFeaturesLen
}
return s.predictor.PredictCategory(ctx, input)
}
func (s *CategoryService) PredictProductCategoryFromDescription(ctx context.Context, description string) (category.PredictionResult, error) {
if description == "" {
return category.PredictionResult{}, ErrEmptyDescription
}
features, err := s.embedder.EmbedProductDescription(ctx, description)
if err != nil {
return category.PredictionResult{}, err
}
if len(features) != ExpectedFeaturesLen {
return category.PredictionResult{}, ErrInvalidFeaturesLen
}
input := category.PredictionInput{Features: features}
return s.predictor.PredictCategory(ctx, input)
}
Ajuste o module path (
github.com/farsoft-apps/...) para o que você realmente usa no GitHub.
3. Novo adapter de embeddings – HTTP + OpenAI-friendly
Agora vem a parte interessante: trocar o dummy por um client HTTP configurável.
3.1. Config + tipos de provider
Crie o arquivo:
internal/infra/embeddings/client.go:
package embeddings
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/farsoft-apps/product-category-api/internal/domain/category"
)
type Provider string
const (
ProviderOpenAI Provider = "openai"
ProviderHTTP Provider = "http" // sua própria API
)
var (
ErrUnsupportedProvider = errors.New("provider de embeddings não suportado")
)
// Configuração genérica de embeddings
type Config struct {
Provider Provider
// Comum para qualquer HTTP
BaseURL string
APIKey string
// Para OpenAI: nome do modelo, ex: "text-embedding-3-small"
Model string
// Para provider HTTP próprio:
// - você decide o contrato JSON; aqui vou assumir algo simples
// POST { "text": "..."} -> { "embedding": [ ... ] }
CustomPath string // ex: "/v1/embeddings"
Timeout time.Duration
}
// HTTPEmbeddingsClient implementa EmbeddingsPort
type HTTPEmbeddingsClient struct {
httpClient *http.Client
cfg Config
}
// Construtor
func NewHTTPEmbeddingsClient(cfg Config) (*HTTPEmbeddingsClient, error) {
if cfg.Provider == "" {
return nil, fmt.Errorf("Provider é obrigatório")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("BaseURL é obrigatório")
}
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Second
}
client := &HTTPEmbeddingsClient{
httpClient: &http.Client{Timeout: cfg.Timeout},
cfg: cfg,
}
return client, nil
}
// Implementa EmbeddingsPort
func (c *HTTPEmbeddingsClient) EmbedProductDescription(ctx context.Context, description string) ([]float32, error) {
switch c.cfg.Provider {
case ProviderOpenAI:
return c.callOpenAIEmbeddings(ctx, description)
case ProviderHTTP:
return c.callCustomHTTPEmbeddings(ctx, description)
default:
return nil, ErrUnsupportedProvider
}
}
3.2. Caminho OpenAI Embeddings
No mesmo arquivo client.go, abaixo:
// ---------- OPENAI EMBEDDINGS ----------
type openAIEmbeddingsRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type openAIEmbeddingsResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
// BaseURL típico: https://api.openai.com
// Rota: POST /v1/embeddings
func (c *HTTPEmbeddingsClient) callOpenAIEmbeddings(ctx context.Context, description string) ([]float32, error) {
if c.cfg.Model == "" {
return nil, fmt.Errorf("Model é obrigatório para ProviderOpenAI")
}
url := strings.TrimRight(c.cfg.BaseURL, "/") + "/v1/embeddings"
reqBody := openAIEmbeddingsRequest{
Model: c.cfg.Model,
Input: []string{description},
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("erro ao serializar request de embeddings: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("erro ao criar request HTTP: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("erro ao chamar OpenAI embeddings: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("OpenAI embeddings retornou status %d", resp.StatusCode)
}
var apiResp openAIEmbeddingsResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("erro ao decodificar resposta OpenAI: %w", err)
}
if len(apiResp.Data) == 0 {
return nil, fmt.Errorf("resposta OpenAI sem dados de embedding")
}
return float64SliceTo32(apiResp.Data[0].Embedding), nil
}
3.3. Caminho para sua própria API HTTP
Aqui você vai encaixar a sua API escrita em Go (ou outra stack), desde que ela exponha um endpoint HTTP.
Vamos assumir um contrato simples:
-
POST { "text": "..." } -
Resposta:
{ "embedding": [0.1, 0.2, ...] }
Também no client.go:
// ---------- PROVIDER HTTP PRÓPRIO ----------
type customEmbeddingsRequest struct {
Text string `json:"text"`
}
type customEmbeddingsResponse struct {
Embedding []float64 `json:"embedding"`
}
// BaseURL típico: http://meu-embeddings:8080
// Path: CustomPath, ex: /v1/embeddings
func (c *HTTPEmbeddingsClient) callCustomHTTPEmbeddings(ctx context.Context, description string) ([]float32, error) {
path := c.cfg.CustomPath
if path == "" {
path = "/v1/embeddings"
}
url := strings.TrimRight(c.cfg.BaseURL, "/") + path
reqBody := customEmbeddingsRequest{
Text: description,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("erro ao serializar request custom de embeddings: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("erro ao criar request HTTP custom: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("erro ao chamar provider HTTP de embeddings: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("provider HTTP embeddings retornou status %d", resp.StatusCode)
}
var apiResp customEmbeddingsResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("erro ao decodificar resposta custom embeddings: %w", err)
}
if len(apiResp.Embedding) == 0 {
return nil, fmt.Errorf("resposta custom embeddings sem vetor")
}
return float64SliceTo32(apiResp.Embedding), nil
}
3.4. Helper para converter []float64 → []float32
Ainda em client.go:
func float64SliceTo32(in []float64) []float32 {
out := make([]float32, len(in))
for i, v := range in {
out[i] = float32(v)
}
return out
}
4. main.go – montar embedder via env (no estilo que você usa)
Agora no cmd/api/main.go a gente troca:
-
Antes:
embedder := embeddings.NewDummyEmbeddings() -
Agora: montamos um
Configa partir de env vars.
Exemplo:
package main
import (
"context"
"log"
"net/http"
"os"
"time"
ort "github.com/yalue/onnxruntime_go"
"github.com/farsoft-apps/product-category-api/internal/app"
apihttp "github.com/farsoft-apps/product-category-api/internal/http"
"github.com/farsoft-apps/product-category-api/internal/infra/embeddings"
"github.com/farsoft-apps/product-category-api/internal/infra/observability"
"github.com/farsoft-apps/product-category-api/internal/infra/onnx"
)
func main() {
ctx := context.Background()
if libPath := os.Getenv("ONNXRUNTIME_LIB"); libPath != "" {
ort.SetSharedLibraryPath(libPath)
}
if err := ort.InitializeEnvironment(); err != nil {
log.Fatalf("erro inicializando onnxruntime: %v", err)
}
defer func() {
if err := ort.DestroyEnvironment(); err != nil {
log.Printf("erro destruindo ambiente onnxruntime: %v", err)
}
}()
observability.InitMetrics()
shutdownTracer, err := observability.InitTracer(ctx, "product-category-api")
if err != nil {
log.Printf("erro inicializando tracer: %v", err)
} else {
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdownTracer(ctx); err != nil {
log.Printf("erro encerrando tracer: %v", err)
}
}()
}
modelPath := os.Getenv("MODEL_PATH")
if modelPath == "" {
modelPath = "model/model.onnx"
}
classNames := []string{"AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"}
expectedFeatures := 16
predictor, err := onnx.NewOnnxCategoryPredictor(modelPath, classNames, expectedFeatures)
if err != nil {
log.Fatalf("erro criando predictor ONNX: %v", err)
}
// ---------- Embeddings Provider via ENV ----------
// EMBEDDINGS_PROVIDER = "openai" ou "http"
providerStr := os.Getenv("EMBEDDINGS_PROVIDER")
if providerStr == "" {
providerStr = string(embeddings.ProviderHTTP) // default: sua própria API
}
provider := embeddings.Provider(providerStr)
baseURL := os.Getenv("EMBEDDINGS_BASE_URL")
apiKey := os.Getenv("EMBEDDINGS_API_KEY")
modelName := os.Getenv("EMBEDDINGS_MODEL") // para OpenAI, ex: "text-embedding-3-small"
customPath := os.Getenv("EMBEDDINGS_CUSTOM_PATH")
embCfg := embeddings.Config{
Provider: provider,
BaseURL: baseURL,
APIKey: apiKey,
Model: modelName,
CustomPath: customPath,
Timeout: 10 * time.Second,
}
embedder, err := embeddings.NewHTTPEmbeddingsClient(embCfg)
if err != nil {
log.Fatalf("erro criando client de embeddings: %v", err)
}
svc := app.NewCategoryService(predictor, embedder)
handlers := apihttp.NewCategoryHandlers(svc)
router := apihttp.NewRouter(handlers)
addr := ":8000"
log.Printf("API de categorização de produtos ouvindo em %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("erro servidor HTTP: %v", err)
}
}
5. Environment vars no docker-compose
5.1. Caso OpenAI
No docker-compose.yml, para usar OpenAI:
api-go:
build:
context: .
dockerfile: Dockerfile.api-go
volumes:
- ./model:/app/model:ro
environment:
- ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
- MODEL_PATH=/app/model/model.onnx
- OTEL_EXPORTER_OTLP_ENDPOINT=tempo:4317
- EMBEDDINGS_PROVIDER=openai
- EMBEDDINGS_BASE_URL=https://api.openai.com
- EMBEDDINGS_API_KEY=${OPENAI_API_KEY}
- EMBEDDINGS_MODEL=text-embedding-3-small
ports:
- "8000:8000"
depends_on:
- tempo
Aí você só precisa passar
OPENAI_API_KEYno.envou no ambiente do host.
5.2. Caso sua própria API de embeddings em Go
Suponha que você tenha outro serviço:
my-embeddings-api:
image: farsoft/embeddings-api:latest
ports:
- "8081:8080"
E ele exponha POST /v1/embeddings com o contrato { "text": "..."} -> { "embedding": [...] }.
Aí:
api-go:
...
environment:
- ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
- MODEL_PATH=/app/model/model.onnx
- OTEL_EXPORTER_OTLP_ENDPOINT=tempo:4317
- EMBEDDINGS_PROVIDER=http
- EMBEDDINGS_BASE_URL=http://my-embeddings-api:8080
- EMBEDDINGS_CUSTOM_PATH=/v1/embeddings
- EMBEDDINGS_API_KEY= # se precisar auth, você define
6. Testando com descrição (sem mais dummy)
curl -X POST http://localhost:8000/predict-description \
-H "Content-Type: application/json" \
-d '{"descricao": "Kit de embreagem para Gol 1.6 8v"}'
Fluxo:
-
Handler recebe a descrição.
-
Service chama
embedder.EmbedProductDescription. -
Esse embedder:
-
Vai na OpenAI (
/v1/embeddings) ou na sua API Go (/v1/embeddingsprópria). -
Converte
[]float64para[]float32.
-
-
Service valida o tamanho (16 – baseado no modelo treinado).
-
ONNX predictor recebe as features, executa o modelo e devolve a categoria.
-
Handler retorna JSON com
category_code,category_nameeprobabilities.
Ver depois:
07 - Formatando a minha maneira