WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package opensearch
|
||||
|
||||
import "context"
|
||||
|
||||
// AuditSink receives audit events emitted from within the driver at the
|
||||
// exact moment they occur (index provisioned, reindex executed). The driver
|
||||
// owns this abstraction so it imports no service package — the dependency
|
||||
// arrow stays one-way (service implements AuditSink; the driver only invokes
|
||||
// it). A nil sink is a no-op, so tests and the env-path (no audit service)
|
||||
// need no special casing.
|
||||
type AuditSink interface {
|
||||
// EmitIndexCreated fires once when the driver provisions a new k-NN
|
||||
// index. alias is the per-dimension alias (or the keyword-only index
|
||||
// name); dim is the embedding dimension (0 for the keyword-only index).
|
||||
EmitIndexCreated(ctx context.Context, alias string, dim int)
|
||||
// EmitReindexExecuted fires when CopyIndices finishes copying docs from
|
||||
// one index to another.
|
||||
EmitReindexExecuted(ctx context.Context, srcAlias, dstAlias string, docs int64)
|
||||
}
|
||||
|
||||
// nopSink is the null-object used when no sink is configured, so emit call
|
||||
// sites never need a nil check.
|
||||
type nopSink struct{}
|
||||
|
||||
func (nopSink) EmitIndexCreated(context.Context, string, int) {}
|
||||
func (nopSink) EmitReindexExecuted(context.Context, string, string, int64) {}
|
||||
|
||||
var _ AuditSink = nopSink{}
|
||||
|
||||
// Option configures a Repository at construction time.
|
||||
type Option func(*Repository)
|
||||
|
||||
// WithAuditSink injects an audit sink. A nil sink is ignored (the Repository
|
||||
// keeps its default no-op behavior).
|
||||
func WithAuditSink(s AuditSink) Option {
|
||||
return func(r *Repository) {
|
||||
if s != nil {
|
||||
r.sink = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// auditSink returns the configured sink, or a no-op if none was set (e.g. a
|
||||
// Repository built directly in tests, or constructed without WithAuditSink).
|
||||
func (r *Repository) auditSink() AuditSink {
|
||||
if r.sink == nil {
|
||||
return nopSink{}
|
||||
}
|
||||
return r.sink
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// spySink records audit events for assertions.
|
||||
type spySink struct {
|
||||
mu sync.Mutex
|
||||
indexCreated []indexCreatedEvent
|
||||
reindex []reindexEvent
|
||||
}
|
||||
|
||||
type indexCreatedEvent struct {
|
||||
alias string
|
||||
dim int
|
||||
}
|
||||
type reindexEvent struct {
|
||||
src, dst string
|
||||
docs int64
|
||||
}
|
||||
|
||||
func (s *spySink) EmitIndexCreated(_ context.Context, alias string, dim int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.indexCreated = append(s.indexCreated, indexCreatedEvent{alias, dim})
|
||||
}
|
||||
|
||||
func (s *spySink) EmitReindexExecuted(_ context.Context, src, dst string, docs int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.reindex = append(s.reindex, reindexEvent{src, dst, docs})
|
||||
}
|
||||
|
||||
// TestBuildInternalCfg_ReadsHNSWFields verifies the HNSW IndexConfig fields
|
||||
// flow into internalCfg, falling back to defaults when unset.
|
||||
func TestBuildInternalCfg_ReadsHNSWFields(t *testing.T) {
|
||||
t.Run("reads set fields", func(t *testing.T) {
|
||||
cfg, err := buildInternalCfg(&types.IndexConfig{
|
||||
HNSWM: 24,
|
||||
HNSWEFConstruction: 200,
|
||||
HNSWEFSearch: 128,
|
||||
KNNEngine: "faiss",
|
||||
NumberOfShards: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.hnswM != 24 || cfg.hnswEFConstruction != 200 || cfg.efSearch != 128 || cfg.knnEngine != "faiss" {
|
||||
t.Errorf("HNSW not wired: %+v", cfg)
|
||||
}
|
||||
if cfg.shards != 2 {
|
||||
t.Errorf("shards: want 2, got %d", cfg.shards)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults when unset", func(t *testing.T) {
|
||||
cfg, err := buildInternalCfg(&types.IndexConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.hnswM != 16 || cfg.hnswEFConstruction != 100 || cfg.efSearch != 100 || cfg.knnEngine != "lucene" {
|
||||
t.Errorf("defaults not applied: %+v", cfg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil config is all defaults", func(t *testing.T) {
|
||||
cfg, err := buildInternalCfg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.knnEngine != "lucene" || cfg.hnswM != 16 {
|
||||
t.Errorf("nil defaults wrong: %+v", cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuildIndexMapping_ReflectsHNSWConfig verifies the end-to-end path
|
||||
// IndexConfig → buildInternalCfg → buildIndexMapping carries the operator's
|
||||
// HNSW values into the cluster mapping JSON (regression guard for the wire-
|
||||
// through, since defaults coincide with common values).
|
||||
func TestBuildIndexMapping_ReflectsHNSWConfig(t *testing.T) {
|
||||
cfg, err := buildInternalCfg(&types.IndexConfig{
|
||||
HNSWM: 24,
|
||||
HNSWEFConstruction: 200,
|
||||
HNSWEFSearch: 128,
|
||||
KNNEngine: "faiss",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := buildIndexMapping(cfg, 768)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, want := range []string{`"m":24`, `"ef_construction":200`, `"engine":"faiss"`, `"knn.algo_param.ef_search":128`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("mapping JSON missing %q\n%s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithAuditSink_SetsAndNilSafe verifies the functional option.
|
||||
func TestWithAuditSink_SetsAndNilSafe(t *testing.T) {
|
||||
spy := &spySink{}
|
||||
r := &Repository{}
|
||||
WithAuditSink(spy)(r)
|
||||
if r.sink != spy {
|
||||
t.Fatal("WithAuditSink did not set the sink")
|
||||
}
|
||||
// nil must not clobber an already-set sink
|
||||
WithAuditSink(nil)(r)
|
||||
if r.sink != spy {
|
||||
t.Fatal("WithAuditSink(nil) clobbered the sink")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditSink_NopByDefault verifies a Repository with no sink does not panic
|
||||
// when the audit accessor is used (nopSink fallback).
|
||||
func TestAuditSink_NopByDefault(t *testing.T) {
|
||||
var _ AuditSink = nopSink{} // compile-time assertion
|
||||
r := &Repository{} // sink left nil
|
||||
// Must not panic.
|
||||
r.auditSink().EmitIndexCreated(context.Background(), "weknora_768", 768)
|
||||
r.auditSink().EmitReindexExecuted(context.Background(), "a", "b", 3)
|
||||
}
|
||||
|
||||
// TestAuditSink_EmitIndexCreated_OnEnsureReady verifies createIndexAndAlias
|
||||
// emits exactly one index-created event with the per-dim alias when a new
|
||||
// index is provisioned.
|
||||
func TestAuditSink_EmitIndexCreated_OnEnsureReady(t *testing.T) {
|
||||
repo, ts := newTestRepo(t, (&indexLifecycleHandler{}).ServeHTTP)
|
||||
defer ts.Close()
|
||||
spy := &spySink{}
|
||||
repo.sink = spy
|
||||
|
||||
if err := repo.ensureReady(context.Background(), 768); err != nil {
|
||||
t.Fatalf("ensureReady: %v", err)
|
||||
}
|
||||
if len(spy.indexCreated) != 1 {
|
||||
t.Fatalf("want 1 index_created event, got %d", len(spy.indexCreated))
|
||||
}
|
||||
got := spy.indexCreated[0]
|
||||
if got.alias != "weknora_test_768" || got.dim != 768 {
|
||||
t.Errorf("event mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
)
|
||||
|
||||
// BatchUpdateChunkEnabledStatus flips is_enabled for the given chunks. The
|
||||
// status map is grouped by value into one _update_by_query per distinct value
|
||||
// (mirrors the Qdrant grouping pattern), so the request body carries the
|
||||
// chunk ids via a terms filter and the new value via bound script params —
|
||||
// never per-chunk string interpolation.
|
||||
//
|
||||
// Targets the cross-dim <base>_* pattern: a chunk's embedding dimension is
|
||||
// not known here, and the same chunk_id is unique across the store's dim
|
||||
// indices + the keyword-only index.
|
||||
func (r *Repository) BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool) error {
|
||||
if len(chunkStatusMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
groups := map[bool][]string{}
|
||||
for id, v := range chunkStatusMap {
|
||||
groups[v] = append(groups[v], id)
|
||||
}
|
||||
// Deterministic order (false then true) for predictable behavior/tests.
|
||||
for _, v := range []bool{false, true} {
|
||||
ids := groups[v]
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(ids)
|
||||
if err := r.updateByQueryScript(ctx, ids,
|
||||
"ctx._source.is_enabled = params.v", map[string]any{"v": v}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchUpdateChunkTagID sets tag_id for the given chunks, grouped by tag.
|
||||
func (r *Repository) BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string) error {
|
||||
if len(chunkTagMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
groups := map[string][]string{}
|
||||
for id, tag := range chunkTagMap {
|
||||
groups[tag] = append(groups[tag], id)
|
||||
}
|
||||
tags := make([]string, 0, len(groups))
|
||||
for tag := range groups {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
for _, tag := range tags {
|
||||
ids := groups[tag]
|
||||
sort.Strings(ids)
|
||||
if err := r.updateByQueryScript(ctx, ids,
|
||||
"ctx._source.tag_id = params.v", map[string]any{"v": tag}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateByQueryScript runs an _update_by_query over the cross-dim <base>_*
|
||||
// pattern, matching the given chunk ids via a terms filter and applying a
|
||||
// constant Painless source with caller values flowing only through bound
|
||||
// params (Painless-injection-safe).
|
||||
func (r *Repository) updateByQueryScript(
|
||||
ctx context.Context, chunkIDs []string, source string, params map[string]any,
|
||||
) error {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"query": map[string]any{
|
||||
"terms": map[string]any{"chunk_id": chunkIDs},
|
||||
},
|
||||
"script": map[string]any{
|
||||
"lang": "painless",
|
||||
"source": source,
|
||||
"params": params,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensearch: marshal update_by_query body: %w", err)
|
||||
}
|
||||
// Q2: UpdateByQueryParams.Refresh is *bool — the wire value "wait_for" is
|
||||
// not expressible via the typed SDK, so we force an immediate refresh.
|
||||
refresh := true
|
||||
resp, err := r.client.UpdateByQuery(ctx, osapi.UpdateByQueryReq{
|
||||
Indices: []string{r.baseIndex + "_*"},
|
||||
Body: bytes.NewReader(body),
|
||||
Params: osapi.UpdateByQueryParams{Refresh: &refresh},
|
||||
})
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
defer drainAndClose(resp.Inspect().Response.Body)
|
||||
return inspectByQueryResponse(io.LimitReader(resp.Inspect().Response.Body, 16<<20))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// inspectByQueryResponse parses an _update_by_query / _delete_by_query
|
||||
// response and surfaces partial failures without leaking cluster-side reason
|
||||
// strings (which may embed document content). Mirrors inspectBulkResponse:
|
||||
// the full reason goes to the debug log only; the returned error carries the
|
||||
// bounded id + type. A non-zero version_conflicts count with no hard failures
|
||||
// is logged as a warning but not treated as an error.
|
||||
func inspectByQueryResponse(body io.Reader) error {
|
||||
var r struct {
|
||||
VersionConflicts int `json:"version_conflicts"`
|
||||
Failures []struct {
|
||||
ID string `json:"id"`
|
||||
Cause struct {
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"cause"`
|
||||
} `json:"failures"`
|
||||
}
|
||||
if err := json.NewDecoder(body).Decode(&r); err != nil {
|
||||
return fmt.Errorf("opensearch: parse by-query response: %w", ErrTransport)
|
||||
}
|
||||
log := logger.GetLogger(context.Background())
|
||||
if len(r.Failures) == 0 {
|
||||
if r.VersionConflicts > 0 {
|
||||
log.Warnf("[OpenSearch] by-query had %d version conflicts (proceeded)", r.VersionConflicts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var msgs []string
|
||||
for _, f := range r.Failures {
|
||||
// Full reason → debug log only (may contain document content).
|
||||
log.Debugf("[OpenSearch] by-query failure: id=%s type=%s reason=%s",
|
||||
f.ID, f.Cause.Type, f.Cause.Reason)
|
||||
if len(msgs) < 5 {
|
||||
msgs = append(msgs, fmt.Sprintf("[%s] %s", f.ID, f.Cause.Type))
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("opensearch: by-query partial failure (%d failed, first 5: %s): %w",
|
||||
len(r.Failures), strings.Join(msgs, "; "), ErrTransport)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package opensearch
|
||||
|
||||
import "github.com/Tencent/WeKnora/internal/types"
|
||||
|
||||
// internalCfg is the driver-internal, immutable view of IndexConfig.
|
||||
// Defaults are chosen so the env-path (no IndexConfig) and DB-store path
|
||||
// (with IndexConfig) produce identical mappings.
|
||||
type internalCfg struct {
|
||||
shards int
|
||||
replicas int
|
||||
knnEngine string // "lucene" | "faiss"
|
||||
hnswM int
|
||||
hnswEFConstruction int
|
||||
efSearch int
|
||||
}
|
||||
|
||||
// buildInternalCfg projects IndexConfig to the driver-internal view,
|
||||
// substituting defaults for unset (zero / empty) fields. Validation of value
|
||||
// ranges (e.g. hnsw_m / ef_construction caps) is a service-layer concern
|
||||
// handled elsewhere (validateOpenSearchIndexConfig at CreateStore); this
|
||||
// function applies defaults only and never rejects. The env-path bypasses
|
||||
// service validation entirely, so the defaults below are its safety net.
|
||||
//
|
||||
// The OpenSearch-specific HNSW fields (knn_engine, hnsw_m,
|
||||
// hnsw_ef_construction, hnsw_ef_search) are read here. They are omitempty on
|
||||
// IndexConfig, so they do not affect other drivers' serialized config.
|
||||
func buildInternalCfg(c *types.IndexConfig) (internalCfg, error) {
|
||||
cfg := internalCfg{
|
||||
shards: 4, // matches the keyword-index default upstream
|
||||
replicas: 1, // assumes >= 2-node cluster
|
||||
knnEngine: "lucene", // OS default; Faiss preferred only at >= 10M docs
|
||||
hnswM: 16, // OS official default
|
||||
hnswEFConstruction: 100, // OS official default
|
||||
efSearch: 100, // OS default
|
||||
}
|
||||
if c == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
if c.NumberOfShards > 0 {
|
||||
cfg.shards = c.NumberOfShards
|
||||
}
|
||||
if c.NumberOfReplicas > 0 {
|
||||
cfg.replicas = c.NumberOfReplicas
|
||||
}
|
||||
if c.KNNEngine != "" {
|
||||
cfg.knnEngine = c.KNNEngine
|
||||
}
|
||||
if c.HNSWM > 0 {
|
||||
cfg.hnswM = c.HNSWM
|
||||
}
|
||||
if c.HNSWEFConstruction > 0 {
|
||||
cfg.hnswEFConstruction = c.HNSWEFConstruction
|
||||
}
|
||||
if c.HNSWEFSearch > 0 {
|
||||
cfg.efSearch = c.HNSWEFSearch
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// copyBatchSize is the pagination size for the source scan. Kept under the
|
||||
// BatchSave per-call cap so each copied page is a single bulk request.
|
||||
const copyBatchSize = 500
|
||||
|
||||
// copySourceDoc is the full _source read during CopyIndices — it includes the
|
||||
// embedding vector and is_recommended, which the retrieve-path hit struct
|
||||
// omits because retrieval does not need them.
|
||||
type copySourceDoc struct {
|
||||
Content string `json:"content"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceType int `json:"source_type"`
|
||||
ChunkID string `json:"chunk_id"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
TagID string `json:"tag_id"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
}
|
||||
|
||||
// transformSourceID mirrors the sibling drivers' source_id remap:
|
||||
// - regular chunk (source_id == chunk_id) → target chunk id
|
||||
// - generated question (source_id == "<chunk>-<q>") → "<targetChunk>-<q>"
|
||||
// - anything else → a fresh uuid
|
||||
func transformSourceID(sourceID, chunkID, targetChunkID string) string {
|
||||
switch {
|
||||
case sourceID == chunkID:
|
||||
return targetChunkID
|
||||
case strings.HasPrefix(sourceID, chunkID+"-"):
|
||||
return targetChunkID + "-" + strings.TrimPrefix(sourceID, chunkID+"-")
|
||||
default:
|
||||
return uuid.New().String()
|
||||
}
|
||||
}
|
||||
|
||||
// CopyIndices copies all docs of one knowledge base into another (within the
|
||||
// same store) by scanning the source and re-saving via BatchSave — mirroring
|
||||
// the Elasticsearch / Qdrant drivers (search→BatchSave), which yields the
|
||||
// source_id transformation and dim/keyword routing for free. Runs
|
||||
// synchronously and paginates; the large-batch background-task path is a
|
||||
// later change.
|
||||
//
|
||||
// NOTE: from/size pagination is bounded by the index's max_result_window
|
||||
// (default 10000). Copies larger than that require the scroll-based async
|
||||
// path (a later change).
|
||||
func (r *Repository) CopyIndices(
|
||||
ctx context.Context,
|
||||
sourceKnowledgeBaseID string,
|
||||
sourceToTargetKBIDMap map[string]string, // keyed by source knowledge_id (mirrors sibling drivers)
|
||||
sourceToTargetChunkIDMap map[string]string,
|
||||
targetKnowledgeBaseID string,
|
||||
dimension int,
|
||||
knowledgeType string,
|
||||
) error {
|
||||
log := logger.GetLogger(ctx)
|
||||
if len(sourceToTargetChunkIDMap) == 0 {
|
||||
log.Warn("[OpenSearch] CopyIndices: empty chunk mapping, skipping")
|
||||
return nil
|
||||
}
|
||||
if dimension <= 0 {
|
||||
return fmt.Errorf("opensearch: CopyIndices requires dim > 0, got %d: %w",
|
||||
dimension, ErrDimensionMismatch)
|
||||
}
|
||||
if err := r.ensureReady(ctx, dimension); err != nil {
|
||||
return err
|
||||
}
|
||||
alias := r.indexAlias(dimension)
|
||||
|
||||
var total int64
|
||||
for from := 0; ; from += copyBatchSize {
|
||||
docs, err := r.copyScanBatch(ctx, alias, sourceKnowledgeBaseID, from, copyBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
break
|
||||
}
|
||||
infos := make([]*types.IndexInfo, 0, len(docs))
|
||||
embMap := make(map[string][]float32, len(docs))
|
||||
enabledMap := make(map[string]bool, len(docs))
|
||||
for i := range docs {
|
||||
d := &docs[i]
|
||||
targetChunkID, ok := sourceToTargetChunkIDMap[d.ChunkID]
|
||||
if !ok {
|
||||
log.Warnf("[OpenSearch] CopyIndices: source chunk %s not mapped, skipping", d.ChunkID)
|
||||
continue
|
||||
}
|
||||
targetKnowledgeID, ok := sourceToTargetKBIDMap[d.KnowledgeID]
|
||||
if !ok {
|
||||
log.Warnf("[OpenSearch] CopyIndices: source knowledge %s not mapped, skipping", d.KnowledgeID)
|
||||
continue
|
||||
}
|
||||
targetSourceID := transformSourceID(d.SourceID, d.ChunkID, targetChunkID)
|
||||
if len(d.Embedding) > 0 {
|
||||
// BatchSave looks up embeddings by SourceID (lookupEmbedding),
|
||||
// so key by the target source id — not the chunk id, which is
|
||||
// the Elasticsearch driver's convention.
|
||||
embMap[targetSourceID] = d.Embedding
|
||||
}
|
||||
enabledMap[targetChunkID] = d.IsEnabled
|
||||
infos = append(infos, &types.IndexInfo{
|
||||
Content: d.Content,
|
||||
SourceID: targetSourceID,
|
||||
SourceType: types.SourceType(d.SourceType),
|
||||
ChunkID: targetChunkID,
|
||||
KnowledgeID: targetKnowledgeID,
|
||||
KnowledgeBaseID: targetKnowledgeBaseID,
|
||||
KnowledgeType: knowledgeType,
|
||||
TagID: d.TagID,
|
||||
IsEnabled: d.IsEnabled,
|
||||
IsRecommended: d.IsRecommended,
|
||||
})
|
||||
}
|
||||
if len(infos) > 0 {
|
||||
params := map[string]any{
|
||||
"embedding": embMap,
|
||||
"chunk_enabled": enabledMap,
|
||||
}
|
||||
if err := r.BatchSave(ctx, infos, params); err != nil {
|
||||
return fmt.Errorf("opensearch: CopyIndices batch save: %w", err)
|
||||
}
|
||||
total += int64(len(infos))
|
||||
}
|
||||
if len(docs) < copyBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Infof("[OpenSearch] CopyIndices: copied %d docs (KB %s → %s, dim=%d)",
|
||||
total, sourceKnowledgeBaseID, targetKnowledgeBaseID, dimension)
|
||||
r.auditSink().EmitReindexExecuted(ctx, alias, alias, total)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyScanBatch reads one page of docs belonging to sourceKB from the per-dim
|
||||
// index, decoding the full _source (including the embedding vector).
|
||||
func (r *Repository) copyScanBatch(
|
||||
ctx context.Context, index, sourceKB string, from, size int,
|
||||
) ([]copySourceDoc, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"from": from,
|
||||
"size": size,
|
||||
"query": map[string]any{
|
||||
"bool": map[string]any{
|
||||
"filter": []any{
|
||||
map[string]any{"term": map[string]any{"knowledge_base_id": sourceKB}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opensearch: marshal copy scan body: %w", err)
|
||||
}
|
||||
req := osapi.SearchReq{Indices: []string{index}, Body: bytes.NewReader(body)}
|
||||
resp, err := r.client.Search(ctx, &req)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("opensearch: index %s missing: %w", index, ErrIndexNotFound)
|
||||
}
|
||||
return nil, wrapTransport(err)
|
||||
}
|
||||
defer drainAndClose(resp.Inspect().Response.Body)
|
||||
var parsed struct {
|
||||
Hits struct {
|
||||
Hits []struct {
|
||||
Source copySourceDoc `json:"_source"`
|
||||
} `json:"hits"`
|
||||
} `json:"hits"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Inspect().Response.Body, 64<<20)).Decode(&parsed); err != nil {
|
||||
return nil, fmt.Errorf("opensearch: parse copy scan response: %w", ErrTransport)
|
||||
}
|
||||
out := make([]copySourceDoc, len(parsed.Hits.Hits))
|
||||
for i, h := range parsed.Hits.Hits {
|
||||
out[i] = h.Source
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTransformSourceID covers the generated-question / regular / fallback
|
||||
// branches that CopyIndices uses to remap source_id.
|
||||
func TestTransformSourceID(t *testing.T) {
|
||||
t.Run("regular chunk uses target chunk id", func(t *testing.T) {
|
||||
if got := transformSourceID("chunk1", "chunk1", "tgt1"); got != "tgt1" {
|
||||
t.Errorf("want tgt1, got %s", got)
|
||||
}
|
||||
})
|
||||
t.Run("generated question preserves question id", func(t *testing.T) {
|
||||
if got := transformSourceID("chunk1-q7", "chunk1", "tgt1"); got != "tgt1-q7" {
|
||||
t.Errorf("want tgt1-q7, got %s", got)
|
||||
}
|
||||
})
|
||||
t.Run("unrelated source id gets fresh uuid", func(t *testing.T) {
|
||||
got := transformSourceID("totally-different", "chunk1", "tgt1")
|
||||
if got == "totally-different" || got == "tgt1" || len(got) != 36 {
|
||||
t.Errorf("want fresh uuid, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCopyIndices_EmptyMapping_NoOp verifies an empty chunk map short-circuits
|
||||
// before any HTTP call.
|
||||
func TestCopyIndices_EmptyMapping_NoOp(t *testing.T) {
|
||||
repo, ts := newTestRepo(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unexpected HTTP call: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer ts.Close()
|
||||
err := repo.CopyIndices(context.Background(), "kbSrc", map[string]string{}, map[string]string{}, "kbDst", 768, "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("want nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyIndices_ScanThenBatchSave verifies the search→BatchSave path:
|
||||
// remaps IDs, keys the embedding by the *target source id* (OpenSearch
|
||||
// BatchSave's lookup key), and emits one reindex audit event.
|
||||
func TestCopyIndices_ScanThenBatchSave(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
bulkBody string
|
||||
searchCnt int
|
||||
)
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodHead:
|
||||
w.WriteHeader(http.StatusOK) // alias exists → ensureReady no-op
|
||||
case strings.Contains(r.URL.Path, "_search"):
|
||||
mu.Lock()
|
||||
searchCnt++
|
||||
first := searchCnt == 1
|
||||
mu.Unlock()
|
||||
if first {
|
||||
_, _ = w.Write([]byte(`{"hits":{"hits":[
|
||||
{"_source":{"content":"c","source_id":"srcChunk","source_type":1,"chunk_id":"srcChunk","knowledge_id":"srcKnow","knowledge_base_id":"kbSrc","tag_id":"t","is_enabled":true,"is_recommended":false,"embedding":[0.1,0.2,0.3]}}
|
||||
]}}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"hits":{"hits":[]}}`))
|
||||
}
|
||||
case strings.HasSuffix(r.URL.Path, "/_bulk"):
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
bulkBody = string(b)
|
||||
mu.Unlock()
|
||||
_, _ = w.Write([]byte(`{"errors":false,"items":[]}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
}
|
||||
repo, ts := newTestRepo(t, handler)
|
||||
defer ts.Close()
|
||||
spy := &spySink{}
|
||||
repo.sink = spy
|
||||
|
||||
err := repo.CopyIndices(context.Background(), "kbSrc",
|
||||
map[string]string{"srcKnow": "tgtKnow"}, // knowledge_id remap (sourceToTargetKBIDMap is keyed by knowledge_id, mirroring ES)
|
||||
map[string]string{"srcChunk": "tgtChunk"},
|
||||
"kbDst", 768, "manual")
|
||||
if err != nil {
|
||||
t.Fatalf("CopyIndices: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if bulkBody == "" {
|
||||
t.Fatal("no bulk request captured")
|
||||
}
|
||||
// Target IDs present, source IDs gone from the written doc.
|
||||
for _, want := range []string{`"chunk_id":"tgtChunk"`, `"knowledge_id":"tgtKnow"`, `"knowledge_base_id":"kbDst"`, `"source_id":"tgtChunk"`} {
|
||||
if !strings.Contains(bulkBody, want) {
|
||||
t.Errorf("bulk body missing %q\n%s", want, bulkBody)
|
||||
}
|
||||
}
|
||||
if strings.Contains(bulkBody, `"knowledge_base_id":"kbSrc"`) {
|
||||
t.Errorf("bulk body leaked source KB id\n%s", bulkBody)
|
||||
}
|
||||
// Embedding written (keyed by target source id internally).
|
||||
if !strings.Contains(bulkBody, "0.1") {
|
||||
t.Errorf("embedding not written\n%s", bulkBody)
|
||||
}
|
||||
if len(spy.reindex) != 1 || spy.reindex[0].docs != 1 {
|
||||
t.Errorf("want 1 reindex event with docs=1, got %+v", spy.reindex)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdateChunkEnabledStatus_GroupedUpdateByQuery verifies the status
|
||||
// map is grouped by value into one _update_by_query per distinct value, each
|
||||
// passing chunk ids via terms + the new value via bound script params.
|
||||
func TestBatchUpdateChunkEnabledStatus_GroupedUpdateByQuery(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
bodies []string
|
||||
)
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "_update_by_query") {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, string(b))
|
||||
mu.Unlock()
|
||||
_, _ = w.Write([]byte(`{"updated":1,"version_conflicts":0,"failures":[]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
repo, ts := newTestRepo(t, handler)
|
||||
defer ts.Close()
|
||||
|
||||
err := repo.BatchUpdateChunkEnabledStatus(context.Background(), map[string]bool{
|
||||
"c1": true, "c2": false, "c3": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchUpdateChunkEnabledStatus: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("want 2 grouped update_by_query calls (true/false), got %d: %v", len(bodies), bodies)
|
||||
}
|
||||
joined := strings.Join(bodies, "\n")
|
||||
for _, want := range []string{"c1", "c2", "c3", "is_enabled", "params"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("update_by_query bodies missing %q\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdateChunkEnabledStatus_Empty_NoOp(t *testing.T) {
|
||||
repo, ts := newTestRepo(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unexpected HTTP call: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer ts.Close()
|
||||
if err := repo.BatchUpdateChunkEnabledStatus(context.Background(), map[string]bool{}); err != nil {
|
||||
t.Fatalf("want nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectByQueryResponse covers the success path and the failure path,
|
||||
// asserting cluster-side reason text is NOT surfaced in the returned error.
|
||||
func TestInspectByQueryResponse(t *testing.T) {
|
||||
t.Run("clean response", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"updated":5,"version_conflicts":0,"failures":[]}`)
|
||||
if err := inspectByQueryResponse(body); err != nil {
|
||||
t.Fatalf("want nil, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("failures do not leak reason", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"updated":1,"version_conflicts":0,"failures":[
|
||||
{"id":"c9","cause":{"type":"version_conflict_engine_exception","reason":"SECRET document body leaked here"}}
|
||||
]}`)
|
||||
err := inspectByQueryResponse(body)
|
||||
if err == nil {
|
||||
t.Fatal("want error for failures, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "SECRET") {
|
||||
t.Errorf("error leaked cluster reason: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "version_conflict_engine_exception") {
|
||||
t.Errorf("error should surface bounded type: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// additionalParams contract (verified against
|
||||
// elasticsearch/structs.go:ToDBVectorEmbedding and
|
||||
// keywords_vector_hybrid_indexer.go:concurrentBatchSave on
|
||||
// upstream/main):
|
||||
//
|
||||
// additionalParams["embedding"] map[string][]float32 // keyed by IndexInfo.SourceID
|
||||
// additionalParams["chunk_enabled"] map[string]bool // keyed by IndexInfo.ChunkID
|
||||
//
|
||||
// Both keys may be absent. Missing "embedding" → keyword-only path
|
||||
// (vector field omitted from the OpenSearch document, routed to the
|
||||
// dim-less keywords index). Missing "chunk_enabled" → IndexInfo's own
|
||||
// IsEnabled field is used as-is.
|
||||
|
||||
// Save indexes a single chunk. Idempotent: _id is set to chunk_id so
|
||||
// re-indexing the same chunk overwrites instead of creating a duplicate.
|
||||
func (r *Repository) Save(
|
||||
ctx context.Context,
|
||||
info *types.IndexInfo,
|
||||
params map[string]any,
|
||||
) error {
|
||||
emb := lookupEmbedding(params, info.SourceID)
|
||||
enabled := lookupChunkEnabled(params, info.ChunkID, info.IsEnabled)
|
||||
dim := len(emb)
|
||||
|
||||
var targetIndex string
|
||||
if dim > 0 {
|
||||
if err := r.ensureReady(ctx, dim); err != nil {
|
||||
return err
|
||||
}
|
||||
targetIndex = r.indexAlias(dim)
|
||||
} else {
|
||||
// Keyword-only path: no embedding supplied. Route to the
|
||||
// dim-less <base>_keywords index.
|
||||
if err := r.ensureKeywordsIndex(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
targetIndex = r.keywordsIndex()
|
||||
}
|
||||
|
||||
doc, err := json.Marshal(toDoc(info, emb, enabled))
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensearch: marshal doc: %w", err)
|
||||
}
|
||||
req := osapi.IndexReq{
|
||||
Index: targetIndex,
|
||||
DocumentID: info.ChunkID, // _id = chunk_id (idempotent)
|
||||
Body: bytes.NewReader(doc),
|
||||
}
|
||||
resp, err := r.client.Index(ctx, req)
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchSave NDJSON-bulk-indexes chunks. Inspects per-item errors and
|
||||
// returns a typed error if ANY item fails.
|
||||
//
|
||||
// Dim-aware pre-marshal size cap: estimateBulkBodyBytes predicts NDJSON
|
||||
// size before allocating; over-cap callers get ErrBatchTooLarge instead
|
||||
// of a 50MB allocation that's then rejected. The service layer is
|
||||
// responsible for dim-aware sub-batching above this.
|
||||
func (r *Repository) BatchSave(
|
||||
ctx context.Context,
|
||||
infos []*types.IndexInfo,
|
||||
params map[string]any,
|
||||
) error {
|
||||
if len(infos) == 0 {
|
||||
return nil
|
||||
}
|
||||
emb, dim, err := extractBatchEmbeddings(params, infos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pre-marshal size + count check.
|
||||
if estimated := estimateBulkBodyBytes(len(infos), dim); estimated > 10*1024*1024 {
|
||||
return fmt.Errorf(
|
||||
"opensearch: estimated bulk body %dB exceeds 10MB cap (n=%d, dim=%d): %w",
|
||||
estimated, len(infos), dim, ErrBatchTooLarge,
|
||||
)
|
||||
}
|
||||
if len(infos) > 1000 {
|
||||
return fmt.Errorf("opensearch: bulk n=%d exceeds 1000-doc cap: %w",
|
||||
len(infos), ErrBatchTooLarge)
|
||||
}
|
||||
|
||||
// Route by dim: dim==0 means the entire batch is keyword-only
|
||||
// (concurrentBatchSaveNoEmbedding path), use the dim-less index.
|
||||
var alias string
|
||||
if dim == 0 {
|
||||
if err := r.ensureKeywordsIndex(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
alias = r.keywordsIndex()
|
||||
} else {
|
||||
if err := r.ensureReady(ctx, dim); err != nil {
|
||||
return err
|
||||
}
|
||||
alias = r.indexAlias(dim)
|
||||
}
|
||||
|
||||
body, err := buildBulkBody(alias, infos, emb, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := osapi.BulkReq{Body: bytes.NewReader(body)}
|
||||
resp, err := r.client.Bulk(ctx, req)
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp != nil {
|
||||
defer drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
// Cap response body at 64MB (bulks of 1000 items are ~1-2MB success,
|
||||
// can balloon with full per-item errors).
|
||||
return inspectBulkResponse(io.LimitReader(resp.Inspect().Response.Body, 64<<20))
|
||||
}
|
||||
|
||||
// estimateBulkBodyBytes returns a rough NDJSON size in bytes. Slight
|
||||
// overestimate so the cap fires before real allocation. Per-item:
|
||||
// - action header `{"index":{"_index":"...","_id":"..."}}\n` ≈ 100B
|
||||
// - doc body: dim*5 (float ASCII) + ~700B metadata + content ≈ 1024B
|
||||
func estimateBulkBodyBytes(n, dim int) int {
|
||||
return n * (100 + dim*5 + 1024)
|
||||
}
|
||||
|
||||
// buildBulkBody constructs the NDJSON request body. emb[i] may be nil/
|
||||
// empty — toDoc handles the keyword-only case by omitting the
|
||||
// "embedding" field. chunk_enabled lookup also happens per-doc.
|
||||
func buildBulkBody(alias string, infos []*types.IndexInfo, emb [][]float32, params map[string]any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
for i, info := range infos {
|
||||
action := map[string]any{
|
||||
"index": map[string]any{
|
||||
"_index": alias,
|
||||
"_id": info.ChunkID,
|
||||
},
|
||||
}
|
||||
actionJSON, err := json.Marshal(action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opensearch: marshal bulk action %d: %w", i, err)
|
||||
}
|
||||
buf.Write(actionJSON)
|
||||
buf.WriteByte('\n')
|
||||
|
||||
enabled := lookupChunkEnabled(params, info.ChunkID, info.IsEnabled)
|
||||
docJSON, err := json.Marshal(toDoc(info, emb[i], enabled))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opensearch: marshal doc %d: %w", i, err)
|
||||
}
|
||||
buf.Write(docJSON)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// inspectBulkResponse parses OpenSearch bulk response, returns a typed
|
||||
// error if any item has a per-item .error. Surfaces up to 5 distinct
|
||||
// error messages + total count for operator diagnosis.
|
||||
//
|
||||
// Does NOT include det.Error.Reason in the wrapped public error
|
||||
// (Reason can contain document content fragments). Only ID + bounded
|
||||
// enum Type is included. Full per-item Reason goes to log.Debug.
|
||||
func inspectBulkResponse(body io.Reader) error {
|
||||
var r struct {
|
||||
Errors bool `json:"errors"`
|
||||
Items []map[string]struct {
|
||||
ID string `json:"_id"`
|
||||
Status int `json:"status"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"error,omitempty"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(body).Decode(&r); err != nil {
|
||||
return fmt.Errorf("opensearch: parse bulk response: %w", ErrTransport)
|
||||
}
|
||||
if !r.Errors {
|
||||
return nil
|
||||
}
|
||||
log := logger.GetLogger(context.Background())
|
||||
var msgs []string
|
||||
total := 0
|
||||
for _, item := range r.Items {
|
||||
for op, det := range item { // {"index": {...}}
|
||||
if det.Error == nil {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
// Full reason → debug log only (may contain doc content).
|
||||
log.Debugf("[OpenSearch] bulk item err: op=%s id=%s type=%s reason=%s",
|
||||
op, det.ID, det.Error.Type, det.Error.Reason)
|
||||
if len(msgs) < 5 {
|
||||
// Public message: ID (operator-meaningful) + Type (bounded enum).
|
||||
msgs = append(msgs, fmt.Sprintf("[%s %s] %s", op, det.ID, det.Error.Type))
|
||||
}
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return nil // shouldn't happen if r.Errors=true, but defensive
|
||||
}
|
||||
return fmt.Errorf("opensearch: bulk partial failure (%d items failed, first 5: %s): %w",
|
||||
total, strings.Join(msgs, "; "), ErrTransport)
|
||||
}
|
||||
|
||||
// toDoc projects IndexInfo + embedding to the OpenSearch document
|
||||
// shape. source_type is `int` (not stringified) — mapping declared as
|
||||
// `integer` field for stable cross-enum-extension behavior. Omits the
|
||||
// "embedding" field entirely when emb is empty (keyword-only doc, no
|
||||
// k-NN graph entry).
|
||||
func toDoc(info *types.IndexInfo, emb []float32, enabled bool) map[string]any {
|
||||
doc := map[string]any{
|
||||
"chunk_id": info.ChunkID,
|
||||
"knowledge_id": info.KnowledgeID,
|
||||
"knowledge_base_id": info.KnowledgeBaseID,
|
||||
"source_id": info.SourceID,
|
||||
"source_type": int(info.SourceType),
|
||||
"tag_id": info.TagID,
|
||||
"content": info.Content,
|
||||
"is_enabled": enabled,
|
||||
"is_recommended": info.IsRecommended,
|
||||
}
|
||||
if len(emb) > 0 {
|
||||
doc["embedding"] = emb
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// lookupEmbedding returns the embedding vector for info.SourceID.
|
||||
// Empty slice (not nil) indicates "no embedding provided" — caller
|
||||
// MUST check len(emb) before calling ensureReady.
|
||||
//
|
||||
// Defense in depth: tolerates the wrong shape rather than crash. The
|
||||
// service layer is responsible for getting params right; we degrade
|
||||
// to keyword-only for this doc and log a warning instead of panicking
|
||||
// downstream.
|
||||
func lookupEmbedding(params map[string]any, sourceID string) []float32 {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := params["embedding"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
embMap, ok := raw.(map[string][]float32)
|
||||
if !ok {
|
||||
logger.GetLogger(context.Background()).Warnf(
|
||||
"[OpenSearch] additionalParams[\"embedding\"] is %T, want map[string][]float32", raw)
|
||||
return nil
|
||||
}
|
||||
return embMap[sourceID] // nil/empty if SourceID not in map
|
||||
}
|
||||
|
||||
// lookupChunkEnabled overlays the chunk_enabled map on top of
|
||||
// info.IsEnabled. Returns the overlay value if present, else the
|
||||
// default (info.IsEnabled).
|
||||
func lookupChunkEnabled(params map[string]any, chunkID string, def bool) bool {
|
||||
if params == nil {
|
||||
return def
|
||||
}
|
||||
raw, ok := params["chunk_enabled"]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
enMap, ok := raw.(map[string]bool)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
if v, present := enMap[chunkID]; present {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// extractBatchEmbeddings projects the params["embedding"] map onto the
|
||||
// info list, returning per-info embeddings (some may be empty) and the
|
||||
// dimension inferred from the first non-empty entry. Returns dim=0 if
|
||||
// no info has an embedding — caller routes to the keywords-only path.
|
||||
//
|
||||
// Defense in depth: all non-empty embeddings MUST share the same
|
||||
// dimension. Mixed-dim → ErrDimensionMismatch.
|
||||
func extractBatchEmbeddings(params map[string]any, infos []*types.IndexInfo) ([][]float32, int, error) {
|
||||
out := make([][]float32, len(infos))
|
||||
dim := 0
|
||||
for i, info := range infos {
|
||||
emb := lookupEmbedding(params, info.SourceID)
|
||||
out[i] = emb
|
||||
if len(emb) > 0 {
|
||||
if dim == 0 {
|
||||
dim = len(emb)
|
||||
} else if len(emb) != dim {
|
||||
return nil, 0, fmt.Errorf(
|
||||
"opensearch: embedding[%s] dim=%d != first non-empty dim=%d: %w",
|
||||
info.SourceID, len(emb), dim, ErrDimensionMismatch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, dim, nil
|
||||
}
|
||||
|
||||
// DeleteByChunkIDList runs _delete_by_query with a terms filter on
|
||||
// chunk_id. Sync only — large batches (>1000) are rejected with
|
||||
// ErrBatchTooLarge; the async task path that lifts the cap arrives in
|
||||
// a later change.
|
||||
//
|
||||
// Refresh policy: opensearch-go v4's typed Params.Refresh is *bool, so
|
||||
// the wire-level "wait_for" value documented in the OpenSearch REST
|
||||
// API is not directly expressible. Refresh:&true forces immediate
|
||||
// segment flush (read-your-writes guaranteed, but expensive at scale).
|
||||
// A follow-up can revisit via a custom raw request if telemetry shows
|
||||
// the cost is material at production batch frequencies.
|
||||
func (r *Repository) DeleteByChunkIDList(
|
||||
ctx context.Context, chunkIDs []string, dim int, _ string,
|
||||
) error {
|
||||
return r.deleteByList(ctx, chunkIDs, dim, "chunk_id")
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteBySourceIDList(
|
||||
ctx context.Context, sourceIDs []string, dim int, _ string,
|
||||
) error {
|
||||
return r.deleteByList(ctx, sourceIDs, dim, "source_id")
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteByKnowledgeIDList(
|
||||
ctx context.Context, knowledgeIDs []string, dim int, _ string,
|
||||
) error {
|
||||
return r.deleteByList(ctx, knowledgeIDs, dim, "knowledge_id")
|
||||
}
|
||||
|
||||
// deleteByList factors the common cap / empty / ensureReady / dispatch
|
||||
// logic out of the three DeleteBy* methods. dim==0 routes to the
|
||||
// dim-less keywords index.
|
||||
func (r *Repository) deleteByList(
|
||||
ctx context.Context, ids []string, dim int, field string,
|
||||
) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(ids) > 1000 {
|
||||
return fmt.Errorf("opensearch: %s-delete batch %d > 1000 cap: %w",
|
||||
field, len(ids), ErrBatchTooLarge)
|
||||
}
|
||||
var index string
|
||||
if dim == 0 {
|
||||
if err := r.ensureKeywordsIndex(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
index = r.keywordsIndex()
|
||||
} else {
|
||||
if err := r.ensureReady(ctx, dim); err != nil {
|
||||
return err
|
||||
}
|
||||
index = r.indexAlias(dim)
|
||||
}
|
||||
return r.deleteByTerms(ctx, index, field, ids)
|
||||
}
|
||||
|
||||
func (r *Repository) deleteByTerms(
|
||||
ctx context.Context, index, field string, values []string,
|
||||
) error {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"query": map[string]any{
|
||||
"terms": map[string]any{field: values},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensearch: marshal delete body: %w", err)
|
||||
}
|
||||
refresh := true
|
||||
req := osapi.DocumentDeleteByQueryReq{
|
||||
Indices: []string{index},
|
||||
Body: bytes.NewReader(body),
|
||||
Params: osapi.DocumentDeleteByQueryParams{Refresh: &refresh},
|
||||
}
|
||||
resp, err := r.client.Document.DeleteByQuery(ctx, req)
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package opensearch implements the RetrieveEngineRepository interface
|
||||
// for OpenSearch k-NN native vector search.
|
||||
package opensearch
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by Repository. The service-layer factory wraps
|
||||
// these into typed AppError values (2200/2201) — the repository itself
|
||||
// never imports internal/errors. The boundary is intentional (directional
|
||||
// dependency).
|
||||
var (
|
||||
// ErrIndexNotFound — alias / underlying index missing. Search and
|
||||
// delete-by-query operations return this when the per-dim alias has
|
||||
// not been created yet (no Save has been issued for that dim).
|
||||
ErrIndexNotFound = errors.New("opensearch: index not found")
|
||||
|
||||
// ErrDimensionMismatch — embedding dimension violates the per-dim
|
||||
// invariant (e.g. dim <= 0, dim > 16000, or embeddings within a
|
||||
// single batch disagree).
|
||||
ErrDimensionMismatch = errors.New("opensearch: embedding dimension mismatch")
|
||||
|
||||
// ErrAuth — cluster returned 401 / 403. Distinguished from ErrTransport
|
||||
// so the service layer can map to a clean 4xx instead of 503.
|
||||
ErrAuth = errors.New("opensearch: authentication failed")
|
||||
|
||||
// ErrTransport — network / 5xx / opaque cluster error. Classified as
|
||||
// transient: ensureReady does NOT persist this in initErr, so the next
|
||||
// caller will retry.
|
||||
ErrTransport = errors.New("opensearch: transport error")
|
||||
|
||||
// ErrVersionUnsupported — cluster is not OpenSearch, is OS 1.x, or is
|
||||
// OS 2.0~2.3 (pre-Lucene-HNSW-GA). probeVersion enforces.
|
||||
ErrVersionUnsupported = errors.New("opensearch: cluster version unsupported")
|
||||
|
||||
// ErrConfigInvalid — IndexConfig / storeID / sanitizeIndexName guard
|
||||
// failed, or the k-NN plugin is missing on one or more cluster nodes.
|
||||
ErrConfigInvalid = errors.New("opensearch: invalid index config")
|
||||
|
||||
// ErrFeatureNotEnabled — stubs.go returns this from methods whose real
|
||||
// implementation has not landed yet (CopyIndices / BatchUpdateChunk* /
|
||||
// swapToVersion, plus the read/write methods that a follow-up commit
|
||||
// will replace with production code).
|
||||
ErrFeatureNotEnabled = errors.New("opensearch: feature not enabled in this build")
|
||||
|
||||
// ErrBatchTooLarge — Save / Delete batch exceeded the driver's sync
|
||||
// cap. Distinct from ErrFeatureNotEnabled so the service layer can
|
||||
// chunk + retry rather than treat the failure as "waiting on a future
|
||||
// implementation."
|
||||
ErrBatchTooLarge = errors.New("opensearch: batch size exceeds driver cap")
|
||||
|
||||
// ErrCircuitBreaker — OpenSearch k-NN circuit breaker returned 429
|
||||
// (knn_circuit_breaker_exception). Classified as transient so the
|
||||
// caller can retry after the operator scales the cluster.
|
||||
ErrCircuitBreaker = errors.New("opensearch: knn circuit breaker open")
|
||||
)
|
||||
|
||||
// isTransientErr classifies sentinel errors. Transient errors do not get
|
||||
// persisted in ensureReady's initErr cache — they can be retried by the
|
||||
// next caller after the underlying cause clears.
|
||||
func isTransientErr(err error) bool {
|
||||
return errors.Is(err, ErrTransport) || errors.Is(err, ErrCircuitBreaker)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// TestConnection verifies an OpenSearch cluster is reachable, runs a
|
||||
// supported version, and has the k-NN plugin installed on every node. It is
|
||||
// the connectivity probe used by the VectorStore service's CreateStore
|
||||
// health-check (the driver's unexported probes are reused here). Returns a
|
||||
// wrapped sentinel error on failure; nil on success.
|
||||
func TestConnection(ctx context.Context, cfg *types.ConnectionConfig) error {
|
||||
client, err := NewOpenSearchClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := probeVersion(ctx, client); err != nil {
|
||||
return err
|
||||
}
|
||||
return probeKNNPlugin(ctx, client)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// clusterHandler serves both GET / (version info) and /_cat/plugins, so the
|
||||
// full TestConnection probe (version + k-NN plugin) can run end to end.
|
||||
func clusterHandler(distribution, number string, plugins []osapi.CatPluginResp) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/_cat/plugins" {
|
||||
_ = json.NewEncoder(w).Encode(plugins)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"version":{"distribution":"` + distribution + `","number":"` + number + `"}}`))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnection_Success(t *testing.T) {
|
||||
ts := httptest.NewServer(clusterHandler("opensearch", "3.3.2", []osapi.CatPluginResp{
|
||||
{Name: "node-1", Component: "opensearch-knn"},
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := TestConnection(context.Background(), &types.ConnectionConfig{Addr: ts.URL}); err != nil {
|
||||
t.Errorf("healthy cluster: want nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnection_RejectsElasticsearch(t *testing.T) {
|
||||
ts := httptest.NewServer(clusterHandler("elasticsearch", "8.10.4", nil))
|
||||
defer ts.Close()
|
||||
if err := TestConnection(context.Background(), &types.ConnectionConfig{Addr: ts.URL}); err == nil {
|
||||
t.Error("elasticsearch cluster should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnection_EmptyAddr(t *testing.T) {
|
||||
if err := TestConnection(context.Background(), &types.ConnectionConfig{}); err == nil {
|
||||
t.Error("empty addr should be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// createIndexAndAlias creates "<alias>_v1" with the full k-NN mapping
|
||||
// and then aliases "<alias>" → "<alias>_v1". Idempotent: returns nil if
|
||||
// the alias already exists (so concurrent NewRepository calls in
|
||||
// different processes don't race the PUT).
|
||||
//
|
||||
// Orphan cleanup: if aliasPut fails after a successful indicesCreate,
|
||||
// the function attempts best-effort DELETE of the orphan _v1 index.
|
||||
// Cleanup failure is logged but does not mask the original aliasPut
|
||||
// error.
|
||||
//
|
||||
// Mapping-drift detection: if indicesCreate returns
|
||||
// resource_already_exists_exception (cross-process race), the existing
|
||||
// mapping's structural fingerprint is checked against the expected
|
||||
// fingerprint. Drift returns ErrConfigInvalid wrapping a clear "manual
|
||||
// reindex required" message instead of silently overlaying a foreign
|
||||
// mapping.
|
||||
func (r *Repository) createIndexAndAlias(ctx context.Context, dim int) error {
|
||||
log := logger.GetLogger(ctx)
|
||||
alias := r.indexAlias(dim)
|
||||
realIndex := alias + "_v1"
|
||||
|
||||
exists, err := r.aliasExists(ctx, alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alias check %s: %w", alias, err)
|
||||
}
|
||||
if exists {
|
||||
return nil // another writer already set it up
|
||||
}
|
||||
|
||||
body, err := buildIndexMapping(r.cfg, dim)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build mapping for dim=%d: %w", dim, err)
|
||||
}
|
||||
|
||||
indexCreated := false
|
||||
if err := r.indicesCreate(ctx, realIndex, body); err != nil {
|
||||
if isAlreadyExistsError(err) {
|
||||
// Cross-process race or stale orphan. Verify the existing
|
||||
// mapping matches what we expected; drift → ErrConfigInvalid.
|
||||
if driftErr := r.verifyMappingMatches(ctx, realIndex, body); driftErr != nil {
|
||||
return fmt.Errorf(
|
||||
"mapping drift on %s: existing index has incompatible mapping; "+
|
||||
"manual reindex required: %w",
|
||||
realIndex, driftErr,
|
||||
)
|
||||
}
|
||||
// Mapping matches → safe to proceed with alias setup.
|
||||
} else {
|
||||
return fmt.Errorf("create index %s: %w", realIndex, err)
|
||||
}
|
||||
} else {
|
||||
indexCreated = true
|
||||
}
|
||||
|
||||
if err := r.aliasPut(ctx, realIndex, alias); err != nil {
|
||||
// Best-effort orphan cleanup so future retries find a clean slate.
|
||||
if indexCreated {
|
||||
if delErr := r.indicesDelete(ctx, realIndex); delErr != nil {
|
||||
log.Warnf("[OpenSearch] orphan cleanup failed for %s: %v "+
|
||||
"(operator must DELETE manually)", realIndex, delErr)
|
||||
} else {
|
||||
log.Infof("[OpenSearch] cleaned up orphan %s after aliasPut failure", realIndex)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("put alias %s → %s: %w", alias, realIndex, err)
|
||||
}
|
||||
if indexCreated {
|
||||
// Emit only when we actually provisioned the index (not when a
|
||||
// concurrent writer / existing index short-circuited above).
|
||||
r.auditSink().EmitIndexCreated(ctx, alias, dim)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildIndexMapping returns the full mapping JSON for a new OpenSearch
|
||||
// k-NN index. content uses the standard analyzer (BM25 default); all
|
||||
// *_id fields are explicit keyword sub-fields so DeleteByQuery / terms
|
||||
// filters hit them without the ES v8 "useKeywordSuffix" auto-detection
|
||||
// dance.
|
||||
//
|
||||
// match_type is intentionally NOT in the mapping — it's a per-result
|
||||
// classification derived from RetrieverType at parse time. source_type
|
||||
// is stored as `integer` (not stringified) so the wire format remains
|
||||
// stable across SourceType enum extension.
|
||||
//
|
||||
// Pure function — no IO, no logging. Easy to unit-test (byte-exact diff).
|
||||
func buildIndexMapping(cfg internalCfg, dim int) ([]byte, error) {
|
||||
type mapping struct {
|
||||
Settings struct {
|
||||
Index struct {
|
||||
Knn bool `json:"knn"`
|
||||
NumberOfShards int `json:"number_of_shards"`
|
||||
NumberOfReplicas int `json:"number_of_replicas"`
|
||||
RefreshInterval string `json:"refresh_interval"`
|
||||
KnnAlgoParamEFSearch int `json:"knn.algo_param.ef_search"`
|
||||
} `json:"index"`
|
||||
} `json:"settings"`
|
||||
Mappings struct {
|
||||
Properties map[string]any `json:"properties"`
|
||||
} `json:"mappings"`
|
||||
}
|
||||
var m mapping
|
||||
m.Settings.Index.Knn = true
|
||||
m.Settings.Index.NumberOfShards = cfg.shards
|
||||
m.Settings.Index.NumberOfReplicas = cfg.replicas
|
||||
m.Settings.Index.RefreshInterval = "1s"
|
||||
m.Settings.Index.KnnAlgoParamEFSearch = cfg.efSearch
|
||||
m.Mappings.Properties = map[string]any{
|
||||
"embedding": map[string]any{
|
||||
"type": "knn_vector",
|
||||
"dimension": dim,
|
||||
"method": map[string]any{
|
||||
"name": "hnsw",
|
||||
"space_type": "cosinesimil",
|
||||
"engine": cfg.knnEngine,
|
||||
"parameters": map[string]any{
|
||||
"m": cfg.hnswM,
|
||||
"ef_construction": cfg.hnswEFConstruction,
|
||||
},
|
||||
},
|
||||
},
|
||||
"content": map[string]any{"type": "text", "analyzer": "standard"},
|
||||
"chunk_id": map[string]any{"type": "keyword"},
|
||||
"knowledge_id": map[string]any{"type": "keyword"},
|
||||
"knowledge_base_id": map[string]any{"type": "keyword"},
|
||||
"tag_id": map[string]any{"type": "keyword"},
|
||||
"source_id": map[string]any{"type": "keyword"},
|
||||
"source_type": map[string]any{"type": "integer"},
|
||||
"is_enabled": map[string]any{"type": "boolean"},
|
||||
"is_recommended": map[string]any{"type": "boolean"},
|
||||
}
|
||||
out, err := json.Marshal(&m)
|
||||
if err != nil {
|
||||
// Cannot happen for our internal struct, but propagate for symmetry
|
||||
// with the other JSON builders in the package.
|
||||
return nil, fmt.Errorf("marshal mapping: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildKeywordsMapping returns the mapping for the dim-less keyword-only
|
||||
// index. Same as buildIndexMapping but with the embedding field omitted
|
||||
// — used by the no-embedding save path (the service layer dispatches
|
||||
// there when the knowledge base has vector indexing disabled).
|
||||
func buildKeywordsMapping(cfg internalCfg) ([]byte, error) {
|
||||
type mapping struct {
|
||||
Settings struct {
|
||||
Index struct {
|
||||
NumberOfShards int `json:"number_of_shards"`
|
||||
NumberOfReplicas int `json:"number_of_replicas"`
|
||||
RefreshInterval string `json:"refresh_interval"`
|
||||
} `json:"index"`
|
||||
} `json:"settings"`
|
||||
Mappings struct {
|
||||
Properties map[string]any `json:"properties"`
|
||||
} `json:"mappings"`
|
||||
}
|
||||
var m mapping
|
||||
m.Settings.Index.NumberOfShards = cfg.shards
|
||||
m.Settings.Index.NumberOfReplicas = cfg.replicas
|
||||
m.Settings.Index.RefreshInterval = "1s"
|
||||
m.Mappings.Properties = map[string]any{
|
||||
"content": map[string]any{"type": "text", "analyzer": "standard"},
|
||||
"chunk_id": map[string]any{"type": "keyword"},
|
||||
"knowledge_id": map[string]any{"type": "keyword"},
|
||||
"knowledge_base_id": map[string]any{"type": "keyword"},
|
||||
"tag_id": map[string]any{"type": "keyword"},
|
||||
"source_id": map[string]any{"type": "keyword"},
|
||||
"source_type": map[string]any{"type": "integer"},
|
||||
"is_enabled": map[string]any{"type": "boolean"},
|
||||
"is_recommended": map[string]any{"type": "boolean"},
|
||||
}
|
||||
out, err := json.Marshal(&m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal keywords mapping: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ensureKeywordsIndex creates the dim-less keyword-only index used by
|
||||
// the no-embedding save path. mutex + flag pattern so transient
|
||||
// failures can be retried by the next caller (sync.Once cannot be
|
||||
// reset).
|
||||
func (r *Repository) ensureKeywordsIndex(ctx context.Context) error {
|
||||
r.keywordsMu.Lock()
|
||||
defer r.keywordsMu.Unlock()
|
||||
|
||||
if r.keywordsReady {
|
||||
return nil
|
||||
}
|
||||
if r.keywordsErr != nil && !isTransientErr(r.keywordsErr) {
|
||||
// Permanent failure — don't retry, surface the same error.
|
||||
return r.keywordsErr
|
||||
}
|
||||
|
||||
name := r.keywordsIndex()
|
||||
exists, err := r.aliasExists(ctx, name) // returns true if index exists too
|
||||
if err != nil {
|
||||
r.keywordsErr = err
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
r.keywordsReady = true
|
||||
r.keywordsErr = nil
|
||||
return nil
|
||||
}
|
||||
body, err := buildKeywordsMapping(r.cfg)
|
||||
if err != nil {
|
||||
r.keywordsErr = err
|
||||
return err
|
||||
}
|
||||
created := false
|
||||
if err := r.indicesCreate(ctx, name, body); err != nil {
|
||||
if !isAlreadyExistsError(err) {
|
||||
r.keywordsErr = err
|
||||
return err
|
||||
}
|
||||
// resource_already_exists_exception — race with concurrent process,
|
||||
// treat as success.
|
||||
} else {
|
||||
created = true
|
||||
}
|
||||
r.keywordsReady = true
|
||||
r.keywordsErr = nil
|
||||
if created {
|
||||
// dim=0 marks the dim-less keyword-only index.
|
||||
r.auditSink().EmitIndexCreated(ctx, name, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// indicesCreate is the low-level wrapper around Indices.Create. Body is
|
||||
// the JSON mapping produced by buildIndexMapping.
|
||||
func (r *Repository) indicesCreate(ctx context.Context, name string, body []byte) error {
|
||||
req := osapi.IndicesCreateReq{Index: name, Body: bytes.NewReader(body)}
|
||||
resp, err := r.client.Indices.Create(ctx, req)
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// indicesDelete is used by createIndexAndAlias's orphan cleanup path.
|
||||
// Caller decides what to do with the error.
|
||||
func (r *Repository) indicesDelete(ctx context.Context, name string) error {
|
||||
req := osapi.IndicesDeleteReq{Indices: []string{name}}
|
||||
resp, err := r.client.Indices.Delete(ctx, req)
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aliasExists uses HEAD /_alias/<name> (Indices.Alias.Exists) — returns
|
||||
// true on 200, false on 404, error on 5xx / transport.
|
||||
//
|
||||
// SDK quirk (opensearch-go v4.6.0): the Exists method passes
|
||||
// dataPointer=nil to its internal do(), which means non-2xx responses
|
||||
// come back as a plain *errors.errorString ("status: 404 Not Found")
|
||||
// rather than as a *opensearch.StructError. We therefore inspect
|
||||
// resp.StatusCode directly (resp is always returned even when err is
|
||||
// non-nil) and only fall back to wrapTransport for the "no response at
|
||||
// all" case (true network failure).
|
||||
func (r *Repository) aliasExists(ctx context.Context, alias string) (bool, error) {
|
||||
req := osapi.AliasExistsReq{Alias: []string{alias}}
|
||||
resp, err := r.client.Indices.Alias.Exists(ctx, req)
|
||||
if resp != nil {
|
||||
statusCode := resp.StatusCode
|
||||
drainAndClose(resp.Body)
|
||||
switch statusCode {
|
||||
case 200:
|
||||
return true, nil
|
||||
case 404:
|
||||
return false, nil
|
||||
}
|
||||
// Unexpected status (5xx) — fall through to the err path so
|
||||
// wrapTransport can classify (and so the test handler's
|
||||
// errBody is logged for diagnosis).
|
||||
}
|
||||
if err != nil {
|
||||
return false, wrapTransport(err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// aliasPut creates an alias pointing at index. Single add operation
|
||||
// (rolling-reindex atomic swap is deferred to a follow-up PR — see
|
||||
// stubs.go swapToVersion).
|
||||
func (r *Repository) aliasPut(ctx context.Context, index, alias string) error {
|
||||
req := osapi.AliasPutReq{
|
||||
Indices: []string{index},
|
||||
Alias: alias,
|
||||
}
|
||||
resp, err := r.client.Indices.Alias.Put(ctx, req)
|
||||
if err != nil {
|
||||
return wrapTransport(err)
|
||||
}
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyMappingMatches GETs the existing index's mapping and compares
|
||||
// the embedding field's structural fingerprint (dimension, HNSW
|
||||
// parameters, engine) against the expected. A full field-by-field diff
|
||||
// would be ideal but expensive; we check only the parts that matter for
|
||||
// query correctness.
|
||||
func (r *Repository) verifyMappingMatches(ctx context.Context, index string, expected []byte) error {
|
||||
req := osapi.MappingGetReq{Indices: []string{index}}
|
||||
resp, err := r.client.Indices.Mapping.Get(ctx, &req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get mapping %s: %w", index, wrapTransport(err))
|
||||
}
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
|
||||
// Both fingerprints are derived from the mapping's embedding-field
|
||||
// HNSW parameters: dimension, m, ef_construction, engine, space_type.
|
||||
// We parse the expected (our own JSON) and the actual (cluster
|
||||
// response) and compare just those bits.
|
||||
expectedFP, err := extractEmbeddingFingerprint(expected)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse expected fingerprint: %w", err)
|
||||
}
|
||||
actualFP, err := extractMappingFingerprintFromResp(resp, index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse actual fingerprint: %w", err)
|
||||
}
|
||||
if expectedFP != actualFP {
|
||||
return fmt.Errorf("mapping fingerprint mismatch: expected %+v, got %+v: %w",
|
||||
expectedFP, actualFP, ErrConfigInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embFingerprint is the minimal set of HNSW parameters that uniquely
|
||||
// determine query semantics for a k-NN index. If any of these differ
|
||||
// between create-time and an existing index, retrieval is incorrect.
|
||||
type embFingerprint struct {
|
||||
Dimension int
|
||||
M int
|
||||
EFConstruction int
|
||||
Engine string
|
||||
SpaceType string
|
||||
}
|
||||
|
||||
// extractEmbeddingFingerprint parses our own mapping JSON (produced by
|
||||
// buildIndexMapping) to pull out the HNSW parameters.
|
||||
func extractEmbeddingFingerprint(body []byte) (embFingerprint, error) {
|
||||
var m struct {
|
||||
Mappings struct {
|
||||
Properties map[string]any `json:"properties"`
|
||||
} `json:"mappings"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return embFingerprint{}, err
|
||||
}
|
||||
return extractFingerprintFromProps(m.Mappings.Properties)
|
||||
}
|
||||
|
||||
// extractMappingFingerprintFromResp parses the cluster's GET-mapping
|
||||
// response and pulls out the embedding-field HNSW fingerprint for the
|
||||
// given index.
|
||||
func extractMappingFingerprintFromResp(resp *osapi.MappingGetResp, index string) (embFingerprint, error) {
|
||||
indexMapping, ok := resp.Indices[index]
|
||||
if !ok {
|
||||
return embFingerprint{}, fmt.Errorf("index %s not in mapping response", index)
|
||||
}
|
||||
// MappingGetResp.Indices[index].Mappings is a json.RawMessage in
|
||||
// the SDK — we decode the inner properties shape ourselves.
|
||||
var m struct {
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(indexMapping.Mappings, &m); err != nil {
|
||||
return embFingerprint{}, err
|
||||
}
|
||||
return extractFingerprintFromProps(m.Properties)
|
||||
}
|
||||
|
||||
func extractFingerprintFromProps(props map[string]any) (embFingerprint, error) {
|
||||
emb, ok := props["embedding"].(map[string]any)
|
||||
if !ok {
|
||||
return embFingerprint{}, fmt.Errorf("embedding field missing or wrong type")
|
||||
}
|
||||
dim, _ := emb["dimension"].(float64) // json.Unmarshal produces float64 for numbers
|
||||
method, _ := emb["method"].(map[string]any)
|
||||
engine, _ := method["engine"].(string)
|
||||
spaceType, _ := method["space_type"].(string)
|
||||
params, _ := method["parameters"].(map[string]any)
|
||||
m, _ := params["m"].(float64)
|
||||
efc, _ := params["ef_construction"].(float64)
|
||||
return embFingerprint{
|
||||
Dimension: int(dim),
|
||||
M: int(m),
|
||||
EFConstruction: int(efc),
|
||||
Engine: engine,
|
||||
SpaceType: spaceType,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// retrieveFilters is the typed shape of OpenSearch query filter
|
||||
// clauses. It is the ONLY way callers (Retrieve) can constrain a
|
||||
// query — there is no JSON-fragment escape hatch. This prevents the
|
||||
// "map[string]interface{} injection" failure mode where a hostile
|
||||
// chunk_id like {"$or":[...]} would otherwise be inlined as JSON DSL.
|
||||
//
|
||||
// All slice fields are interpreted as IN-set membership (OS terms
|
||||
// query). Empty slice = no constraint on that dimension.
|
||||
//
|
||||
// IncludeDisabled controls the implicit is_enabled = true clause:
|
||||
// - false (default) — appends is_enabled term, hiding disabled chunks
|
||||
// - true — omits the clause; admin / audit callers can
|
||||
// observe disabled chunks. Service layer pins false for user-facing
|
||||
// APIs.
|
||||
type retrieveFilters struct {
|
||||
KBIDs []string
|
||||
KnowledgeIDs []string
|
||||
TagIDs []string
|
||||
ExcludeChunkIDs []string
|
||||
ExcludeKnowledgeIDs []string
|
||||
IncludeDisabled bool
|
||||
}
|
||||
|
||||
// fromParams projects RetrieveParams to retrieveFilters, applying the
|
||||
// multi-KB input from the service-layer dispatcher as terms.
|
||||
// ExcludeKnowledgeIDs is included so the OpenSearch driver matches the
|
||||
// behaviour of the Qdrant / pgvector drivers.
|
||||
func fromParams(p types.RetrieveParams) *retrieveFilters {
|
||||
return &retrieveFilters{
|
||||
KBIDs: p.KnowledgeBaseIDs,
|
||||
KnowledgeIDs: p.KnowledgeIDs,
|
||||
TagIDs: p.TagIDs,
|
||||
ExcludeChunkIDs: p.ExcludeChunkIDs,
|
||||
ExcludeKnowledgeIDs: p.ExcludeKnowledgeIDs,
|
||||
// IncludeDisabled stays false — set explicitly by admin callers
|
||||
// only. Driver receives this from a typed field, not from
|
||||
// AdditionalParams, so the contract is checked at compile time.
|
||||
}
|
||||
}
|
||||
|
||||
// toBoolMust emits the OpenSearch bool.must clause list for this
|
||||
// filter. Returns a freshly allocated slice each call so callers can
|
||||
// safely append additional clauses (e.g. the keyword match clause in
|
||||
// buildKeywordQuery) without aliasing.
|
||||
func (f *retrieveFilters) toBoolMust() []map[string]any {
|
||||
var must []map[string]any
|
||||
if len(f.KBIDs) > 0 {
|
||||
must = append(must, map[string]any{
|
||||
"terms": map[string]any{"knowledge_base_id": f.KBIDs},
|
||||
})
|
||||
}
|
||||
if len(f.KnowledgeIDs) > 0 {
|
||||
must = append(must, map[string]any{
|
||||
"terms": map[string]any{"knowledge_id": f.KnowledgeIDs},
|
||||
})
|
||||
}
|
||||
if len(f.TagIDs) > 0 {
|
||||
must = append(must, map[string]any{
|
||||
"terms": map[string]any{"tag_id": f.TagIDs},
|
||||
})
|
||||
}
|
||||
if len(f.ExcludeChunkIDs) > 0 {
|
||||
must = append(must, map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must_not": map[string]any{
|
||||
"terms": map[string]any{"chunk_id": f.ExcludeChunkIDs},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(f.ExcludeKnowledgeIDs) > 0 {
|
||||
must = append(must, map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must_not": map[string]any{
|
||||
"terms": map[string]any{"knowledge_id": f.ExcludeKnowledgeIDs},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if !f.IncludeDisabled {
|
||||
must = append(must, map[string]any{
|
||||
"term": map[string]any{"is_enabled": true},
|
||||
})
|
||||
}
|
||||
return must
|
||||
}
|
||||
|
||||
// buildKNNQuery composes a pure k-NN ANN query with caller filters. No
|
||||
// hybrid pipeline — native hybrid is out of scope for this PR; fusion
|
||||
// stays in the service-layer RRF.
|
||||
//
|
||||
// Applies `min_score` when threshold > 0. The OpenSearch k-NN plugin's
|
||||
// SpaceType.COSINESIMIL.scoreTranslation produces (1 + cos) / 2 ∈ [0, 1]
|
||||
// at the client, so the caller's Threshold value passes through to
|
||||
// min_score directly without further scaling.
|
||||
//
|
||||
// Signature returns ([]byte, error) so callers can propagate
|
||||
// json.Marshal failures (reachable for NaN / +/-Inf in embedding).
|
||||
func buildKNNQuery(embedding []float32, topK int, threshold float64, f *retrieveFilters) ([]byte, error) {
|
||||
knn := map[string]any{
|
||||
"embedding": map[string]any{
|
||||
"vector": embedding,
|
||||
"k": topK,
|
||||
"filter": map[string]any{
|
||||
"bool": map[string]any{"must": f.toBoolMust()},
|
||||
},
|
||||
},
|
||||
}
|
||||
body := map[string]any{
|
||||
"size": topK,
|
||||
"query": map[string]any{"knn": knn},
|
||||
}
|
||||
if threshold > 0 {
|
||||
body["min_score"] = threshold
|
||||
}
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal knn query: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildKeywordQuery composes a BM25 match query against the content
|
||||
// field with caller filters. Score range is OpenSearch BM25 native (no
|
||||
// upper bound) — the normalizer keyword-passthrough rule treats keyword
|
||||
// retrievers as rank-based for RRF, so the score scale does not need
|
||||
// normalization here. min_score still applied if requested.
|
||||
func buildKeywordQuery(queryText string, topK int, threshold float64, f *retrieveFilters) ([]byte, error) {
|
||||
must := f.toBoolMust()
|
||||
must = append(must, map[string]any{
|
||||
"match": map[string]any{"content": queryText},
|
||||
})
|
||||
body := map[string]any{
|
||||
"size": topK,
|
||||
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||
}
|
||||
if threshold > 0 {
|
||||
body["min_score"] = threshold
|
||||
}
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal keyword query: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/opensearch-project/opensearch-go/v4"
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// EngineAwareNormalizer applies the documented per-engine cosine-score
|
||||
// formula. (Repository implements interfaces.RetrieveEngineRepository for
|
||||
// OpenSearch k-NN. Safe for concurrent use after construction: all fields
|
||||
// except the once / initErr maps are read-only after NewRepository returns;
|
||||
// per-dimension index initialization is guarded by sync.Once.
|
||||
//
|
||||
// Lifecycle: NewRepository validates connectivity + cluster version +
|
||||
// k-NN plugin, but does NOT create any index. Index creation happens
|
||||
// lazily on first Save / BatchSave / Retrieve once the embedding
|
||||
// dimension is known (see ensureReady — per-dimension index naming).
|
||||
//
|
||||
// Concurrency: A single Repository instance is shared across N goroutines
|
||||
// concurrently retrieving from the same store (the multi-store fan-out
|
||||
// dispatcher in the service layer). The struct is therefore designed to
|
||||
// be allocation-stable after construction; the only mutable state is the
|
||||
// sync.Once + initErr maps, each guarded by its own mutex.
|
||||
//
|
||||
// CRITICAL INVARIANT: initErr[dim] = err must be written INSIDE the
|
||||
// once.Do closure — moving it outside opens a TOCTOU where a concurrent
|
||||
// caller reads zero before the first caller persists.
|
||||
type Repository struct {
|
||||
client *osapi.Client
|
||||
baseIndex string // pre-suffix base name, e.g. "weknora_abcdef012345"
|
||||
cfg internalCfg // immutable after construction (value, not pointer)
|
||||
|
||||
// Per-dimension lazy initialization. ensureReady(ctx, dim) inserts an
|
||||
// entry the first time dim is seen, runs the create-index work inside
|
||||
// sync.Once, and persists any permanent error so subsequent callers
|
||||
// see the same failure without re-attempting the PUT. Transient
|
||||
// errors (ErrTransport, ErrCircuitBreaker) are NOT persisted — the
|
||||
// next caller retries.
|
||||
onceMu sync.Mutex
|
||||
once map[int]*sync.Once
|
||||
initMu sync.Mutex
|
||||
initErr map[int]error
|
||||
|
||||
// Lazy init state for the dim-less keyword-only index used by the
|
||||
// no-embedding path (concurrentBatchSaveNoEmbedding in the service
|
||||
// layer). Mutex + flag pattern (rather than sync.Once) so transient
|
||||
// failures can be retried by the next caller — sync.Once does not
|
||||
// support reset.
|
||||
keywordsMu sync.Mutex
|
||||
keywordsReady bool
|
||||
keywordsErr error
|
||||
|
||||
// sink receives audit events (index created / reindex executed). nil
|
||||
// means no auditing; use r.auditSink() to get a non-nil sink. Set via
|
||||
// WithAuditSink at construction.
|
||||
sink AuditSink
|
||||
}
|
||||
|
||||
// Compile-time interface satisfaction (Go best practice — keeps the build
|
||||
// red if the interface drifts and our implementation lags).
|
||||
var _ interfaces.RetrieveEngineRepository = (*Repository)(nil)
|
||||
|
||||
// NewRepository builds a new OpenSearch k-NN repository and verifies the
|
||||
// backing cluster is reachable + version-compatible + has the k-NN
|
||||
// plugin installed on every cluster node. It does NOT create any index —
|
||||
// callers (Save / Retrieve) trigger lazy per-dimension creation via
|
||||
// ensureReady on first use.
|
||||
//
|
||||
// storeID is the VectorStore.ID owning this repository instance. It is
|
||||
// folded into the base index name so multiple OpenSearch VectorStores
|
||||
// pointing at the same cluster do not collide:
|
||||
// - storeID == "" — env-path; no prefix, base index = "weknora".
|
||||
// - len(storeID) >= 16 — DB-store path; prefix = storeID[:12]
|
||||
// (48-bit collision space).
|
||||
// - 1..15 chars — rejected with ErrConfigInvalid.
|
||||
//
|
||||
// indexCfg is optional — pass nil to use env var (OPENSEARCH_INDEX) or
|
||||
// default ("weknora") values.
|
||||
//
|
||||
// Optional behavior is configured via functional options (e.g.
|
||||
// WithAuditSink). Passing no options keeps audit emission as a no-op, so the
|
||||
// env-path and tests need no extra wiring.
|
||||
//
|
||||
// Returns a typed sentinel error wrapped with %w; callers translate to
|
||||
// AppError at the engine-factory boundary.
|
||||
func NewRepository(
|
||||
ctx context.Context,
|
||||
client *osapi.Client,
|
||||
storeID string,
|
||||
indexCfg *types.IndexConfig,
|
||||
opts ...Option,
|
||||
) (interfaces.RetrieveEngineRepository, error) {
|
||||
log := logger.GetLogger(ctx)
|
||||
|
||||
if storeID != "" && len(storeID) < 16 {
|
||||
return nil, fmt.Errorf(
|
||||
"opensearch: storeID must be empty or >=16 chars, got %d: %w",
|
||||
len(storeID), ErrConfigInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
base := types.ResolveIndexName(indexCfg, "OPENSEARCH_INDEX", "weknora")
|
||||
if storeID != "" {
|
||||
// 12 hex chars → 48-bit collision space (UUID is 32 chars; using
|
||||
// half gives strong-enough collision avoidance for the realistic
|
||||
// "two OpenSearch VectorStores on the same cluster" case).
|
||||
base = fmt.Sprintf("%s_%s", base, storeID[:12])
|
||||
}
|
||||
base, err := sanitizeIndexName(base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opensearch: invalid index base name: %w", err)
|
||||
}
|
||||
|
||||
icfg, err := buildInternalCfg(indexCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opensearch: invalid index config: %w", err)
|
||||
}
|
||||
|
||||
if err := probeVersion(ctx, client); err != nil {
|
||||
return nil, err // already wraps ErrVersionUnsupported / ErrTransport
|
||||
}
|
||||
if err := probeKNNPlugin(ctx, client); err != nil {
|
||||
return nil, err // already wraps ErrConfigInvalid / ErrTransport
|
||||
}
|
||||
|
||||
r := &Repository{
|
||||
client: client,
|
||||
baseIndex: base,
|
||||
cfg: icfg,
|
||||
once: make(map[int]*sync.Once),
|
||||
initErr: make(map[int]error),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
log.Infof("[OpenSearch] repository ready (baseIndex=%s, knn_engine=%s, hnsw_m=%d)",
|
||||
base, icfg.knnEngine, icfg.hnswM)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ensureReady creates the per-dimension index (alias-backed) the first
|
||||
// time a given embedding dimension is seen. Concurrent callers for the
|
||||
// same dim block on the same sync.Once.
|
||||
//
|
||||
// Transient vs permanent error handling:
|
||||
// - PERMANENT failures (ErrConfigInvalid, ErrAuth, ErrVersionUnsupported,
|
||||
// ErrDimensionMismatch) are persisted in initErr[dim] and re-served to
|
||||
// all future callers without retry — caller must restart or fix config.
|
||||
// - TRANSIENT failures (ErrTransport, ErrCircuitBreaker) are NOT
|
||||
// persisted; the sync.Once is reset for that dim so the next caller
|
||||
// can retry. This prevents a single cluster blip from permanently
|
||||
// locking out a dim.
|
||||
//
|
||||
// Caller MUST pass a real ctx; nil is rejected. dim must be in (0, 16000]
|
||||
// (OpenSearch knn_vector hard limit).
|
||||
//
|
||||
// CRITICAL INVARIANT: initErr[dim] = err is written INSIDE the once.Do
|
||||
// closure. Moving it outside opens a TOCTOU where a concurrent caller
|
||||
// reads zero before the first caller persists.
|
||||
func (r *Repository) ensureReady(ctx context.Context, dim int) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("opensearch: ensureReady requires non-nil ctx")
|
||||
}
|
||||
if dim <= 0 || dim > 16000 {
|
||||
return fmt.Errorf("opensearch: dim %d out of range (1..16000): %w",
|
||||
dim, ErrDimensionMismatch)
|
||||
}
|
||||
|
||||
r.onceMu.Lock()
|
||||
once, ok := r.once[dim]
|
||||
if !ok {
|
||||
once = &sync.Once{}
|
||||
r.once[dim] = once
|
||||
}
|
||||
r.onceMu.Unlock()
|
||||
|
||||
once.Do(func() {
|
||||
err := r.createIndexAndAlias(ctx, dim)
|
||||
if err != nil && isTransientErr(err) {
|
||||
// Do NOT persist transient errors. Reset the sync.Once so
|
||||
// the next caller retries (requires once map mutation).
|
||||
r.onceMu.Lock()
|
||||
delete(r.once, dim)
|
||||
r.onceMu.Unlock()
|
||||
// Fall through — caller still sees this attempt's err.
|
||||
}
|
||||
r.initMu.Lock()
|
||||
if err == nil || !isTransientErr(err) {
|
||||
r.initErr[dim] = err // nil on success, persist permanent failures
|
||||
}
|
||||
r.initMu.Unlock()
|
||||
})
|
||||
|
||||
r.initMu.Lock()
|
||||
err := r.initErr[dim]
|
||||
r.initMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// indexAlias returns the alias name for a given dimension. All reads /
|
||||
// writes target the alias; the alias points at the actual <alias>_v1
|
||||
// index, enabling future rolling-reindex swap workflows (the swap path
|
||||
// itself is deferred to a follow-up PR; see stubs.go swapToVersion).
|
||||
func (r *Repository) indexAlias(dim int) string {
|
||||
return fmt.Sprintf("%s_%d", r.baseIndex, dim)
|
||||
}
|
||||
|
||||
// keywordsIndex returns the name of the dim-less keyword-only index used
|
||||
// by the no-embedding save path (the service layer dispatches there when
|
||||
// the knowledge base has vector indexing disabled). The mapping is the
|
||||
// same as the per-dim index minus the knn_vector field.
|
||||
func (r *Repository) keywordsIndex() string {
|
||||
return r.baseIndex + "_keywords"
|
||||
}
|
||||
|
||||
// EngineType returns the engine constant for OpenSearch. Used by the
|
||||
// fan-out normalizer to route to the [0, 1] passthrough group.
|
||||
func (r *Repository) EngineType() types.RetrieverEngineType {
|
||||
return types.OpenSearchRetrieverEngineType
|
||||
}
|
||||
|
||||
// Support reports the retrieval modes this driver implements. OpenSearch
|
||||
// k-NN supports both ANN (vector) and BM25 (keyword) within a single
|
||||
// document; the keyword-only index keeps BM25 functional for callers
|
||||
// that omit the embedding map.
|
||||
func (r *Repository) Support() []types.RetrieverType {
|
||||
return []types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType}
|
||||
}
|
||||
|
||||
// probeVersion sends GET / and validates the cluster is OpenSearch in
|
||||
// a supported version range:
|
||||
// - Reject: ES (any), OS 1.x, OS 2.0~2.3 (Lucene HNSW preview).
|
||||
// - Warn-but-accept: OS 2.4~2.10 (Lucene HNSW GA, pre-LTS).
|
||||
// - Clean-accept: OS 2.11+, OS 3.x (primary tested: 3.3.2).
|
||||
func probeVersion(ctx context.Context, client *osapi.Client) error {
|
||||
resp, err := client.Info(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensearch: cluster info: %w", wrapTransport(err))
|
||||
}
|
||||
distribution := resp.Version.Distribution
|
||||
number := resp.Version.Number
|
||||
|
||||
if distribution != "opensearch" {
|
||||
return fmt.Errorf("opensearch: unsupported distribution %q: %w",
|
||||
distribution, ErrVersionUnsupported)
|
||||
}
|
||||
maj, min := parseMajorMinor(number)
|
||||
switch {
|
||||
case maj == 1:
|
||||
return fmt.Errorf("opensearch: 1.x EOL: %w", ErrVersionUnsupported)
|
||||
case maj == 2 && min >= 0 && min <= 3:
|
||||
return fmt.Errorf("opensearch: %s lacks Lucene HNSW GA (need 2.4+): %w",
|
||||
number, ErrVersionUnsupported)
|
||||
case maj == 2 && min >= 4 && min <= 10:
|
||||
logger.GetLogger(ctx).Warnf(
|
||||
"[OpenSearch] using pre-2.11 cluster %s; recommend 2.11+ LTS", number)
|
||||
return nil
|
||||
case maj == 2 || maj == 3:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("opensearch: unsupported version %s: %w",
|
||||
number, ErrVersionUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMajorMinor extracts (major, minor) from a semver string such as
|
||||
// "3.3.2", "2.10.0-SNAPSHOT", "2.10". Returns (0, 0) for unparseable
|
||||
// input. Robust to pre-release suffixes (strips after '-') and missing
|
||||
// patch component (e.g. "2.10").
|
||||
func parseMajorMinor(num string) (major, minor int) {
|
||||
base := strings.SplitN(num, "-", 2)[0]
|
||||
parts := strings.Split(base, ".")
|
||||
if len(parts) < 2 {
|
||||
return 0, 0
|
||||
}
|
||||
m1, err1 := strconv.Atoi(parts[0])
|
||||
m2, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return m1, m2
|
||||
}
|
||||
|
||||
// probeKNNPlugin verifies the OpenSearch cluster has the opensearch-knn
|
||||
// plugin installed on EVERY node. Without it on at least one node,
|
||||
// knn_vector queries that route a shard to that node fail with opaque
|
||||
// shard-failure errors — we surface this as a clear "plugin missing on
|
||||
// N nodes" message at registration.
|
||||
//
|
||||
// Implementation: _cat/plugins returns one row per (node, plugin) pair.
|
||||
// We group by node name and verify every distinct node has the
|
||||
// opensearch-knn component.
|
||||
func probeKNNPlugin(ctx context.Context, client *osapi.Client) error {
|
||||
// CatPluginsReq's Params struct has no Format field — the SDK
|
||||
// negotiates JSON via Accept header and returns a typed
|
||||
// CatPluginsResp with .Plugins []CatPluginResp already parsed.
|
||||
resp, err := client.Cat.Plugins(ctx, &osapi.CatPluginsReq{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensearch: cat plugins: %w", wrapTransport(err))
|
||||
}
|
||||
|
||||
// Group by node, verify every distinct node has opensearch-knn.
|
||||
nodes := make(map[string]bool) // nodeName -> hasKNN
|
||||
for _, p := range resp.Plugins {
|
||||
if p.Name == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := nodes[p.Name]; !seen {
|
||||
nodes[p.Name] = false
|
||||
}
|
||||
if p.Component == "opensearch-knn" {
|
||||
nodes[p.Name] = true
|
||||
}
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return fmt.Errorf("opensearch: _cat/plugins returned no rows: %w", ErrConfigInvalid)
|
||||
}
|
||||
var missing []string
|
||||
for node, hasKNN := range nodes {
|
||||
if !hasKNN {
|
||||
missing = append(missing, node)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf(
|
||||
"opensearch: opensearch-knn plugin missing on %d/%d nodes (%v): %w",
|
||||
len(missing), len(nodes), missing, ErrConfigInvalid,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wrapTransport classifies a raw transport error into one of the
|
||||
// driver's sentinel errors based on HTTP status ONLY. The cluster-side
|
||||
// error message (err.Error()) is intentionally NOT included in the
|
||||
// wrapped public error — it may contain internal index names, shard
|
||||
// IDs, node hostnames, or document content snippets. The full SDK
|
||||
// error is routed to log.Debug for operator forensics.
|
||||
//
|
||||
// Classification:
|
||||
// - 401 / 403 → ErrAuth
|
||||
// - 404 → caller decides (use isNotFound separately for index ops)
|
||||
// - 429 + knn_circuit_breaker_exception → ErrCircuitBreaker
|
||||
// - 5xx / network / timeout → ErrTransport
|
||||
//
|
||||
// This is the ONE place in the package that maps wire errors to
|
||||
// sentinels; downstream functions just propagate.
|
||||
func wrapTransport(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Route the verbose SDK message to debug log (operator-visible, not
|
||||
// user-visible). Background ctx is intentional — wrapTransport may
|
||||
// be invoked from any caller including the constructor.
|
||||
logger.GetLogger(context.Background()).Debugf("[OpenSearch] transport err: %v", err)
|
||||
|
||||
var se *opensearch.StructError
|
||||
if errors.As(err, &se) {
|
||||
switch se.Status {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("authentication failed: %w", ErrAuth)
|
||||
case http.StatusTooManyRequests:
|
||||
if se.Err.Type == "knn_circuit_breaker_exception" {
|
||||
return fmt.Errorf("circuit breaker open: %w", ErrCircuitBreaker)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("transport error: %w", ErrTransport)
|
||||
}
|
||||
|
||||
// isNotFound returns true if err carries an HTTP 404. Used by index /
|
||||
// search code paths that want to distinguish "missing alias" from
|
||||
// "transport problem".
|
||||
func isNotFound(err error) bool {
|
||||
var se *opensearch.StructError
|
||||
return errors.As(err, &se) && se.Status == http.StatusNotFound
|
||||
}
|
||||
|
||||
// isAlreadyExistsError returns true if err is a 400 with OpenSearch's
|
||||
// resource_already_exists_exception type. Used by createIndexAndAlias
|
||||
// to handle the cross-process race where two replicas try to create
|
||||
// the same per-dim index simultaneously.
|
||||
func isAlreadyExistsError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var se *opensearch.StructError
|
||||
if !errors.As(err, &se) {
|
||||
return false
|
||||
}
|
||||
return se.Status == http.StatusBadRequest &&
|
||||
se.Err.Type == "resource_already_exists_exception"
|
||||
}
|
||||
|
||||
// sanitizeRe enforces the OpenSearch index-name contract:
|
||||
// - lowercase ASCII letters, digits, underscore, hyphen only
|
||||
// - must start with [a-z0-9]
|
||||
// - length 1..255
|
||||
var sanitizeRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,254}$`)
|
||||
|
||||
// sanitizeIndexName rejects names that violate the OpenSearch index-name
|
||||
// rules. Any non-conforming input returns ErrConfigInvalid — the driver
|
||||
// does NOT silently rewrite invalid input so operators get a clear
|
||||
// error at registration.
|
||||
func sanitizeIndexName(name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("empty index name: %w", ErrConfigInvalid)
|
||||
}
|
||||
// Explicit reject of OpenSearch-reserved wildcards / separators /
|
||||
// control characters. Some of these would already be caught by the
|
||||
// regexp below, but a dedicated check keeps the error message
|
||||
// specific.
|
||||
if strings.ContainsAny(name, "*?,\n\r\t/\\") {
|
||||
return "", fmt.Errorf("invalid char in %q: %w", name, ErrConfigInvalid)
|
||||
}
|
||||
if !sanitizeRe.MatchString(name) {
|
||||
return "", fmt.Errorf("name %q must match %s: %w",
|
||||
name, sanitizeRe.String(), ErrConfigInvalid)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// drainAndClose closes the response body after draining unread bytes —
|
||||
// required for HTTP keep-alive connection reuse. opensearch-go does NOT
|
||||
// drain the body for us; failing to drain on every response will starve
|
||||
// the per-host connection pool under load.
|
||||
func drainAndClose(body io.ReadCloser) {
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, body)
|
||||
_ = body.Close()
|
||||
}
|
||||
|
||||
// limitedDecode wraps json.NewDecoder with an io.LimitReader so a
|
||||
// pathological or malicious cluster response cannot exhaust process
|
||||
// memory. Callers pass the documented per-endpoint cap (e.g. 64MB for
|
||||
// bulk, 16MB for search, 1MB for plugin probe).
|
||||
func limitedDecode(body io.Reader, maxBytes int64, v any) error {
|
||||
return json.NewDecoder(io.LimitReader(body, maxBytes)).Decode(v)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Retrieve dispatches to vectorRetrieve or keywordsRetrieve based on
|
||||
// params.RetrieverType. Returns exactly one *RetrieveResult — the
|
||||
// service-layer multi-store dispatcher depends on this one-bundle-per-
|
||||
// driver protocol. Score in result is RAW [0, 1] passthrough (the
|
||||
// OpenSearch k-NN plugin's SpaceType.COSINESIMIL.scoreTranslation has
|
||||
// already mapped to (1 + cos) / 2 before the score reaches us).
|
||||
//
|
||||
// Dim resolution order:
|
||||
// 1. params.AdditionalParams["dim"] if present and positive
|
||||
// 2. len(params.Embedding) if positive
|
||||
// 3. cross-dim multi-index search for keyword-only paths via
|
||||
// "<base>_*" wildcard pattern
|
||||
//
|
||||
// Vector retrieve always requires a positive dim (it's intrinsic to k-NN).
|
||||
func (r *Repository) Retrieve(
|
||||
ctx context.Context,
|
||||
params types.RetrieveParams,
|
||||
) ([]*types.RetrieveResult, error) {
|
||||
dim, multiIndex := resolveDim(params)
|
||||
|
||||
switch params.RetrieverType {
|
||||
case types.VectorRetrieverType:
|
||||
if dim == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"opensearch: vector retrieve requires embedding or AdditionalParams[\"dim\"]: %w",
|
||||
ErrDimensionMismatch)
|
||||
}
|
||||
if err := r.ensureReady(ctx, dim); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.vectorRetrieve(ctx, params, dim)
|
||||
case types.KeywordsRetrieverType:
|
||||
if multiIndex {
|
||||
// BM25-only path without dim — search across "<base>_*"
|
||||
// wildcard pattern. Slower than a single-index search but
|
||||
// correct for admin / keyword-only callers.
|
||||
return r.keywordsRetrieve(ctx, params, r.baseIndex+"_*")
|
||||
}
|
||||
if err := r.ensureReady(ctx, dim); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.keywordsRetrieve(ctx, params, r.indexAlias(dim))
|
||||
default:
|
||||
return nil, fmt.Errorf("opensearch: unsupported retriever type %q", params.RetrieverType)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDim returns (dim, multiIndex). multiIndex=true means dim is
|
||||
// unknown and the caller should use a wildcard alias pattern (keyword
|
||||
// path only — vector retrieve cannot be dim-less).
|
||||
func resolveDim(p types.RetrieveParams) (int, bool) {
|
||||
if p.AdditionalParams != nil {
|
||||
if v, ok := p.AdditionalParams["dim"].(int); ok && v > 0 {
|
||||
return v, false
|
||||
}
|
||||
}
|
||||
if len(p.Embedding) > 0 {
|
||||
return len(p.Embedding), false
|
||||
}
|
||||
return 0, true // unknown — multi-index fallback for keyword
|
||||
}
|
||||
|
||||
func (r *Repository) vectorRetrieve(
|
||||
ctx context.Context, p types.RetrieveParams, dim int,
|
||||
) ([]*types.RetrieveResult, error) {
|
||||
body, err := buildKNNQuery(p.Embedding, effectiveTopK(ctx, p), p.Threshold, fromParams(p))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits, err := r.search(ctx, r.indexAlias(dim), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapResults(ctx, hits, types.VectorRetrieverType, types.MatchTypeEmbedding), nil
|
||||
}
|
||||
|
||||
func (r *Repository) keywordsRetrieve(
|
||||
ctx context.Context, p types.RetrieveParams, indexPattern string,
|
||||
) ([]*types.RetrieveResult, error) {
|
||||
body, err := buildKeywordQuery(p.Query, effectiveTopK(ctx, p), p.Threshold, fromParams(p))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits, err := r.search(ctx, indexPattern, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapResults(ctx, hits, types.KeywordsRetrieverType, types.MatchTypeKeywords), nil
|
||||
}
|
||||
|
||||
// effectiveTopK returns the caller's TopK or 10 if unset. The 10 is a
|
||||
// defensive default — every real caller sets TopK, so a TopK=10 in
|
||||
// production logs indicates a caller bug. Caps at 10000 to stay below
|
||||
// OpenSearch's index.max_result_window default.
|
||||
func effectiveTopK(ctx context.Context, p types.RetrieveParams) int {
|
||||
if p.TopK <= 0 {
|
||||
logger.GetLogger(ctx).Warnf("[OpenSearch] Retrieve called with TopK<=0; defaulting to 10 (caller bug?)")
|
||||
return 10
|
||||
}
|
||||
if p.TopK > 10000 {
|
||||
return 10000
|
||||
}
|
||||
return p.TopK
|
||||
}
|
||||
|
||||
func (r *Repository) search(ctx context.Context, indexPattern string, body []byte) ([]hit, error) {
|
||||
req := osapi.SearchReq{Indices: []string{indexPattern}, Body: bytes.NewReader(body)}
|
||||
resp, err := r.client.Search(ctx, &req)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("opensearch: index %s missing: %w", indexPattern, ErrIndexNotFound)
|
||||
}
|
||||
return nil, wrapTransport(err)
|
||||
}
|
||||
defer func() {
|
||||
if resp != nil {
|
||||
drainAndClose(resp.Inspect().Response.Body)
|
||||
}
|
||||
}()
|
||||
// Cap response body at 16MB (search results bounded by max_result_window * doc_size).
|
||||
return parseSearchHits(io.LimitReader(resp.Inspect().Response.Body, 16<<20))
|
||||
}
|
||||
|
||||
// hit is the structural shape we pull from each OpenSearch search hit.
|
||||
// Field-by-field decode (vs map[string]any) keeps the JSON shape
|
||||
// pinned at compile time.
|
||||
type hit struct {
|
||||
ID string `json:"_id"` // equals chunk_id per the indexing invariant
|
||||
Score float64 `json:"_score"`
|
||||
Source struct {
|
||||
Content string `json:"content"`
|
||||
ChunkID string `json:"chunk_id"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceType int `json:"source_type"` // integer, not stringified
|
||||
TagID string `json:"tag_id"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
} `json:"_source"`
|
||||
}
|
||||
|
||||
func parseSearchHits(body io.Reader) ([]hit, error) {
|
||||
var resp struct {
|
||||
Hits struct {
|
||||
Hits []hit `json:"hits"`
|
||||
} `json:"hits"`
|
||||
}
|
||||
if err := json.NewDecoder(body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("opensearch: parse search response: %w", ErrTransport)
|
||||
}
|
||||
return resp.Hits.Hits, nil
|
||||
}
|
||||
|
||||
// wrapResults converts OpenSearch hits to the upstream IndexWithScore
|
||||
// shape. Always returns exactly one *RetrieveResult — the service-layer
|
||||
// multi-store dispatcher depends on this "one engine result per driver"
|
||||
// contract.
|
||||
//
|
||||
// Defensive: warns if hit._id != _source.chunk_id (we set _id=chunk_id
|
||||
// on every write, so a mismatch means external tooling has been writing
|
||||
// into the index) but does not fail the query — the result is still
|
||||
// usable for ranking, just suspicious.
|
||||
func wrapResults(ctx context.Context, hits []hit, rt types.RetrieverType, mt types.MatchType) []*types.RetrieveResult {
|
||||
log := logger.GetLogger(ctx)
|
||||
out := make([]*types.IndexWithScore, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
if h.ID != h.Source.ChunkID {
|
||||
log.Warnf("[OpenSearch] hit._id=%q != _source.chunk_id=%q (D12 invariant violation)",
|
||||
h.ID, h.Source.ChunkID)
|
||||
}
|
||||
out = append(out, &types.IndexWithScore{
|
||||
ID: h.ID,
|
||||
ChunkID: h.Source.ChunkID,
|
||||
KnowledgeID: h.Source.KnowledgeID,
|
||||
KnowledgeBaseID: h.Source.KnowledgeBaseID,
|
||||
SourceID: h.Source.SourceID,
|
||||
SourceType: types.SourceType(h.Source.SourceType),
|
||||
TagID: h.Source.TagID,
|
||||
Content: h.Source.Content,
|
||||
Score: h.Score,
|
||||
MatchType: mt,
|
||||
IsEnabled: h.Source.IsEnabled,
|
||||
})
|
||||
}
|
||||
return []*types.RetrieveResult{{
|
||||
Results: out,
|
||||
RetrieverEngineType: types.OpenSearchRetrieverEngineType,
|
||||
RetrieverType: rt,
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// End-to-end repository tests intentionally use loopback httptest servers.
|
||||
secutils.SetSSRFWhitelistFromRaw("127.0.0.1,::1,localhost,opensearch.example.com")
|
||||
code := m.Run()
|
||||
secutils.SetSSRFWhitelistFromRaw("")
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// This file holds the remaining stubs for methods whose real implementation
|
||||
// has not landed yet — the rolling-reindex swap (swapToVersion) and the
|
||||
// precise storage estimate. CopyIndices (copy.go), BatchUpdateChunkEnabledStatus
|
||||
// and BatchUpdateChunkTagID (bulk_update.go) are now implemented. Each
|
||||
// remaining stub returns ErrFeatureNotEnabled (or, for EstimateStorageSize, a
|
||||
// conservative lower-bound) so any accidental invocation surfaces loudly.
|
||||
|
||||
// EstimateStorageSize: the real implementation that reads cluster
|
||||
// `_stats` for the per-dim alias arrives in a later change. For now we
|
||||
// return a conservative lower-bound estimate using the HNSW memory
|
||||
// formula so the upstream KB-delete guard fails-closed (treats non-empty
|
||||
// KBs as "may free non-trivial storage, force confirmation") rather than
|
||||
// failing open.
|
||||
//
|
||||
// Formula: N * (1024 content bytes + 4*dimGuess float + 128 HNSW M=16 overhead)
|
||||
// dimGuess = 768 (common embedding size; the real implementation reads
|
||||
// the actual dim from the cluster).
|
||||
func (r *Repository) EstimateStorageSize(
|
||||
_ context.Context,
|
||||
indexInfoList []*types.IndexInfo,
|
||||
_ map[string]any,
|
||||
) int64 {
|
||||
if len(indexInfoList) == 0 {
|
||||
return 0
|
||||
}
|
||||
const (
|
||||
contentBytes = 1024 // average chunk content
|
||||
embDimGuess = 768 // common embedding size
|
||||
hnswOverhead = 128 // M=16 → 8*M = 128 bytes/vector
|
||||
)
|
||||
return int64(len(indexInfoList)) * int64(contentBytes+4*embDimGuess+hnswOverhead)
|
||||
}
|
||||
|
||||
// swapToVersion is a stub for the future rolling-reindex swap path.
|
||||
// Calling it is illegal at this stage — alias is fixed at "_v1" and only
|
||||
// a follow-up change ships the swap orchestration. The surface exists so
|
||||
// the future API contract is reviewable now.
|
||||
//
|
||||
// Unexported because it is not part of the public
|
||||
// RetrieveEngineRepository interface — exposing it would require an
|
||||
// interface widening in internal/types/interfaces.
|
||||
func (r *Repository) swapToVersion(_ context.Context, _ int, _ int) error {
|
||||
return ErrFeatureNotEnabled
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/opensearch-project/opensearch-go/v4"
|
||||
osapi "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// NewOpenSearchClient builds a TLS-hardened, pool-tuned *osapi.Client for
|
||||
// the OpenSearch driver. The caller wires it into the registry from the
|
||||
// env path (container) and the DB-store path (engine factory); the
|
||||
// Repository constructor itself receives the pre-built client.
|
||||
//
|
||||
// TLS posture:
|
||||
// - MinVersion: TLS 1.2 (TLS 1.3 negotiated when both ends support).
|
||||
// - InsecureSkipVerify: opt-in via ConnectionConfig.InsecureSkipVerify.
|
||||
// Default false. Operators set true ONLY for self-signed dev clusters.
|
||||
// - CipherSuites: explicit forward-secrecy-only list. TLS 1.2's default
|
||||
// cipher list includes TLS_RSA_* (no forward secrecy); we drop them.
|
||||
// TLS 1.3 cipher selection is automatic and safe.
|
||||
//
|
||||
// Transport tuning:
|
||||
// - MaxIdleConnsPerHost: 32 (Go default 2 is too low when callers
|
||||
// dispatch concurrent queries across multiple knowledge bases)
|
||||
// - IdleConnTimeout: 90s (typical LB keep-alive)
|
||||
// - ResponseHeaderTimeout: 30s (per-request safety net)
|
||||
// - ExpectContinueTimeout: 1s
|
||||
func NewOpenSearchClient(cfg *types.ConnectionConfig) (*osapi.Client, error) {
|
||||
if cfg == nil || cfg.Addr == "" {
|
||||
return nil, fmt.Errorf("opensearch: ConnectionConfig.Addr required: %w", ErrConfigInvalid)
|
||||
}
|
||||
if err := secutils.ValidateURLForSSRF(cfg.Addr); err != nil {
|
||||
return nil, fmt.Errorf("opensearch: address failed SSRF validation: %w", err)
|
||||
}
|
||||
transport := buildHTTPTransport(cfg.InsecureSkipVerify)
|
||||
return osapi.NewClient(osapi.Config{
|
||||
Client: opensearch.Config{
|
||||
Addresses: []string{cfg.Addr},
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
Transport: &secutils.SSRFValidatingRoundTripper{Base: transport},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildHTTPTransport constructs the TLS-hardened, pool-tuned
|
||||
// *http.Transport used by every OpenSearch client this driver creates.
|
||||
// Extracted from newOpenSearchClient so unit tests can pin the TLS
|
||||
// posture directly (the opensearch-go SDK wraps the transport in an
|
||||
// internal interface, making reflection-based introspection of the
|
||||
// final client fragile across SDK versions).
|
||||
func buildHTTPTransport(insecureSkipVerify bool) *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: secutils.SSRFSafeDialContext,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: insecureSkipVerify, //nolint:gosec — operator opt-in flag
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
},
|
||||
},
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// TestNewOpenSearchClient_RejectsEmptyAddr verifies the constructor
|
||||
// refuses an empty Addr — operators who forget to set OPENSEARCH_ADDR
|
||||
// get a clear ErrConfigInvalid at registration instead of a cryptic
|
||||
// transport error later.
|
||||
func TestNewOpenSearchClient_RejectsEmptyAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewOpenSearchClient(&types.ConnectionConfig{Addr: ""})
|
||||
if !errors.Is(err, ErrConfigInvalid) {
|
||||
t.Fatalf("empty addr: want ErrConfigInvalid, got %v", err)
|
||||
}
|
||||
_, err = NewOpenSearchClient(nil)
|
||||
if !errors.Is(err, ErrConfigInvalid) {
|
||||
t.Fatalf("nil cfg: want ErrConfigInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewOpenSearchClient_Succeeds_OnValidAddr is a smoke test for the
|
||||
// happy path — a non-empty Addr must produce a non-nil *osapi.Client.
|
||||
// We don't probe the cluster (no network), just verify the constructor
|
||||
// returns successfully.
|
||||
func TestNewOpenSearchClient_Succeeds_OnValidAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, err := NewOpenSearchClient(&types.ConnectionConfig{
|
||||
Addr: "https://opensearch.example.com:9200",
|
||||
Username: "admin",
|
||||
Password: "secret", // not a real password — wire-format only
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOpenSearchClient: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("client must be non-nil on success")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHTTPTransport_DefaultsToSecureTLS pins the default
|
||||
// InsecureSkipVerify=false. Operators who don't opt in MUST get a
|
||||
// strict-verify HTTPS client.
|
||||
func TestBuildHTTPTransport_DefaultsToSecureTLS(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := buildHTTPTransport(false)
|
||||
if tr.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig must be set")
|
||||
}
|
||||
if tr.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("default InsecureSkipVerify must be false (secure)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHTTPTransport_InsecureSkipVerifyOptIn confirms the flag
|
||||
// flows through to tls.Config. Self-signed dev clusters can opt in;
|
||||
// production deployments MUST leave it false.
|
||||
func TestBuildHTTPTransport_InsecureSkipVerifyOptIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := buildHTTPTransport(true)
|
||||
if tr.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig must be set")
|
||||
}
|
||||
if !tr.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify=true must propagate to tls.Config")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHTTPTransport_TLS12MinVersion pins the TLS 1.2 hard floor.
|
||||
// TLS 1.3 negotiates automatically when both ends support it; 1.0 / 1.1
|
||||
// must be rejected at handshake time.
|
||||
func TestBuildHTTPTransport_TLS12MinVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := buildHTTPTransport(false)
|
||||
if tr.TLSClientConfig.MinVersion != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion: want tls.VersionTLS12 (0x%x), got 0x%x",
|
||||
tls.VersionTLS12, tr.TLSClientConfig.MinVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHTTPTransport_CipherSuitesExcludeNonECDHE pins the explicit
|
||||
// cipher list to forward-secrecy-only ECDHE suites. TLS 1.2's default
|
||||
// list includes TLS_RSA_* (no forward secrecy); we drop them. TLS 1.3
|
||||
// cipher selection is automatic so this only matters for the 1.2 path.
|
||||
func TestBuildHTTPTransport_CipherSuitesExcludeNonECDHE(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := buildHTTPTransport(false)
|
||||
cs := tr.TLSClientConfig.CipherSuites
|
||||
if len(cs) == 0 {
|
||||
t.Fatal("CipherSuites must be explicitly pinned (got empty)")
|
||||
}
|
||||
allowed := map[uint16]bool{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: true,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: true,
|
||||
}
|
||||
for _, suite := range cs {
|
||||
if !allowed[suite] {
|
||||
t.Fatalf("cipher suite 0x%x is not in the forward-secrecy whitelist", suite)
|
||||
}
|
||||
}
|
||||
// Spot-check that a known-weak non-ECDHE cipher is NOT in the list.
|
||||
for _, suite := range cs {
|
||||
if suite == tls.TLS_RSA_WITH_AES_128_GCM_SHA256 {
|
||||
t.Fatal("TLS_RSA_WITH_AES_128_GCM_SHA256 must NOT be present (no forward secrecy)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHTTPTransport_PoolTuning pins the connection-pool settings.
|
||||
// The Go default MaxIdleConnsPerHost is 2, which is too low for concurrent
|
||||
// queries across multiple knowledge bases.
|
||||
func TestBuildHTTPTransport_PoolTuning(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := buildHTTPTransport(false)
|
||||
if tr.MaxIdleConnsPerHost < 32 {
|
||||
t.Errorf("MaxIdleConnsPerHost: want >=32 for concurrent KB queries, got %d",
|
||||
tr.MaxIdleConnsPerHost)
|
||||
}
|
||||
if tr.IdleConnTimeout == 0 {
|
||||
t.Error("IdleConnTimeout must be set (Go default of 0 leaks idle conns)")
|
||||
}
|
||||
if tr.ResponseHeaderTimeout == 0 {
|
||||
t.Error("ResponseHeaderTimeout must be set (Go default of 0 means unbounded)")
|
||||
}
|
||||
if tr.ExpectContinueTimeout == 0 {
|
||||
t.Error("ExpectContinueTimeout must be set (avoids 100-continue stalls)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user