| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| package weaviate_vector_search |
| |
| import ( |
| "context" |
| "crypto/sha1" |
| "embed" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "net/http" |
| "strconv" |
| "strings" |
| "sync" |
| |
| "github.com/apache/answer-plugins/util" |
| "github.com/apache/answer-plugins/vector-search-weaviate/i18n" |
| "github.com/apache/answer/plugin" |
| "github.com/segmentfault/pacman/log" |
| "github.com/weaviate/weaviate-go-client/v4/weaviate" |
| "github.com/weaviate/weaviate-go-client/v4/weaviate/auth" |
| "github.com/weaviate/weaviate-go-client/v4/weaviate/fault" |
| "github.com/weaviate/weaviate-go-client/v4/weaviate/graphql" |
| "github.com/weaviate/weaviate/entities/models" |
| ) |
| |
| //go:embed info.yaml |
| var Info embed.FS |
| |
| const ( |
| className = "AnswerVector" |
| ) |
| |
| // VectorSearchEngine implements plugin.VectorSearch using Weaviate. |
| type VectorSearchEngine struct { |
| Config *VectorSearchConfig |
| client *weaviate.Client |
| syncer plugin.VectorSearchSyncer |
| syncing bool |
| lock sync.Mutex |
| } |
| |
| // VectorSearchConfig holds all plugin configuration. |
| type VectorSearchConfig struct { |
| Endpoint string `json:"endpoint"` |
| APIKey string `json:"api_key"` |
| EmbeddingAPIHost string `json:"embedding_api_host"` |
| EmbeddingAPIKey string `json:"embedding_api_key"` |
| EmbeddingModel string `json:"embedding_model"` |
| EmbeddingLevel string `json:"embedding_level"` |
| SimilarityThreshold float64 `json:"similarity_threshold"` |
| } |
| |
| func init() { |
| plugin.Register(&VectorSearchEngine{ |
| Config: &VectorSearchConfig{}, |
| lock: sync.Mutex{}, |
| }) |
| } |
| |
| func (e *VectorSearchEngine) Info() plugin.Info { |
| info := &util.Info{} |
| info.GetInfo(Info) |
| |
| return plugin.Info{ |
| Name: plugin.MakeTranslator(i18n.InfoName), |
| SlugName: info.SlugName, |
| Description: plugin.MakeTranslator(i18n.InfoDescription), |
| Author: info.Author, |
| Version: info.Version, |
| Link: info.Link, |
| } |
| } |
| |
| // Description returns metadata about this vector search engine. |
| func (e *VectorSearchEngine) Description() plugin.VectorSearchDesc { |
| return plugin.VectorSearchDesc{ |
| Icon: "", |
| Link: "https://weaviate.io/developers/weaviate", |
| } |
| } |
| |
| // RegisterSyncer stores the syncer and triggers a full sync. |
| func (e *VectorSearchEngine) RegisterSyncer(ctx context.Context, syncer plugin.VectorSearchSyncer) { |
| e.syncer = syncer |
| if e.client != nil { |
| e.sync() |
| } |
| } |
| |
| // SearchSimilar performs a nearVector search in Weaviate. |
| func (e *VectorSearchEngine) SearchSimilar(ctx context.Context, query string, topK int) ([]plugin.VectorSearchResult, error) { |
| if e.client == nil { |
| return nil, fmt.Errorf("weaviate client not initialized") |
| } |
| if topK <= 0 { |
| topK = 10 |
| } |
| |
| log.Debugf("weaviate: SearchSimilar query=%q topK=%d model=%s", query, topK, e.Config.EmbeddingModel) |
| |
| // Generate embedding for the query text. |
| log.Debugf("weaviate: generating query embedding via %s model=%s", e.Config.EmbeddingAPIHost, e.Config.EmbeddingModel) |
| embedding, err := plugin.GenerateEmbedding(ctx, e.Config.EmbeddingAPIHost, e.Config.EmbeddingAPIKey, e.Config.EmbeddingModel, query) |
| if err != nil { |
| log.Errorf("weaviate: generate query embedding failed: %v", err) |
| return nil, fmt.Errorf("generate query embedding: %w", err) |
| } |
| log.Debugf("weaviate: query embedding generated, dimensions=%d", len(embedding)) |
| |
| embeddingFloats := make([]float32, len(embedding)) |
| copy(embeddingFloats, embedding) |
| |
| nearVector := e.client.GraphQL().NearVectorArgBuilder().WithVector(embeddingFloats) |
| |
| fields := []graphql.Field{ |
| {Name: "objectID"}, |
| {Name: "objectType"}, |
| {Name: "title"}, |
| {Name: "metadata"}, |
| {Name: "_additional", Fields: []graphql.Field{ |
| {Name: "certainty"}, |
| {Name: "id"}, |
| }}, |
| } |
| |
| log.Debugf("weaviate: executing nearVector search on class=%s limit=%d", className, topK) |
| result, err := e.client.GraphQL().Get(). |
| WithClassName(className). |
| WithFields(fields...). |
| WithNearVector(nearVector). |
| WithLimit(topK). |
| Do(ctx) |
| if err != nil { |
| log.Errorf("weaviate: nearVector search failed: %v", err) |
| return nil, fmt.Errorf("weaviate nearVector search failed: %w", err) |
| } |
| |
| if result.Errors != nil { |
| msgs := make([]string, 0, len(result.Errors)) |
| for _, e := range result.Errors { |
| msgs = append(msgs, e.Message) |
| } |
| log.Errorf("weaviate: GraphQL errors: %s", strings.Join(msgs, "; ")) |
| return nil, fmt.Errorf("weaviate GraphQL errors: %s", strings.Join(msgs, "; ")) |
| } |
| |
| data, ok := result.Data["Get"].(map[string]interface{}) |
| if !ok { |
| log.Warnf("weaviate: unexpected response format from GraphQL") |
| return nil, fmt.Errorf("unexpected response format") |
| } |
| objects, ok := data[className].([]interface{}) |
| if !ok { |
| log.Debugf("weaviate: no objects returned from search") |
| return nil, nil |
| } |
| |
| log.Debugf("weaviate: search returned %d raw results", len(objects)) |
| |
| results := make([]plugin.VectorSearchResult, 0, len(objects)) |
| filtered := 0 |
| for _, obj := range objects { |
| m, ok := obj.(map[string]interface{}) |
| if !ok { |
| continue |
| } |
| |
| objectID, _ := m["objectID"].(string) |
| objectType, _ := m["objectType"].(string) |
| metadata, _ := m["metadata"].(string) |
| |
| score := 0.0 |
| if additional, ok := m["_additional"].(map[string]interface{}); ok { |
| if certainty, ok := additional["certainty"].(float64); ok { |
| // Weaviate certainty is in [0, 1] where 1 = identical. |
| score = certainty |
| } |
| } |
| |
| if e.Config.SimilarityThreshold > 0 && score < e.Config.SimilarityThreshold { |
| log.Debugf("weaviate: filtered out objectID=%s score=%.4f < threshold=%.4f", objectID, score, e.Config.SimilarityThreshold) |
| filtered++ |
| continue |
| } |
| |
| log.Debugf("weaviate: result objectID=%s objectType=%s score=%.4f", objectID, objectType, score) |
| results = append(results, plugin.VectorSearchResult{ |
| ObjectID: objectID, |
| ObjectType: objectType, |
| Metadata: metadata, |
| Score: score, |
| }) |
| } |
| |
| log.Debugf("weaviate: SearchSimilar done, returning %d results (%d filtered by threshold)", len(results), filtered) |
| return results, nil |
| } |
| |
| // UpdateContent upserts a single document into Weaviate. |
| func (e *VectorSearchEngine) UpdateContent(ctx context.Context, content *plugin.VectorSearchContent) error { |
| if e.client == nil { |
| return fmt.Errorf("weaviate client not initialized") |
| } |
| |
| log.Debugf("weaviate: UpdateContent objectID=%s objectType=%s title=%q contentLen=%d", |
| content.ObjectID, content.ObjectType, content.Title, len(content.Content)) |
| |
| log.Debugf("weaviate: generating embedding for objectID=%s via %s model=%s", |
| content.ObjectID, e.Config.EmbeddingAPIHost, e.Config.EmbeddingModel) |
| embedding, err := plugin.GenerateEmbedding(ctx, e.Config.EmbeddingAPIHost, e.Config.EmbeddingAPIKey, e.Config.EmbeddingModel, content.Content) |
| if err != nil { |
| log.Errorf("weaviate: generate embedding for %s failed: %v", content.ObjectID, err) |
| return fmt.Errorf("generate embedding for %s: %w", content.ObjectID, err) |
| } |
| log.Debugf("weaviate: embedding generated for objectID=%s dimensions=%d", content.ObjectID, len(embedding)) |
| |
| embeddingFloats := make([]float32, len(embedding)) |
| copy(embeddingFloats, embedding) |
| |
| properties := map[string]interface{}{ |
| "objectID": content.ObjectID, |
| "objectType": content.ObjectType, |
| "title": content.Title, |
| "content": content.Content, |
| "metadata": content.Metadata, |
| } |
| |
| // Use deterministic UUID so repeated upserts for the same objectID overwrite. |
| uuid := deterministicUUID(content.ObjectID) |
| log.Debugf("weaviate: upsert objectID=%s uuid=%s", content.ObjectID, uuid) |
| |
| // Check if object already exists. |
| _, err = e.client.Data().ObjectsGetter(). |
| WithClassName(className). |
| WithID(uuid). |
| Do(ctx) |
| if err == nil { |
| // Object exists, update it. |
| log.Debugf("weaviate: updating existing object objectID=%s uuid=%s", content.ObjectID, uuid) |
| err = e.client.Data().Updater(). |
| WithClassName(className). |
| WithID(uuid). |
| WithProperties(properties). |
| WithVector(embeddingFloats). |
| Do(ctx) |
| } else { |
| // Object does not exist, create it. |
| log.Debugf("weaviate: creating new object objectID=%s uuid=%s", content.ObjectID, uuid) |
| _, err = e.client.Data().Creator(). |
| WithClassName(className). |
| WithProperties(properties). |
| WithVector(embeddingFloats). |
| WithID(uuid). |
| Do(ctx) |
| } |
| |
| if err != nil { |
| log.Errorf("weaviate: upsert document %s failed: %v", content.ObjectID, err) |
| return fmt.Errorf("upsert document %s: %w", content.ObjectID, err) |
| } |
| log.Debugf("weaviate: upsert objectID=%s done", content.ObjectID) |
| return nil |
| } |
| |
| // DeleteContent removes a document by object ID. |
| func (e *VectorSearchEngine) DeleteContent(ctx context.Context, objectID string) error { |
| if e.client == nil { |
| return fmt.Errorf("weaviate client not initialized") |
| } |
| |
| // Delete using deterministic UUID. |
| uuid := deterministicUUID(objectID) |
| log.Debugf("weaviate: DeleteContent objectID=%s uuid=%s", objectID, uuid) |
| err := e.client.Data().Deleter(). |
| WithClassName(className). |
| WithID(uuid). |
| Do(ctx) |
| if err != nil { |
| log.Errorf("weaviate: delete document %s failed: %v", objectID, err) |
| return fmt.Errorf("delete document %s: %w", objectID, err) |
| } |
| log.Debugf("weaviate: deleted objectID=%s", objectID) |
| return nil |
| } |
| |
| // ConfigFields returns the plugin configuration form fields. |
| func (e *VectorSearchEngine) ConfigFields() []plugin.ConfigField { |
| return []plugin.ConfigField{ |
| { |
| Name: "endpoint", |
| Type: plugin.ConfigTypeInput, |
| Title: plugin.MakeTranslator(i18n.ConfigEndpointTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigEndpointDescription), |
| Required: true, |
| UIOptions: plugin.ConfigFieldUIOptions{ |
| InputType: plugin.InputTypeText, |
| }, |
| Value: e.Config.Endpoint, |
| }, |
| { |
| Name: "api_key", |
| Type: plugin.ConfigTypeInput, |
| Title: plugin.MakeTranslator(i18n.ConfigAPIKeyTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigAPIKeyDescription), |
| Required: false, |
| UIOptions: plugin.ConfigFieldUIOptions{ |
| InputType: plugin.InputTypePassword, |
| }, |
| Value: e.Config.APIKey, |
| }, |
| { |
| Name: "embedding_api_host", |
| Type: plugin.ConfigTypeInput, |
| Title: plugin.MakeTranslator(i18n.ConfigEmbeddingAPIHostTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigEmbeddingAPIHostDescription), |
| Required: true, |
| UIOptions: plugin.ConfigFieldUIOptions{ |
| InputType: plugin.InputTypeText, |
| }, |
| Value: e.Config.EmbeddingAPIHost, |
| }, |
| { |
| Name: "embedding_api_key", |
| Type: plugin.ConfigTypeInput, |
| Title: plugin.MakeTranslator(i18n.ConfigEmbeddingAPIKeyTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigEmbeddingAPIKeyDescription), |
| Required: true, |
| UIOptions: plugin.ConfigFieldUIOptions{ |
| InputType: plugin.InputTypePassword, |
| }, |
| Value: e.Config.EmbeddingAPIKey, |
| }, |
| { |
| Name: "embedding_model", |
| Type: plugin.ConfigTypeInput, |
| Title: plugin.MakeTranslator(i18n.ConfigEmbeddingModelTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigEmbeddingModelDescription), |
| Required: true, |
| UIOptions: plugin.ConfigFieldUIOptions{ |
| InputType: plugin.InputTypeText, |
| }, |
| Value: e.Config.EmbeddingModel, |
| }, |
| { |
| Name: "embedding_level", |
| Type: plugin.ConfigTypeSelect, |
| Title: plugin.MakeTranslator(i18n.ConfigEmbeddingLevelTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigEmbeddingLevelDescription), |
| Required: true, |
| Options: []plugin.ConfigFieldOption{ |
| {Label: plugin.MakeTranslator(i18n.ConfigEmbeddingLevelOptionQuestion), Value: "question"}, |
| {Label: plugin.MakeTranslator(i18n.ConfigEmbeddingLevelOptionAnswer), Value: "answer"}, |
| }, |
| Value: e.Config.EmbeddingLevel, |
| }, |
| { |
| Name: "similarity_threshold", |
| Type: plugin.ConfigTypeInput, |
| Title: plugin.MakeTranslator(i18n.ConfigSimilarityThresholdTitle), |
| Description: plugin.MakeTranslator(i18n.ConfigSimilarityThresholdDescription), |
| Required: false, |
| UIOptions: plugin.ConfigFieldUIOptions{ |
| InputType: plugin.InputTypeText, |
| }, |
| Value: fmt.Sprintf("%.2f", e.Config.SimilarityThreshold), |
| }, |
| } |
| } |
| |
| // ConfigReceiver applies configuration from the admin UI. |
| func (e *VectorSearchEngine) ConfigReceiver(config []byte) error { |
| // Pre-process: convert string numbers to actual numbers before unmarshalling, |
| // because the admin UI sends all form values as strings. |
| var raw map[string]interface{} |
| if err := json.Unmarshal(config, &raw); err != nil { |
| return fmt.Errorf("unmarshal config: %w", err) |
| } |
| if threshStr, ok := raw["similarity_threshold"].(string); ok { |
| if thresh, err := strconv.ParseFloat(threshStr, 64); err == nil { |
| raw["similarity_threshold"] = thresh |
| } |
| } |
| fixed, _ := json.Marshal(raw) |
| |
| conf := &VectorSearchConfig{} |
| if err := json.Unmarshal(fixed, conf); err != nil { |
| return fmt.Errorf("unmarshal config: %w", err) |
| } |
| |
| e.Config = conf |
| |
| if !plugin.StatusManager.IsEnabled("weaviate_vector_search") { |
| log.Debugf("weaviate: plugin not active, skipping initialization") |
| return nil |
| } |
| |
| log.Debugf("weaviate: initializing client: endpoint=%s scheme=%s host=%s apiKey=%v", |
| conf.Endpoint, parseScheme(conf.Endpoint), stripScheme(conf.Endpoint), conf.APIKey != "") |
| log.Debugf("weaviate: embedding config: host=%s model=%s level=%s threshold=%.2f", |
| conf.EmbeddingAPIHost, conf.EmbeddingModel, conf.EmbeddingLevel, conf.SimilarityThreshold) |
| |
| cfg := weaviate.Config{ |
| Host: stripScheme(conf.Endpoint), |
| Scheme: parseScheme(conf.Endpoint), |
| } |
| if conf.APIKey != "" { |
| cfg.AuthConfig = auth.ApiKey{Value: conf.APIKey} |
| } |
| |
| client, err := weaviate.NewClient(cfg) |
| if err != nil { |
| return fmt.Errorf("create weaviate client: %w", err) |
| } |
| |
| // Verify connectivity. |
| log.Debugf("weaviate: checking liveness at %s", conf.Endpoint) |
| live, err := client.Misc().LiveChecker().Do(context.Background()) |
| if err != nil || !live { |
| log.Errorf("weaviate: liveness check failed: live=%v err=%v", live, err) |
| return fmt.Errorf("weaviate not reachable at %s: %v", conf.Endpoint, err) |
| } |
| log.Debugf("weaviate: liveness check passed") |
| e.client = client |
| |
| // Create/ensure the class with vector configuration. |
| // If it fails with an auth error (401/403) and an API key was set, retry |
| // without auth in case the server is running in anonymous-access mode. |
| if err := e.ensureClass(context.Background()); err != nil { |
| if conf.APIKey != "" && isAuthError(err) { |
| log.Debugf("weaviate: ensureClass failed with auth error, retrying without auth (anonymous access)") |
| cfg.AuthConfig = nil |
| client, err = weaviate.NewClient(cfg) |
| if err != nil { |
| return fmt.Errorf("create weaviate client: %w", err) |
| } |
| e.client = client |
| if err := e.ensureClass(context.Background()); err != nil { |
| return fmt.Errorf("ensure class: %w", err) |
| } |
| } else { |
| return fmt.Errorf("ensure class: %w", err) |
| } |
| } |
| |
| return nil |
| } |
| |
| // isAuthError reports whether err is a Weaviate client error carrying a 401 or |
| // 403 status code, i.e. an authentication/authorization failure. |
| func isAuthError(err error) bool { |
| var clientErr *fault.WeaviateClientError |
| if errors.As(err, &clientErr) { |
| return clientErr.StatusCode == http.StatusUnauthorized || |
| clientErr.StatusCode == http.StatusForbidden |
| } |
| return false |
| } |
| |
| // ensureClass creates the Weaviate class if it doesn't exist. |
| func (e *VectorSearchEngine) ensureClass(ctx context.Context) error { |
| log.Debugf("weaviate: checking if class %s exists", className) |
| exists, err := e.client.Schema().ClassExistenceChecker(). |
| WithClassName(className). |
| Do(ctx) |
| if err != nil { |
| log.Errorf("weaviate: class existence check failed: %v", err) |
| return err |
| } |
| if exists { |
| log.Debugf("weaviate: class %s already exists", className) |
| return nil |
| } |
| |
| log.Debugf("weaviate: creating class %s with vectorizer=none", className) |
| |
| classObj := &models.Class{ |
| Class: className, |
| Description: "Answer vector search documents", |
| VectorIndexType: "hnsw", |
| Properties: []*models.Property{ |
| { |
| Name: "objectID", |
| DataType: []string{"text"}, |
| Description: "Unique object identifier (question or answer ID)", |
| Tokenization: "field", |
| }, |
| { |
| Name: "objectType", |
| DataType: []string{"text"}, |
| Description: "Type of object: question or answer", |
| Tokenization: "field", |
| }, |
| { |
| Name: "title", |
| DataType: []string{"text"}, |
| Description: "Title of the question", |
| }, |
| { |
| Name: "content", |
| DataType: []string{"text"}, |
| Description: "Aggregated text content", |
| }, |
| { |
| Name: "metadata", |
| DataType: []string{"text"}, |
| Description: "JSON metadata for link composition", |
| Tokenization: "field", |
| }, |
| }, |
| // Use "none" vectorizer since we supply vectors externally via GenerateEmbedding. |
| Vectorizer: "none", |
| } |
| |
| err = e.client.Schema().ClassCreator(). |
| WithClass(classObj). |
| Do(ctx) |
| if err != nil { |
| return fmt.Errorf("create class %s: %w", className, err) |
| } |
| log.Debugf("weaviate: class %s created successfully", className) |
| return nil |
| } |
| |
| // deterministicUUID generates a deterministic UUID v5 from the objectID string. |
| func deterministicUUID(objectID string) string { |
| h := sha1.Sum([]byte(objectID)) |
| // Set version bits to 5 (0101) and variant bits to RFC 4122 (10). |
| h[6] = (h[6] & 0x0f) | 0x50 |
| h[8] = (h[8] & 0x3f) | 0x80 |
| return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", |
| h[0:4], h[4:6], h[6:8], h[8:10], h[10:16]) |
| } |
| |
| // stripScheme removes the http:// or https:// prefix from a URL. |
| func stripScheme(endpoint string) string { |
| endpoint = strings.TrimPrefix(endpoint, "https://") |
| endpoint = strings.TrimPrefix(endpoint, "http://") |
| return strings.TrimRight(endpoint, "/") |
| } |
| |
| // parseScheme extracts the scheme from a URL, defaulting to "http". |
| func parseScheme(endpoint string) string { |
| if strings.HasPrefix(endpoint, "https://") { |
| return "https" |
| } |
| return "http" |
| } |