Files
ai-agent/internal/ai/rag/vectordb/libsql.go
T

415 lines
13 KiB
Go
Raw Normal View History

package vectordb
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
turso "turso.tech/database/tursogo"
)
const (
defaultLibSQLPath = "data/agent/vectors.db"
defaultSearchTopK = 10
busyTimeoutMillis = 5000
)
var collectionNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
type LibSQLProvider struct {
db *sql.DB
}
func NewLibSQLProvider(cfg *config.VectorDBConfig) (*LibSQLProvider, error) {
if cfg == nil {
return nil, fmt.Errorf("libsql vector database config is required")
}
path := strings.TrimSpace(cfg.Path)
if path == "" {
path = defaultLibSQLPath
}
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve libsql vector database path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return nil, fmt.Errorf("create libsql vector database directory: %w", err)
}
connector, err := turso.NewConnector(absPath, turso.WithBusyTimeout(busyTimeoutMillis))
if err != nil {
return nil, fmt.Errorf("create libsql vector database connector: %w", err)
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
provider := &LibSQLProvider{db: db}
if err := provider.initialize(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
return provider, nil
}
func (p *LibSQLProvider) initialize(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("libsql vector database is closed")
}
if err := p.db.PingContext(ctx); err != nil {
return fmt.Errorf("connect to libsql vector database: %w", err)
}
_, err := p.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS "_agent_vector_collections" (
name TEXT PRIMARY KEY NOT NULL,
dimension INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("initialize libsql collection registry: %w", err)
}
return nil
}
func (p *LibSQLProvider) Close() error {
if p == nil || p.db == nil {
return nil
}
err := p.db.Close()
p.db = nil
return err
}
func (p *LibSQLProvider) CreateCollection(ctx context.Context, name string, dimension int) error {
tableName, err := collectionIdentifier(name)
if err != nil {
return err
}
if dimension <= 0 || dimension > 65536 {
return fmt.Errorf("invalid libsql vector dimension: %d", dimension)
}
if info, getErr := p.GetCollection(ctx, name); getErr == nil {
if info.Dimension != dimension {
return fmt.Errorf("collection %s already uses dimension %d, requested %d", name, info.Dimension, dimension)
}
return nil
} else if !errors.Is(getErr, sql.ErrNoRows) {
return getErr
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin libsql collection transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
createTable := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id TEXT PRIMARY KEY NOT NULL,
embedding BLOB NOT NULL,
knowledge_base_id INTEGER NOT NULL DEFAULT 0,
document_id INTEGER NOT NULL DEFAULT 0,
document_title TEXT NOT NULL DEFAULT '',
faq_id INTEGER NOT NULL DEFAULT 0,
faq_question TEXT NOT NULL DEFAULT '',
chunk_no INTEGER NOT NULL DEFAULT 0,
chunk_type TEXT NOT NULL DEFAULT '',
section_path TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT ''
)`, tableName)
if _, err := tx.ExecContext(ctx, createTable); err != nil {
return fmt.Errorf("create libsql collection %s: %w", name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf(
`CREATE INDEX IF NOT EXISTS %s ON %s (knowledge_base_id, document_id)`,
quoteIdentifier(name+"_payload_idx"), tableName,
)); err != nil {
return fmt.Errorf("create libsql payload index for %s: %w", name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO "_agent_vector_collections" (name, dimension) VALUES (?, ?)`, name, dimension,
); err != nil {
return fmt.Errorf("register libsql collection %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit libsql collection %s: %w", name, err)
}
return nil
}
func (p *LibSQLProvider) DeleteCollection(ctx context.Context, name string) error {
tableName, err := collectionIdentifier(name)
if err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin libsql collection transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+tableName); err != nil {
return fmt.Errorf("drop libsql collection %s: %w", name, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM "_agent_vector_collections" WHERE name = ?`, name); err != nil {
return fmt.Errorf("unregister libsql collection %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit libsql collection deletion %s: %w", name, err)
}
return nil
}
func (p *LibSQLProvider) GetCollection(ctx context.Context, name string) (*CollectionInfo, error) {
tableName, err := collectionIdentifier(name)
if err != nil {
return nil, err
}
var dimension int
if err := p.db.QueryRowContext(ctx,
`SELECT dimension FROM "_agent_vector_collections" WHERE name = ?`, name,
).Scan(&dimension); err != nil {
return nil, err
}
var count int
if err := p.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tableName).Scan(&count); err != nil {
return nil, fmt.Errorf("count libsql collection %s: %w", name, err)
}
return &CollectionInfo{Name: name, Dimension: dimension, PointCount: count, Status: "ready"}, nil
}
func (p *LibSQLProvider) ListCollections(ctx context.Context) ([]string, error) {
rows, err := p.db.QueryContext(ctx, `SELECT name FROM "_agent_vector_collections" ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list libsql collections: %w", err)
}
defer rows.Close()
collections := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("scan libsql collection: %w", err)
}
collections = append(collections, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate libsql collections: %w", err)
}
return collections, nil
}
func (p *LibSQLProvider) UpsertVectors(ctx context.Context, collectionName string, vectors []Vector) error {
if len(vectors) == 0 {
return nil
}
tableName, err := collectionIdentifier(collectionName)
if err != nil {
return err
}
info, err := p.GetCollection(ctx, collectionName)
if err != nil {
return fmt.Errorf("get libsql collection %s: %w", collectionName, err)
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin libsql vector upsert: %w", err)
}
defer func() { _ = tx.Rollback() }()
statement := fmt.Sprintf(`INSERT INTO %s (
id, embedding, knowledge_base_id, document_id, document_title,
faq_id, faq_question, chunk_no, chunk_type, section_path, title, content, provider
) VALUES (?, vector32(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
embedding=excluded.embedding,
knowledge_base_id=excluded.knowledge_base_id,
document_id=excluded.document_id,
document_title=excluded.document_title,
faq_id=excluded.faq_id,
faq_question=excluded.faq_question,
chunk_no=excluded.chunk_no,
chunk_type=excluded.chunk_type,
section_path=excluded.section_path,
title=excluded.title,
content=excluded.content,
provider=excluded.provider`, tableName)
stmt, err := tx.PrepareContext(ctx, statement)
if err != nil {
return fmt.Errorf("prepare libsql vector upsert: %w", err)
}
defer stmt.Close()
for _, item := range vectors {
if strings.TrimSpace(item.ID) == "" {
return fmt.Errorf("libsql vector id is required")
}
if len(item.Vector) != info.Dimension {
return fmt.Errorf("invalid vector dimension for %s: got %d, want %d", item.ID, len(item.Vector), info.Dimension)
}
encoded, err := json.Marshal(item.Vector)
if err != nil {
return fmt.Errorf("encode vector %s: %w", item.ID, err)
}
payload := item.Payload
if _, err := stmt.ExecContext(ctx,
item.ID, string(encoded), payload.KnowledgeBaseID, payload.DocumentID, payload.DocumentTitle,
payload.FaqID, payload.FaqQuestion, payload.ChunkNo, payload.ChunkType,
payload.SectionPath, payload.Title, payload.Content, payload.Provider,
); err != nil {
return fmt.Errorf("upsert libsql vector %s: %w", item.ID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit libsql vector upsert: %w", err)
}
return nil
}
func (p *LibSQLProvider) DeleteVectors(ctx context.Context, collectionName string, ids []string) error {
if len(ids) == 0 {
return nil
}
tableName, err := collectionIdentifier(collectionName)
if err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin libsql vector deletion: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx, "DELETE FROM "+tableName+" WHERE id = ?")
if err != nil {
return fmt.Errorf("prepare libsql vector deletion: %w", err)
}
defer stmt.Close()
for _, id := range ids {
if _, err := stmt.ExecContext(ctx, id); err != nil {
return fmt.Errorf("delete libsql vector %s: %w", id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit libsql vector deletion: %w", err)
}
return nil
}
func (p *LibSQLProvider) Search(ctx context.Context, req *SearchRequest) ([]SearchResult, error) {
if req == nil {
return nil, fmt.Errorf("libsql search request is required")
}
tableName, err := collectionIdentifier(req.CollectionName)
if err != nil {
return nil, err
}
info, err := p.GetCollection(ctx, req.CollectionName)
if err != nil {
return nil, fmt.Errorf("get libsql collection %s: %w", req.CollectionName, err)
}
if len(req.Vector) != info.Dimension {
return nil, fmt.Errorf("invalid search vector dimension: got %d, want %d", len(req.Vector), info.Dimension)
}
topK := req.TopK
if topK <= 0 {
topK = defaultSearchTopK
}
encoded, err := json.Marshal(req.Vector)
if err != nil {
return nil, fmt.Errorf("encode search vector: %w", err)
}
vectorJSON := string(encoded)
filterSQL, filterArgs := buildSearchFilter(req.Filter)
innerWhere := filterSQL
innerArgs := []any{vectorJSON}
if filterSQL != "" {
innerArgs = append(innerArgs, filterArgs...)
}
query := fmt.Sprintf(`SELECT id, score, knowledge_base_id, document_id, document_title,
faq_id, faq_question, chunk_no, chunk_type, section_path, title, content, provider
FROM (
SELECT id, 1.0 - vector_distance_cos(embedding, vector32(?)) AS score,
knowledge_base_id, document_id, document_title, faq_id, faq_question,
chunk_no, chunk_type, section_path, title, content, provider
FROM %s%s
) ranked
WHERE score >= ?
ORDER BY score DESC
LIMIT ?`, tableName, innerWhere)
innerArgs = append(innerArgs, req.ScoreThreshold, topK)
rows, err := p.db.QueryContext(ctx, query, innerArgs...)
if err != nil {
return nil, fmt.Errorf("search libsql collection %s: %w", req.CollectionName, err)
}
defer rows.Close()
results := make([]SearchResult, 0, topK)
for rows.Next() {
var result SearchResult
if err := rows.Scan(
&result.ID, &result.Score,
&result.Payload.KnowledgeBaseID, &result.Payload.DocumentID, &result.Payload.DocumentTitle,
&result.Payload.FaqID, &result.Payload.FaqQuestion, &result.Payload.ChunkNo,
&result.Payload.ChunkType, &result.Payload.SectionPath, &result.Payload.Title,
&result.Payload.Content, &result.Payload.Provider,
); err != nil {
return nil, fmt.Errorf("scan libsql search result: %w", err)
}
results = append(results, result)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate libsql search results: %w", err)
}
return results, nil
}
func collectionIdentifier(name string) (string, error) {
name = strings.TrimSpace(name)
if !collectionNamePattern.MatchString(name) {
return "", fmt.Errorf("invalid libsql collection name %q", name)
}
return quoteIdentifier(name), nil
}
func quoteIdentifier(value string) string {
return `"` + value + `"`
}
func buildSearchFilter(filter *SearchFilter) (string, []any) {
if filter == nil {
return "", nil
}
clauses := make([]string, 0, 2)
args := make([]any, 0, len(filter.KnowledgeBaseIDs)+len(filter.DocumentIDs))
if len(filter.KnowledgeBaseIDs) > 0 {
clauses = append(clauses, "knowledge_base_id IN ("+placeholders(len(filter.KnowledgeBaseIDs))+")")
for _, id := range filter.KnowledgeBaseIDs {
args = append(args, id)
}
}
if len(filter.DocumentIDs) > 0 {
clauses = append(clauses, "document_id IN ("+placeholders(len(filter.DocumentIDs))+")")
for _, id := range filter.DocumentIDs {
args = append(args, id)
}
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
func placeholders(count int) string {
values := make([]string, count)
for i := range values {
values[i] = "?"
}
return strings.Join(values, ",")
}