mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-05 15:20:30 +08:00
## What Adds [**SereneDB**](https://serenedb.com) as a selectable doc-store engine on **both** RAGFlow paths: - the **Go** `DocEngine` (`internal/engine/serenedb`), alongside Elasticsearch and Infinity; - the **Python** `DocStoreConnection` (`rag/utils/serenedb_conn.py`) + `DOC_ENGINE=serenedb` registration. SereneDB is a PostgreSQL-wire engine (DuckDB execution) whose single inverted index carries **both** a scored text column (`@@`, BM25) and an IVF vector column (`<#>`, inner product), so hybrid search is one SQL statement. The Go engine connects with `database/sql` + `lib/pq` (already a dependency, no new module); the Python connector uses psycopg2 (already a dependency). ## Storage model One table per tenant with `kb_id` as a filter column - the **Elasticsearch / OceanBase** model, not Infinity's per-dataset tables. This keeps BM25 statistics (IDF, avgdl) computed over the whole tenant corpus (global IDF). Both connectors use this identical layout, so they are storage- and retrieval-compatible: `hybrid` proxy routing and Python↔Go switching are safe. On the Python side the connector is wired as OceanBase's plain-SQL sibling (chunk_data JSON metadata, inline chunk vectors, verbatim ES field names); the ES tokenizer path is unchanged. Metadata stays one table per tenant (`ragflow_doc_meta_<tenant>`). The query shapes mirror the Python connector, including the five empirically-found landmines: the scored dictionary needs `frequency + norm` (else `BM25()` silently returns 0.0), the `@@` query is the tokenized query, the scored lexical branch matches one column, vectors use an L2-normalized shadow column with `ip`/`sq8`, and the similarity threshold goes directly in the ANN scan's `WHERE`. **Minimum engine version: SereneDB 26.07.4.** --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
6.3 KiB
Go
221 lines
6.3 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package serenedb
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/server/config"
|
|
|
|
_ "github.com/lib/pq"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
defaultHost = "serenedb"
|
|
defaultPort = 7890
|
|
defaultUser = "postgres"
|
|
defaultDBName = "postgres"
|
|
defaultSSLMode = "disable"
|
|
poolMaxOpen = 8
|
|
poolMaxIdle = 2
|
|
connMaxLifetime = 30 * time.Minute
|
|
)
|
|
|
|
var (
|
|
dsnKeywordPwRe = regexp.MustCompile(`(password=)('(?:[^'\\]|\\.)*'|\S+)`)
|
|
dsnURLPwRe = regexp.MustCompile(`(://[^:/@\s]+:)[^@\s]+(@)`)
|
|
)
|
|
|
|
// redactDSN hides the password in both the lib/pq keyword form (password=...)
|
|
// and the URL form (scheme://user:password@host) used by SERENEDB_DSN.
|
|
func redactDSN(dsn string) string {
|
|
dsn = dsnKeywordPwRe.ReplaceAllString(dsn, "${1}***")
|
|
dsn = dsnURLPwRe.ReplaceAllString(dsn, "${1}***${2}")
|
|
return dsn
|
|
}
|
|
|
|
// quoteDSNValue wraps a lib/pq keyword-DSN value in single quotes, escaping
|
|
// backslashes and quotes, so hosts/users/passwords with spaces or special
|
|
// characters do not break the DSN.
|
|
func quoteDSNValue(v string) string {
|
|
r := strings.ReplaceAll(v, `\`, `\\`)
|
|
r = strings.ReplaceAll(r, `'`, `\'`)
|
|
return "'" + r + "'"
|
|
}
|
|
|
|
// serenedbEngine implements engine.DocEngine backed by SereneDB.
|
|
type serenedbEngine struct {
|
|
db *sql.DB
|
|
dsnSafe string
|
|
}
|
|
|
|
// NewEngine constructs the engine from the SereneDB config, mirroring the
|
|
// elasticsearch/infinity factories in engine/global.go.
|
|
func NewEngine(cfg config.SereneDBConfig) (*serenedbEngine, error) {
|
|
dsn := buildDSN(cfg)
|
|
|
|
db, err := sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serenedb: open: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(poolMaxOpen)
|
|
db.SetMaxIdleConns(poolMaxIdle)
|
|
db.SetConnMaxLifetime(connMaxLifetime)
|
|
|
|
e := &serenedbEngine{db: db, dsnSafe: redactDSN(dsn)}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("serenedb: ping %s: %w", e.dsnSafe, err)
|
|
}
|
|
common.Info("SereneDB engine initialized", zap.String("dsn", e.dsnSafe))
|
|
return e, nil
|
|
}
|
|
|
|
// buildDSN assembles a lib/pq keyword DSN. SERENEDB_DSN overrides everything
|
|
// (matching the Python connector); otherwise config values fill in, then the
|
|
// documented defaults.
|
|
func buildDSN(cfg config.SereneDBConfig) string {
|
|
if env := os.Getenv("SERENEDB_DSN"); env != "" {
|
|
return env
|
|
}
|
|
host, port, user, password, dbName := defaultHost, defaultPort, defaultUser, "", defaultDBName
|
|
sslMode := defaultSSLMode
|
|
if cfg.Host != "" {
|
|
host = cfg.Host
|
|
}
|
|
if cfg.Port != 0 {
|
|
port = cfg.Port
|
|
}
|
|
if cfg.User != "" {
|
|
user = cfg.User
|
|
}
|
|
password = cfg.Password
|
|
if cfg.DBName != "" {
|
|
dbName = cfg.DBName
|
|
}
|
|
if cfg.SSLMode != "" {
|
|
sslMode = cfg.SSLMode
|
|
}
|
|
parts := []string{
|
|
fmt.Sprintf("host=%s", quoteDSNValue(host)),
|
|
fmt.Sprintf("port=%d", port),
|
|
fmt.Sprintf("user=%s", quoteDSNValue(user)),
|
|
fmt.Sprintf("dbname=%s", quoteDSNValue(dbName)),
|
|
fmt.Sprintf("sslmode=%s", quoteDSNValue(sslMode)),
|
|
}
|
|
if password != "" {
|
|
parts = append(parts, fmt.Sprintf("password=%s", quoteDSNValue(password)))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// GetType returns the engine type string.
|
|
func (e *serenedbEngine) GetType() string {
|
|
return "serenedb"
|
|
}
|
|
|
|
// SupportsPageRank reports dataset-level pagerank support. Like Elasticsearch,
|
|
// SereneDB folds pagerank_fea into every scored query and stores it in a real
|
|
// column that UpdateChunks can set, so the dataset-level toggle is supported.
|
|
func (e *serenedbEngine) SupportsPageRank() bool {
|
|
return true
|
|
}
|
|
|
|
// Ping verifies connectivity.
|
|
func (e *serenedbEngine) Ping(ctx context.Context) error {
|
|
return e.db.PingContext(ctx)
|
|
}
|
|
|
|
// Close releases the connection pool.
|
|
func (e *serenedbEngine) Close() error {
|
|
if e.db == nil {
|
|
return nil
|
|
}
|
|
return e.db.Close()
|
|
}
|
|
|
|
// exec runs a statement that returns no rows.
|
|
func (e *serenedbEngine) exec(ctx context.Context, query string, args ...interface{}) error {
|
|
_, err := e.db.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// queryMaps runs a query and shapes each row into a map keyed by column name,
|
|
// decoding JSON and array columns to structured values.
|
|
func (e *serenedbEngine) queryMaps(ctx context.Context, query string, args ...interface{}) ([]map[string]interface{}, error) {
|
|
rows, err := e.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
cols, err := rows.Columns()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []map[string]interface{}
|
|
for rows.Next() {
|
|
vals := make([]interface{}, len(cols))
|
|
ptrs := make([]interface{}, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
return nil, err
|
|
}
|
|
entity := make(map[string]interface{}, len(cols))
|
|
for i, col := range cols {
|
|
v := decodeValue(col, vals[i])
|
|
if v == nil {
|
|
continue
|
|
}
|
|
entity[col] = v
|
|
}
|
|
out = append(out, entity)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// tableExists reports whether a relation exists. It validates the identifier
|
|
// (table names cannot be parameterized) and queries the catalog, so a missing
|
|
// table returns (false, nil) while a connectivity or permission failure is
|
|
// surfaced as an error instead of being masked as "absent".
|
|
func (e *serenedbEngine) tableExists(ctx context.Context, tableName string) (bool, error) {
|
|
if !validIdentifier(tableName) {
|
|
return false, fmt.Errorf("serenedb: invalid table name %q", tableName)
|
|
}
|
|
rows, err := e.db.QueryContext(ctx,
|
|
"SELECT 1 FROM information_schema.tables WHERE table_name = $1 AND table_schema = current_schema() LIMIT 1",
|
|
tableName)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
return rows.Next(), rows.Err()
|
|
}
|