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
457 lines
16 KiB
Go
457 lines
16 KiB
Go
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)
|
|
}
|