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,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
|
||||
}
|
||||
Reference in New Issue
Block a user