refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -293,12 +293,7 @@ func (s *answer) retrieve(req request.KnowledgeSearchRequest, ctx context.Contex
|
||||
defaultRerankLimit := resolveDefaultRerankLimit(knowledgeBases)
|
||||
rerankLimit := resolveRerankLimit(req.RerankLimit, defaultRerankLimit)
|
||||
if rerankLimit > 0 && len(results) > rerankLimit {
|
||||
return Retrieve.RetrieveWithRerank(ctx, RetrieveRequest{
|
||||
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
|
||||
Query: req.Question,
|
||||
TopK: req.TopK,
|
||||
ScoreThreshold: req.ScoreThreshold,
|
||||
}, rerankLimit)
|
||||
return Retrieve.ApplyRerank(ctx, req.Question, results, rerankLimit)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -64,10 +64,10 @@ func (p *structuredProvider) Chunk(ctx context.Context, req *ChunkRequest) ([]Ch
|
||||
CharCount: len([]rune(part)),
|
||||
TokenCount: estimateTokenCount(part),
|
||||
Metadata: map[string]any{
|
||||
"provider": enums.KnowledgeChunkProviderStructured,
|
||||
"blockType": block.Type,
|
||||
"sectionPath": block.SectionPath,
|
||||
"sectionTitle": block.Title,
|
||||
"provider": enums.KnowledgeChunkProviderStructured,
|
||||
"block_type": block.Type,
|
||||
"section_path": block.SectionPath,
|
||||
"section_title": block.Title,
|
||||
},
|
||||
})
|
||||
chunkNo++
|
||||
|
||||
@@ -215,12 +215,7 @@ func (s *index) EnsureCollection(ctx context.Context) error {
|
||||
return fmt.Errorf("vectordb provider not initialized")
|
||||
}
|
||||
|
||||
existing, err := provider.GetCollection(ctx, collectionName)
|
||||
if err == nil && existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.CreateCollection(ctx, collectionName, dimension)
|
||||
return s.ensureCollection(ctx, provider, collectionName, dimension)
|
||||
}
|
||||
|
||||
func (s *index) RebuildKnowledgeBaseIndex(ctx context.Context, knowledgeBaseID int64) error {
|
||||
|
||||
@@ -50,7 +50,10 @@ func (s *index) prepareDocumentVectors(ctx context.Context, knowledgeBase models
|
||||
directoryPath := loadKnowledgeDirectoryPath(document.DirectoryID)
|
||||
|
||||
for i, chunk := range chunks {
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, chunk.Content)
|
||||
embeddingBase := fmt.Sprintf("knowledge-index:base:%d:document:%d:version:%d:chunk:%d", knowledgeBase.ID, document.ID, document.UpdatedAt.UnixNano(), chunk.ChunkNo)
|
||||
embeddingCtx := ai.WithPlatformAIRequestScope(ctx, embeddingBase)
|
||||
embeddingCtx = ai.WithPlatformAIRequestPurpose(embeddingCtx, "embedding.document-index")
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(embeddingCtx, chunk.Content)
|
||||
if err != nil {
|
||||
slog.Error("Failed to generate embedding for chunk", "document_id", document.ID, "chunk_index", i, "error", err)
|
||||
return nil, nil, 0, fmt.Errorf("failed to generate embedding for chunk %d: %w", i, err)
|
||||
|
||||
@@ -35,7 +35,10 @@ func buildFAQChunkModel(knowledgeBase models.KnowledgeBase, faq models.Knowledge
|
||||
}
|
||||
|
||||
func (s *index) prepareFAQVector(ctx context.Context, knowledgeBase models.KnowledgeBase, faq models.KnowledgeFAQ, content string) (vectordb.Vector, models.KnowledgeChunk, int, error) {
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, content)
|
||||
embeddingBase := fmt.Sprintf("knowledge-index:base:%d:faq:%d:version:%d", knowledgeBase.ID, faq.ID, faq.UpdatedAt.UnixNano())
|
||||
embeddingCtx := ai.WithPlatformAIRequestScope(ctx, embeddingBase)
|
||||
embeddingCtx = ai.WithPlatformAIRequestPurpose(embeddingCtx, "embedding.faq-index")
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(embeddingCtx, content)
|
||||
if err != nil {
|
||||
return vectordb.Vector{}, models.KnowledgeChunk{}, 0, fmt.Errorf("failed to generate embedding for faq %d: %w", faq.ID, err)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ import (
|
||||
)
|
||||
|
||||
func (s *index) ensureCollection(ctx context.Context, provider vectordb.Provider, collectionName string, dimension int) error {
|
||||
collectionInfo, err := provider.GetCollection(ctx, collectionName)
|
||||
if err == nil && collectionInfo != nil {
|
||||
return nil
|
||||
}
|
||||
if dimension <= 0 {
|
||||
return fmt.Errorf("invalid embedding dimension: %d", dimension)
|
||||
}
|
||||
collectionInfo, err := provider.GetCollection(ctx, collectionName)
|
||||
if err == nil && collectionInfo != nil {
|
||||
if collectionInfo.Dimension != dimension {
|
||||
return fmt.Errorf("knowledge vector collection dimension is %d, but the current embedding model uses %d; switch back to the original embedding model or recreate the vector collection and rebuild all knowledge base indexes", collectionInfo.Dimension, dimension)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := provider.CreateCollection(ctx, collectionName, dimension); err != nil {
|
||||
return fmt.Errorf("failed to create collection: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/rag/vectordb"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
type dimensionTestPlatformProvider struct{}
|
||||
|
||||
func (dimensionTestPlatformProvider) ModelSource(context.Context) (string, error) {
|
||||
return contract.ModelSourcePlatform, nil
|
||||
}
|
||||
|
||||
func (dimensionTestPlatformProvider) Config(context.Context) (*contract.PlatformAIConfig, error) {
|
||||
return &contract.PlatformAIConfig{
|
||||
APIKey: "license-signed",
|
||||
BaseURL: "https://platform.example/v1",
|
||||
EmbeddingModel: "qwen3.7-text-embedding",
|
||||
EmbeddingDimension: 4,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (dimensionTestPlatformProvider) Status(context.Context) (*contract.PlatformAIStatus, error) {
|
||||
return &contract.PlatformAIStatus{Enabled: true, EmbeddingEnabled: true}, nil
|
||||
}
|
||||
|
||||
func TestEnsureCollectionRejectsChangedEmbeddingDimension(t *testing.T) {
|
||||
if err := vectordb.Init(&config.VectorDBConfig{Path: filepath.Join(t.TempDir(), "vectors.db")}); err != nil {
|
||||
t.Fatalf("vectordb.Init() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = vectordb.Close() })
|
||||
provider := vectordb.GetProvider()
|
||||
if err := provider.CreateCollection(context.Background(), knowledgeCollectionName, 3); err != nil {
|
||||
t.Fatalf("CreateCollection() error = %v", err)
|
||||
}
|
||||
|
||||
ai.SetPlatformAIProvider(dimensionTestPlatformProvider{})
|
||||
t.Cleanup(func() { ai.SetPlatformAIProvider(nil) })
|
||||
|
||||
err := Index.EnsureCollection(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected dimension mismatch error")
|
||||
}
|
||||
if message := err.Error(); !strings.Contains(message, "dimension is 3") || !strings.Contains(message, "uses 4") || !strings.Contains(message, "rebuild") {
|
||||
t.Fatalf("unexpected dimension mismatch error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (s *rerank) Rerank(ctx context.Context, query string, documents []string, t
|
||||
}
|
||||
|
||||
func (s *rerank) callRerankAPI(ctx context.Context, query string, documents []string, topN int) ([]RerankResult, error) {
|
||||
config, err := ai.GetEnabledAIConfig(enums.AIModelTypeRerank)
|
||||
config, err := ai.ResolveAIConfig(ctx, enums.AIModelTypeRerank, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/ai/rag/vectordb"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
type retrieve struct {
|
||||
rerankResults func(context.Context, string, []RetrieveResult, int) ([]RetrieveResult, error)
|
||||
}
|
||||
|
||||
var Retrieve = &retrieve{}
|
||||
@@ -117,14 +120,22 @@ func (s *retrieve) RetrieveWithRerank(ctx context.Context, req RetrieveRequest,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ApplyRerank(ctx, req.Query, results, rerankLimit)
|
||||
}
|
||||
|
||||
if len(results) <= rerankLimit {
|
||||
// ApplyRerank reranks an existing vector result set. Keeping rerank separate
|
||||
// from retrieval prevents callers from generating and billing the query
|
||||
// embedding a second time.
|
||||
func (s *retrieve) ApplyRerank(ctx context.Context, query string, results []RetrieveResult, rerankLimit int) ([]RetrieveResult, error) {
|
||||
if rerankLimit <= 0 || len(results) <= rerankLimit {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
rerankedResults, err := s.rerank(ctx, req.Query, results, rerankLimit)
|
||||
rerankedResults, err := s.rerank(ctx, query, results, rerankLimit)
|
||||
if err != nil {
|
||||
slog.Warn("Rerank failed, returning original results", "error", err)
|
||||
if !errors.Is(err, ai.ErrPlatformModelUnsupported) {
|
||||
slog.Warn("Rerank failed, returning original results", "error", err)
|
||||
}
|
||||
if len(results) > rerankLimit {
|
||||
return results[:rerankLimit], nil
|
||||
}
|
||||
@@ -135,6 +146,9 @@ func (s *retrieve) RetrieveWithRerank(ctx context.Context, req RetrieveRequest,
|
||||
}
|
||||
|
||||
func (s *retrieve) rerank(ctx context.Context, query string, results []RetrieveResult, limit int) ([]RetrieveResult, error) {
|
||||
if s.rerankResults != nil {
|
||||
return s.rerankResults(ctx, query, results, limit)
|
||||
}
|
||||
return Rerank.RerankResults(ctx, query, results, limit)
|
||||
}
|
||||
|
||||
@@ -222,9 +236,9 @@ func (s *retrieve) loadRetrievableKnowledgeBases(ids []int64) []models.Knowledge
|
||||
}
|
||||
|
||||
type KnowledgeBaseStats struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
DocumentCount int64 `json:"documentCount"`
|
||||
PublishedCount int64 `json:"publishedCount"`
|
||||
ChunkCount int64 `json:"chunkCount"`
|
||||
VectorCount int `json:"vectorCount"`
|
||||
KnowledgeBaseID int64 `json:"knowledge_base_id"`
|
||||
DocumentCount int64 `json:"document_count"`
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ChunkCount int64 `json:"chunk_count"`
|
||||
VectorCount int `json:"vector_count"`
|
||||
}
|
||||
|
||||
@@ -47,38 +47,38 @@ type CreateRetrieveLogRequest struct {
|
||||
|
||||
type retrieveTraceData struct {
|
||||
Retrieve retrieveTraceRetrieve `json:"retrieve"`
|
||||
ChunkConfig retrieveTraceChunkConfig `json:"chunkConfig"`
|
||||
ChunkConfig retrieveTraceChunkConfig `json:"chunk_config"`
|
||||
Context retrieveTraceContext `json:"context"`
|
||||
Citations []retrieveTraceCitation `json:"citations"`
|
||||
}
|
||||
|
||||
type retrieveTraceRetrieve struct {
|
||||
Provider string `json:"provider"`
|
||||
RerankEnabled bool `json:"rerankEnabled"`
|
||||
RerankLimit int `json:"rerankLimit"`
|
||||
RawHitCount int `json:"rawHitCount"`
|
||||
ContextHitCount int `json:"contextHitCount"`
|
||||
CitationCount int `json:"citationCount"`
|
||||
RerankEnabled bool `json:"rerank_enabled"`
|
||||
RerankLimit int `json:"rerank_limit"`
|
||||
RawHitCount int `json:"raw_hit_count"`
|
||||
ContextHitCount int `json:"context_hit_count"`
|
||||
CitationCount int `json:"citation_count"`
|
||||
}
|
||||
|
||||
type retrieveTraceChunkConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
TargetTokens int `json:"targetTokens"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
OverlapTokens int `json:"overlapTokens"`
|
||||
TargetTokens int `json:"target_tokens"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
OverlapTokens int `json:"overlap_tokens"`
|
||||
}
|
||||
|
||||
type retrieveTraceContext struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
DocumentIDs []int64 `json:"documentIds"`
|
||||
SectionPaths []string `json:"sectionPaths"`
|
||||
UsedChunkKeys []string `json:"usedChunkKeys"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledge_base_ids"`
|
||||
DocumentIDs []int64 `json:"document_ids"`
|
||||
SectionPaths []string `json:"section_paths"`
|
||||
UsedChunkKeys []string `json:"used_chunk_keys"`
|
||||
}
|
||||
|
||||
type retrieveTraceCitation struct {
|
||||
DocumentID int64 `json:"documentId"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
ChunkNo int `json:"chunk_no"`
|
||||
SectionPath string `json:"section_path"`
|
||||
}
|
||||
|
||||
func (s *retrieveLog) FindHitsByRetrieveLogID(retrieveLogID int64) []models.KnowledgeRetrieveHit {
|
||||
|
||||
@@ -21,7 +21,8 @@ func (s *retrieve) searchKnowledgeBaseVectors(ctx context.Context, req RetrieveR
|
||||
trace := &RetrieveTrace{}
|
||||
|
||||
embeddingStartedAt := time.Now()
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, req.Query)
|
||||
embeddingCtx := ai.WithPlatformAIRequestPurpose(ctx, "embedding.knowledge-query")
|
||||
embeddingResult, err := ai.Embedding.GenerateEmbedding(embeddingCtx, req.Query)
|
||||
trace.EmbeddingMs = time.Since(embeddingStartedAt).Milliseconds()
|
||||
if err != nil {
|
||||
return nil, trace, fmt.Errorf("failed to generate query embedding: %w", err)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
@@ -20,6 +22,26 @@ func TestResolveKnowledgeBaseSearchOptionsUsesKnowledgeBaseDefaults(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRerankFallsBackWithoutRetrievingAgain(t *testing.T) {
|
||||
calls := 0
|
||||
retriever := &retrieve{rerankResults: func(context.Context, string, []RetrieveResult, int) ([]RetrieveResult, error) {
|
||||
calls++
|
||||
return nil, errors.New("platform rerank is unavailable")
|
||||
}}
|
||||
results := []RetrieveResult{{ChunkID: 1, Score: 0.9}, {ChunkID: 2, Score: 0.8}, {ChunkID: 3, Score: 0.7}}
|
||||
|
||||
got, err := retriever.ApplyRerank(context.Background(), "refund", results, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyRerank() error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("rerank calls = %d, want 1", calls)
|
||||
}
|
||||
if len(got) != 2 || got[0].ChunkID != 1 || got[1].ChunkID != 2 {
|
||||
t.Fatalf("ApplyRerank() fallback = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKnowledgeBaseSearchOptionsRequestOverridesKnowledgeBaseDefaults(t *testing.T) {
|
||||
topK, scoreThreshold := resolveKnowledgeBaseSearchOptions(RetrieveRequest{
|
||||
TopK: 9,
|
||||
|
||||
+10
-10
@@ -8,18 +8,18 @@ type RetrieveRequest struct {
|
||||
}
|
||||
|
||||
type RetrieveResult struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ChunkID int64 `json:"chunkId"`
|
||||
DocumentID int64 `json:"documentId"`
|
||||
DocumentTitle string `json:"documentTitle"`
|
||||
FaqID int64 `json:"faqId"`
|
||||
FaqQuestion string `json:"faqQuestion"`
|
||||
ChunkNo int `json:"chunkNo"`
|
||||
KnowledgeBaseID int64 `json:"knowledge_base_id"`
|
||||
ChunkID int64 `json:"chunk_id"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
DocumentTitle string `json:"document_title"`
|
||||
FaqID int64 `json:"faq_id"`
|
||||
FaqQuestion string `json:"faq_question"`
|
||||
ChunkNo int `json:"chunk_no"`
|
||||
Title string `json:"title"`
|
||||
SectionPath string `json:"sectionPath"`
|
||||
SectionPath string `json:"section_path"`
|
||||
Content string `json:"content"`
|
||||
Score float32 `json:"score"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
ChunkType string `json:"chunk_type"`
|
||||
}
|
||||
|
||||
type RerankRequest struct {
|
||||
@@ -44,5 +44,5 @@ type RerankResponse struct {
|
||||
|
||||
type RerankResult struct {
|
||||
Index int `json:"index"`
|
||||
RelevanceScore float64 `json:"relevanceScore"`
|
||||
RelevanceScore float64 `json:"relevance_score"`
|
||||
}
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
//go:build lancedb
|
||||
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
"github.com/lancedb/lancedb-go/pkg/contracts"
|
||||
"github.com/lancedb/lancedb-go/pkg/lancedb"
|
||||
)
|
||||
|
||||
const lanceDBVectorColumn = "vector"
|
||||
|
||||
type LanceDBProvider struct {
|
||||
conn contracts.IConnection
|
||||
}
|
||||
|
||||
func NewLanceDBProvider(cfg *config.LanceDBVectorDBConfig) (Provider, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("lancedb config is nil")
|
||||
}
|
||||
path := strings.TrimSpace(cfg.Path)
|
||||
if path == "" {
|
||||
path = "data/lancedb"
|
||||
}
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create lancedb directory %s: %w", path, err)
|
||||
}
|
||||
conn, err := lancedb.Connect(context.Background(), path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LanceDBProvider{conn: conn}, nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) Close() error {
|
||||
if p.conn == nil || p.conn.IsClosed() {
|
||||
return nil
|
||||
}
|
||||
return p.conn.Close()
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) CreateCollection(ctx context.Context, name string, dimension int) error {
|
||||
if dimension <= 0 {
|
||||
return fmt.Errorf("invalid lancedb vector dimension: %d", dimension)
|
||||
}
|
||||
if err := p.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
schema, err := newLanceDBSchema(dimension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table, err := p.conn.CreateTable(ctx, name, schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create lancedb table %s: %w", name, err)
|
||||
}
|
||||
return table.Close()
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) DeleteCollection(ctx context.Context, name string) error {
|
||||
if err := p.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.conn.DropTable(ctx, name); err != nil {
|
||||
return fmt.Errorf("failed to delete lancedb table %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) GetCollection(ctx context.Context, name string) (*CollectionInfo, error) {
|
||||
table, err := p.openTable(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer table.Close()
|
||||
|
||||
schema, err := table.Schema(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get lancedb table schema %s: %w", name, err)
|
||||
}
|
||||
count, err := table.Count(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count lancedb table %s: %w", name, err)
|
||||
}
|
||||
return &CollectionInfo{
|
||||
Name: name,
|
||||
Dimension: lanceDBVectorDimension(schema),
|
||||
PointCount: int(count),
|
||||
Status: "ok",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) ListCollections(ctx context.Context) ([]string, error) {
|
||||
if err := p.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names, err := p.conn.TableNames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list lancedb tables: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) UpsertVectors(ctx context.Context, collectionName string, vectors []Vector) error {
|
||||
if len(vectors) == 0 {
|
||||
return nil
|
||||
}
|
||||
table, err := p.openTable(ctx, collectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer table.Close()
|
||||
|
||||
ids := make([]string, 0, len(vectors))
|
||||
for _, vector := range vectors {
|
||||
if strings.TrimSpace(vector.ID) != "" {
|
||||
ids = append(ids, vector.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := table.Delete(ctx, lanceDBStringInFilter("id", ids)); err != nil {
|
||||
return fmt.Errorf("failed to delete existing lancedb vectors from %s: %w", collectionName, err)
|
||||
}
|
||||
}
|
||||
|
||||
record, release, err := newLanceDBVectorRecord(vectors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if err := table.AddRecords(ctx, []arrow.Record{record}, nil); err != nil {
|
||||
return fmt.Errorf("failed to add lancedb vectors to %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) DeleteVectors(ctx context.Context, collectionName string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
table, err := p.openTable(ctx, collectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer table.Close()
|
||||
|
||||
if err := table.Delete(ctx, lanceDBStringInFilter("id", ids)); err != nil {
|
||||
return fmt.Errorf("failed to delete lancedb vectors from %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) Search(ctx context.Context, req *SearchRequest) ([]SearchResult, error) {
|
||||
table, err := p.openTable(ctx, req.CollectionName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer table.Close()
|
||||
|
||||
filter := lanceDBSearchFilter(req.Filter)
|
||||
var rows []map[string]interface{}
|
||||
if filter == "" {
|
||||
rows, err = table.VectorSearch(ctx, lanceDBVectorColumn, req.Vector, req.TopK)
|
||||
} else {
|
||||
rows, err = table.VectorSearchWithFilter(ctx, lanceDBVectorColumn, req.Vector, req.TopK, filter)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search lancedb table %s: %w", req.CollectionName, err)
|
||||
}
|
||||
|
||||
results := make([]SearchResult, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
score := lanceDBScoreFromRow(row)
|
||||
if req.ScoreThreshold > 0 && score < req.ScoreThreshold {
|
||||
continue
|
||||
}
|
||||
results = append(results, SearchResult{
|
||||
ID: valueToString(row["id"]),
|
||||
Score: score,
|
||||
Payload: lanceDBPayloadFromRow(row),
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) ensureOpen() error {
|
||||
if p == nil || p.conn == nil || p.conn.IsClosed() {
|
||||
return fmt.Errorf("lancedb provider is closed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LanceDBProvider) openTable(ctx context.Context, name string) (contracts.ITable, error) {
|
||||
if err := p.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table, err := p.conn.OpenTable(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open lancedb table %s: %w", name, err)
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func newLanceDBSchema(dimension int) (contracts.ISchema, error) {
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "id", Type: arrow.BinaryTypes.String, Nullable: false},
|
||||
{Name: lanceDBVectorColumn, Type: arrow.FixedSizeListOf(int32(dimension), arrow.PrimitiveTypes.Float32), Nullable: false},
|
||||
{Name: "knowledge_base_id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
|
||||
{Name: "document_id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
|
||||
{Name: "document_title", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "faq_id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
|
||||
{Name: "faq_question", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "chunk_no", Type: arrow.PrimitiveTypes.Int32, Nullable: false},
|
||||
{Name: "chunk_type", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "section_path", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "title", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "content", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "provider", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
}, nil)
|
||||
return lancedb.NewSchema(schema)
|
||||
}
|
||||
|
||||
func newLanceDBVectorRecord(vectors []Vector) (arrow.Record, func(), error) {
|
||||
dimension := 0
|
||||
for _, item := range vectors {
|
||||
if len(item.Vector) > 0 {
|
||||
dimension = len(item.Vector)
|
||||
break
|
||||
}
|
||||
}
|
||||
if dimension <= 0 {
|
||||
return nil, nil, fmt.Errorf("lancedb vector dimension is empty")
|
||||
}
|
||||
for _, item := range vectors {
|
||||
if len(item.Vector) != dimension {
|
||||
return nil, nil, fmt.Errorf("inconsistent lancedb vector dimension for %s: got %d, want %d", item.ID, len(item.Vector), dimension)
|
||||
}
|
||||
}
|
||||
|
||||
pool := memory.NewGoAllocator()
|
||||
idBuilder := array.NewStringBuilder(pool)
|
||||
kbIDBuilder := array.NewInt64Builder(pool)
|
||||
documentIDBuilder := array.NewInt64Builder(pool)
|
||||
documentTitleBuilder := array.NewStringBuilder(pool)
|
||||
faqIDBuilder := array.NewInt64Builder(pool)
|
||||
faqQuestionBuilder := array.NewStringBuilder(pool)
|
||||
chunkNoBuilder := array.NewInt32Builder(pool)
|
||||
chunkTypeBuilder := array.NewStringBuilder(pool)
|
||||
sectionPathBuilder := array.NewStringBuilder(pool)
|
||||
titleBuilder := array.NewStringBuilder(pool)
|
||||
contentBuilder := array.NewStringBuilder(pool)
|
||||
providerBuilder := array.NewStringBuilder(pool)
|
||||
vectorBuilder := array.NewFloat32Builder(pool)
|
||||
|
||||
for _, item := range vectors {
|
||||
payload := item.Payload
|
||||
idBuilder.Append(item.ID)
|
||||
vectorBuilder.AppendValues(item.Vector, nil)
|
||||
kbIDBuilder.Append(payload.KnowledgeBaseID)
|
||||
documentIDBuilder.Append(payload.DocumentID)
|
||||
documentTitleBuilder.Append(payload.DocumentTitle)
|
||||
faqIDBuilder.Append(payload.FaqID)
|
||||
faqQuestionBuilder.Append(payload.FaqQuestion)
|
||||
chunkNoBuilder.Append(int32(payload.ChunkNo))
|
||||
chunkTypeBuilder.Append(payload.ChunkType)
|
||||
sectionPathBuilder.Append(payload.SectionPath)
|
||||
titleBuilder.Append(payload.Title)
|
||||
contentBuilder.Append(payload.Content)
|
||||
providerBuilder.Append(payload.Provider)
|
||||
}
|
||||
|
||||
idArray := idBuilder.NewArray()
|
||||
vectorValues := vectorBuilder.NewArray()
|
||||
kbIDArray := kbIDBuilder.NewArray()
|
||||
documentIDArray := documentIDBuilder.NewArray()
|
||||
documentTitleArray := documentTitleBuilder.NewArray()
|
||||
faqIDArray := faqIDBuilder.NewArray()
|
||||
faqQuestionArray := faqQuestionBuilder.NewArray()
|
||||
chunkNoArray := chunkNoBuilder.NewArray()
|
||||
chunkTypeArray := chunkTypeBuilder.NewArray()
|
||||
sectionPathArray := sectionPathBuilder.NewArray()
|
||||
titleArray := titleBuilder.NewArray()
|
||||
contentArray := contentBuilder.NewArray()
|
||||
providerArray := providerBuilder.NewArray()
|
||||
|
||||
vectorType := arrow.FixedSizeListOf(int32(dimension), arrow.PrimitiveTypes.Float32)
|
||||
vectorArray := array.NewFixedSizeListData(
|
||||
array.NewData(vectorType, len(vectors), []*memory.Buffer{nil}, []arrow.ArrayData{vectorValues.Data()}, 0, 0),
|
||||
)
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "id", Type: arrow.BinaryTypes.String, Nullable: false},
|
||||
{Name: lanceDBVectorColumn, Type: vectorType, Nullable: false},
|
||||
{Name: "knowledge_base_id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
|
||||
{Name: "document_id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
|
||||
{Name: "document_title", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "faq_id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
|
||||
{Name: "faq_question", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "chunk_no", Type: arrow.PrimitiveTypes.Int32, Nullable: false},
|
||||
{Name: "chunk_type", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "section_path", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "title", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "content", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
{Name: "provider", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||
}, nil)
|
||||
columns := []arrow.Array{
|
||||
idArray,
|
||||
vectorArray,
|
||||
kbIDArray,
|
||||
documentIDArray,
|
||||
documentTitleArray,
|
||||
faqIDArray,
|
||||
faqQuestionArray,
|
||||
chunkNoArray,
|
||||
chunkTypeArray,
|
||||
sectionPathArray,
|
||||
titleArray,
|
||||
contentArray,
|
||||
providerArray,
|
||||
}
|
||||
record := array.NewRecord(schema, columns, int64(len(vectors)))
|
||||
release := func() {
|
||||
record.Release()
|
||||
for _, column := range columns {
|
||||
column.Release()
|
||||
}
|
||||
vectorValues.Release()
|
||||
}
|
||||
return record, release, nil
|
||||
}
|
||||
|
||||
func lanceDBVectorDimension(schema *arrow.Schema) int {
|
||||
if schema == nil {
|
||||
return 0
|
||||
}
|
||||
for i := 0; i < schema.NumFields(); i++ {
|
||||
field := schema.Field(i)
|
||||
if field.Name != lanceDBVectorColumn {
|
||||
continue
|
||||
}
|
||||
listType, ok := field.Type.(*arrow.FixedSizeListType)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(listType.Len())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lanceDBSearchFilter(filter *SearchFilter) string {
|
||||
if filter == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if len(filter.KnowledgeBaseIDs) > 0 {
|
||||
parts = append(parts, lanceDBIntInFilter("knowledge_base_id", filter.KnowledgeBaseIDs))
|
||||
}
|
||||
if len(filter.DocumentIDs) > 0 {
|
||||
parts = append(parts, lanceDBIntInFilter("document_id", filter.DocumentIDs))
|
||||
}
|
||||
return strings.Join(parts, " AND ")
|
||||
}
|
||||
|
||||
func lanceDBIntInFilter(column string, values []int64) string {
|
||||
items := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
items = append(items, strconv.FormatInt(value, 10))
|
||||
}
|
||||
return fmt.Sprintf("%s IN (%s)", column, strings.Join(items, ","))
|
||||
}
|
||||
|
||||
func lanceDBStringInFilter(column string, values []string) string {
|
||||
items := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
items = append(items, "'"+strings.ReplaceAll(value, "'", "''")+"'")
|
||||
}
|
||||
return fmt.Sprintf("%s IN (%s)", column, strings.Join(items, ","))
|
||||
}
|
||||
|
||||
func lanceDBScoreFromRow(row map[string]interface{}) float32 {
|
||||
for _, key := range []string{"_distance", "distance"} {
|
||||
if value, ok := row[key]; ok {
|
||||
distance := valueToFloat64(value)
|
||||
if math.IsNaN(distance) {
|
||||
break
|
||||
}
|
||||
score := 1 - distance
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
if score > 1 {
|
||||
return 1
|
||||
}
|
||||
return float32(score)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"_score", "score"} {
|
||||
if value, ok := row[key]; ok {
|
||||
score := valueToFloat64(value)
|
||||
if !math.IsNaN(score) {
|
||||
return float32(score)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lanceDBPayloadFromRow(row map[string]interface{}) ChunkPayload {
|
||||
return ChunkPayload{
|
||||
KnowledgeBaseID: valueToInt64(row["knowledge_base_id"]),
|
||||
DocumentID: valueToInt64(row["document_id"]),
|
||||
DocumentTitle: valueToString(row["document_title"]),
|
||||
FaqID: valueToInt64(row["faq_id"]),
|
||||
FaqQuestion: valueToString(row["faq_question"]),
|
||||
ChunkNo: int(valueToInt64(row["chunk_no"])),
|
||||
ChunkType: valueToString(row["chunk_type"]),
|
||||
SectionPath: valueToString(row["section_path"]),
|
||||
Title: valueToString(row["title"]),
|
||||
Content: valueToString(row["content"]),
|
||||
Provider: valueToString(row["provider"]),
|
||||
}
|
||||
}
|
||||
|
||||
func valueToString(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case []byte:
|
||||
return string(v)
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func valueToInt64(value interface{}) int64 {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v)
|
||||
case int32:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case uint64:
|
||||
return int64(v)
|
||||
case float32:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
ret, _ := strconv.ParseInt(v, 10, 64)
|
||||
return ret
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func valueToFloat64(value interface{}) float64 {
|
||||
switch v := value.(type) {
|
||||
case float32:
|
||||
return float64(v)
|
||||
case float64:
|
||||
return v
|
||||
case int:
|
||||
return float64(v)
|
||||
case int32:
|
||||
return float64(v)
|
||||
case int64:
|
||||
return float64(v)
|
||||
case string:
|
||||
ret, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !lancedb
|
||||
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
func NewLanceDBProvider(_ *config.LanceDBVectorDBConfig) (Provider, error) {
|
||||
return nil, fmt.Errorf("LanceDB provider is not built. Rebuild with -tags lancedb and configure LanceDB native libraries")
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//go:build lancedb
|
||||
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
func TestLanceDBProviderVectorLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider, err := NewLanceDBProvider(&config.LanceDBVectorDBConfig{Path: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLanceDBProvider() error = %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
const collectionName = "knowledge_chunks"
|
||||
if err := provider.CreateCollection(ctx, collectionName, 3); err != nil {
|
||||
t.Fatalf("CreateCollection() error = %v", err)
|
||||
}
|
||||
|
||||
vectors := []Vector{
|
||||
{
|
||||
ID: "a",
|
||||
Vector: []float32{1, 0, 0},
|
||||
Payload: ChunkPayload{
|
||||
KnowledgeBaseID: 10,
|
||||
DocumentID: 100,
|
||||
Title: "A",
|
||||
Content: "alpha",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "b",
|
||||
Vector: []float32{0, 1, 0},
|
||||
Payload: ChunkPayload{
|
||||
KnowledgeBaseID: 20,
|
||||
DocumentID: 200,
|
||||
Title: "B",
|
||||
Content: "beta",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := provider.UpsertVectors(ctx, collectionName, vectors); err != nil {
|
||||
t.Fatalf("UpsertVectors() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := provider.GetCollection(ctx, collectionName)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCollection() error = %v", err)
|
||||
}
|
||||
if info.Dimension != 3 {
|
||||
t.Fatalf("CollectionInfo.Dimension = %d, want 3", info.Dimension)
|
||||
}
|
||||
if info.PointCount != 2 {
|
||||
t.Fatalf("CollectionInfo.PointCount = %d, want 2", info.PointCount)
|
||||
}
|
||||
|
||||
results, err := provider.Search(ctx, &SearchRequest{
|
||||
CollectionName: collectionName,
|
||||
Vector: []float32{1, 0, 0},
|
||||
TopK: 5,
|
||||
ScoreThreshold: 0,
|
||||
Filter: &SearchFilter{
|
||||
KnowledgeBaseIDs: []int64{10},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error = %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Search() returned %d results, want 1: %#v", len(results), results)
|
||||
}
|
||||
if results[0].ID != "a" {
|
||||
t.Fatalf("Search()[0].ID = %q, want %q", results[0].ID, "a")
|
||||
}
|
||||
if results[0].Payload.KnowledgeBaseID != 10 {
|
||||
t.Fatalf("Search()[0].Payload.KnowledgeBaseID = %d, want 10", results[0].Payload.KnowledgeBaseID)
|
||||
}
|
||||
|
||||
if err := provider.DeleteVectors(ctx, collectionName, []string{"a"}); err != nil {
|
||||
t.Fatalf("DeleteVectors() error = %v", err)
|
||||
}
|
||||
results, err = provider.Search(ctx, &SearchRequest{
|
||||
CollectionName: collectionName,
|
||||
Vector: []float32{1, 0, 0},
|
||||
TopK: 5,
|
||||
ScoreThreshold: 0,
|
||||
Filter: &SearchFilter{
|
||||
KnowledgeBaseIDs: []int64{10},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search() after delete error = %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("Search() after delete returned %d results, want 0: %#v", len(results), results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
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, ",")
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
func TestLibSQLProviderVectorLifecycle(t *testing.T) {
|
||||
databaseDir := t.TempDir()
|
||||
provider, err := NewLibSQLProvider(&config.VectorDBConfig{
|
||||
Path: filepath.Join(databaseDir, "vectors.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLibSQLProvider() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = provider.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
const collection = "knowledge_chunks"
|
||||
if err := provider.CreateCollection(ctx, collection, 3); err != nil {
|
||||
t.Fatalf("CreateCollection() error = %v", err)
|
||||
}
|
||||
vectors := []Vector{
|
||||
{ID: "a", Vector: []float32{1, 0, 0}, Payload: ChunkPayload{KnowledgeBaseID: 1, DocumentID: 10, Content: "alpha"}},
|
||||
{ID: "b", Vector: []float32{0, 1, 0}, Payload: ChunkPayload{KnowledgeBaseID: 2, DocumentID: 20, Content: "beta"}},
|
||||
{ID: "c", Vector: []float32{0.9, 0.1, 0}, Payload: ChunkPayload{KnowledgeBaseID: 1, DocumentID: 11, Content: "gamma"}},
|
||||
}
|
||||
if err := provider.UpsertVectors(ctx, collection, vectors); err != nil {
|
||||
t.Fatalf("UpsertVectors() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := provider.GetCollection(ctx, collection)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCollection() error = %v", err)
|
||||
}
|
||||
if info.Dimension != 3 || info.PointCount != 3 || info.Status != "ready" {
|
||||
t.Fatalf("GetCollection() = %+v", info)
|
||||
}
|
||||
|
||||
results, err := provider.Search(ctx, &SearchRequest{
|
||||
CollectionName: collection,
|
||||
Vector: []float32{1, 0, 0},
|
||||
TopK: 2,
|
||||
ScoreThreshold: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 || results[0].ID != "a" {
|
||||
t.Fatalf("Search() = %+v, want a first", results)
|
||||
}
|
||||
|
||||
filtered, err := provider.Search(ctx, &SearchRequest{
|
||||
CollectionName: collection,
|
||||
Vector: []float32{1, 0, 0},
|
||||
TopK: 10,
|
||||
ScoreThreshold: 0,
|
||||
Filter: &SearchFilter{KnowledgeBaseIDs: []int64{2}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("filtered Search() error = %v", err)
|
||||
}
|
||||
if len(filtered) != 1 || filtered[0].ID != "b" || filtered[0].Payload.Content != "beta" {
|
||||
t.Fatalf("filtered Search() = %+v", filtered)
|
||||
}
|
||||
if err := provider.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
provider, err = NewLibSQLProvider(&config.VectorDBConfig{Path: filepath.Join(databaseDir, "vectors.db")})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen NewLibSQLProvider() error = %v", err)
|
||||
}
|
||||
info, err = provider.GetCollection(ctx, collection)
|
||||
if err != nil || info.PointCount != 3 {
|
||||
t.Fatalf("reopened GetCollection() = %+v, %v", info, err)
|
||||
}
|
||||
|
||||
if err := provider.DeleteVectors(ctx, collection, []string{"a"}); err != nil {
|
||||
t.Fatalf("DeleteVectors() error = %v", err)
|
||||
}
|
||||
if err := provider.DeleteCollection(ctx, collection); err != nil {
|
||||
t.Fatalf("DeleteCollection() error = %v", err)
|
||||
}
|
||||
collections, err := provider.ListCollections(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCollections() error = %v", err)
|
||||
}
|
||||
if len(collections) != 0 {
|
||||
t.Fatalf("ListCollections() = %v, want empty", collections)
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,23 @@ import (
|
||||
"fmt"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
var defaultProvider Provider
|
||||
|
||||
func Init(cfg *config.VectorDBConfig) error {
|
||||
if cfg == nil || cfg.Type == "" {
|
||||
return nil
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("libsql vector database config is required")
|
||||
}
|
||||
|
||||
var err error
|
||||
switch enums.VectorDBType(cfg.Type) {
|
||||
case enums.VectorDBTypeQdrant:
|
||||
defaultProvider, err = NewQdrantProvider(&cfg.Qdrant)
|
||||
case enums.VectorDBTypeLanceDB:
|
||||
defaultProvider, err = NewLanceDBProvider(&cfg.LanceDB)
|
||||
default:
|
||||
return fmt.Errorf("unsupported vectordb type: %s", cfg.Type)
|
||||
provider, err := NewLibSQLProvider(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
if defaultProvider != nil {
|
||||
_ = defaultProvider.Close()
|
||||
}
|
||||
defaultProvider = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetProvider() Provider {
|
||||
@@ -33,7 +30,9 @@ func GetProvider() Provider {
|
||||
|
||||
func Close() error {
|
||||
if defaultProvider != nil {
|
||||
return defaultProvider.Close()
|
||||
err := defaultProvider.Close()
|
||||
defaultProvider = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build !lancedb
|
||||
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
func TestInitLanceDBWithoutBuildTagReturnsActionableError(t *testing.T) {
|
||||
err := Init(&config.VectorDBConfig{
|
||||
Type: "lancedb",
|
||||
LanceDB: config.LanceDBVectorDBConfig{
|
||||
Path: "data/lancedb",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Init(lancedb) error = nil, want actionable build tag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LanceDB provider is not built") {
|
||||
t.Fatalf("Init(lancedb) error = %q, want build tag guidance", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package vectordb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/qdrant/go-client/qdrant"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/config"
|
||||
)
|
||||
|
||||
type QdrantProvider struct {
|
||||
client *qdrant.Client
|
||||
}
|
||||
|
||||
func NewQdrantProvider(cfg *config.QdrantVectorDBConfig) (*QdrantProvider, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("vectordb config is nil")
|
||||
}
|
||||
|
||||
host := cfg.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
port := cfg.GrpcPort
|
||||
if port <= 0 {
|
||||
port = 6334
|
||||
}
|
||||
|
||||
client, err := qdrant.NewClient(&qdrant.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
APIKey: cfg.APIKey,
|
||||
UseTLS: cfg.UseTLS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create qdrant client: %w", err)
|
||||
}
|
||||
|
||||
return &QdrantProvider{client: client}, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) Close() error {
|
||||
if p.client != nil {
|
||||
return p.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) CreateCollection(ctx context.Context, name string, dimension int) error {
|
||||
err := p.client.CreateCollection(ctx, &qdrant.CreateCollection{
|
||||
CollectionName: name,
|
||||
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
|
||||
Size: uint64(dimension),
|
||||
Distance: qdrant.Distance_Cosine,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create collection %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) DeleteCollection(ctx context.Context, name string) error {
|
||||
err := p.client.DeleteCollection(ctx, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) GetCollection(ctx context.Context, name string) (*CollectionInfo, error) {
|
||||
info, err := p.client.GetCollectionInfo(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection %s: %w", name, err)
|
||||
}
|
||||
|
||||
status := info.GetStatus().String()
|
||||
pointCount := int(info.GetPointsCount())
|
||||
|
||||
dimension := 0
|
||||
if info.Config != nil && info.Config.Params != nil {
|
||||
vectorsConfig := info.Config.Params.VectorsConfig
|
||||
if vectorsConfig != nil {
|
||||
params := vectorsConfig.GetParams()
|
||||
if params != nil {
|
||||
dimension = int(params.Size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &CollectionInfo{
|
||||
Name: name,
|
||||
Dimension: dimension,
|
||||
PointCount: pointCount,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) ListCollections(ctx context.Context) ([]string, error) {
|
||||
collections, err := p.client.ListCollections(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list collections: %w", err)
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) UpsertVectors(ctx context.Context, collectionName string, vectors []Vector) error {
|
||||
if len(vectors) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
points := make([]*qdrant.PointStruct, 0, len(vectors))
|
||||
for _, v := range vectors {
|
||||
points = append(points, &qdrant.PointStruct{
|
||||
Id: qdrant.NewID(v.ID),
|
||||
Vectors: qdrant.NewVectors(v.Vector...),
|
||||
Payload: qdrant.NewValueMap(v.Payload.ToMap()),
|
||||
})
|
||||
}
|
||||
|
||||
_, err := p.client.Upsert(ctx, &qdrant.UpsertPoints{
|
||||
CollectionName: collectionName,
|
||||
Points: points,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert vectors to collection %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) DeleteVectors(ctx context.Context, collectionName string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pointIDs := make([]*qdrant.PointId, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
pointIDs = append(pointIDs, qdrant.NewID(id))
|
||||
}
|
||||
|
||||
_, err := p.client.Delete(ctx, &qdrant.DeletePoints{
|
||||
CollectionName: collectionName,
|
||||
Points: &qdrant.PointsSelector{
|
||||
PointsSelectorOneOf: &qdrant.PointsSelector_Points{
|
||||
Points: &qdrant.PointsIdsList{
|
||||
Ids: pointIDs,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete vectors from collection %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) Search(ctx context.Context, req *SearchRequest) ([]SearchResult, error) {
|
||||
filter := p.buildFilter(req.Filter)
|
||||
|
||||
results, err := p.client.Query(ctx, &qdrant.QueryPoints{
|
||||
CollectionName: req.CollectionName,
|
||||
Query: qdrant.NewQuery(req.Vector...),
|
||||
Limit: qdrant.PtrOf(uint64(req.TopK)),
|
||||
ScoreThreshold: &req.ScoreThreshold,
|
||||
Filter: filter,
|
||||
WithPayload: qdrant.NewWithPayload(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search collection %s: %w", req.CollectionName, err)
|
||||
}
|
||||
|
||||
searchResults := make([]SearchResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
payload := make(map[string]any)
|
||||
if r.Payload != nil {
|
||||
for k, v := range r.Payload {
|
||||
payload[k] = p.extractPayloadValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
id := ""
|
||||
if r.Id != nil {
|
||||
id = r.Id.GetUuid()
|
||||
}
|
||||
|
||||
searchResults = append(searchResults, SearchResult{
|
||||
ID: id,
|
||||
Score: r.Score,
|
||||
Payload: ChunkPayloadFromMap(payload),
|
||||
})
|
||||
}
|
||||
|
||||
return searchResults, nil
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) buildFilter(filter *SearchFilter) *qdrant.Filter {
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
must := make([]*qdrant.Condition, 0, 2)
|
||||
if len(filter.KnowledgeBaseIDs) > 0 {
|
||||
must = append(must, qdrant.NewMatchInts("knowledge_base_id", filter.KnowledgeBaseIDs...))
|
||||
}
|
||||
if len(filter.DocumentIDs) > 0 {
|
||||
must = append(must, qdrant.NewMatchInts("document_id", filter.DocumentIDs...))
|
||||
}
|
||||
if len(must) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &qdrant.Filter{Must: must}
|
||||
}
|
||||
|
||||
func (p *QdrantProvider) extractPayloadValue(v *qdrant.Value) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch val := v.Kind.(type) {
|
||||
case *qdrant.Value_StringValue:
|
||||
return val.StringValue
|
||||
case *qdrant.Value_IntegerValue:
|
||||
return val.IntegerValue
|
||||
case *qdrant.Value_DoubleValue:
|
||||
return val.DoubleValue
|
||||
case *qdrant.Value_BoolValue:
|
||||
return val.BoolValue
|
||||
case *qdrant.Value_ListValue:
|
||||
list := make([]interface{}, 0, len(val.ListValue.Values))
|
||||
for _, item := range val.ListValue.Values {
|
||||
list = append(list, p.extractPayloadValue(item))
|
||||
}
|
||||
return list
|
||||
case *qdrant.Value_StructValue:
|
||||
m := make(map[string]interface{})
|
||||
for k, v := range val.StructValue.Fields {
|
||||
m[k] = p.extractPayloadValue(v)
|
||||
}
|
||||
return m
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,16 @@ type Vector struct {
|
||||
}
|
||||
|
||||
type SearchRequest struct {
|
||||
CollectionName string `json:"collectionName"`
|
||||
CollectionName string `json:"collection_name"`
|
||||
Vector []float32 `json:"vector"`
|
||||
TopK int `json:"topK"`
|
||||
ScoreThreshold float32 `json:"scoreThreshold"`
|
||||
TopK int `json:"top_k"`
|
||||
ScoreThreshold float32 `json:"score_threshold"`
|
||||
Filter *SearchFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
type SearchFilter struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds,omitempty"`
|
||||
DocumentIDs []int64 `json:"documentIds,omitempty"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledge_base_ids,omitempty"`
|
||||
DocumentIDs []int64 `json:"document_ids,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
@@ -30,7 +30,7 @@ type SearchResult struct {
|
||||
type CollectionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Dimension int `json:"dimension"`
|
||||
PointCount int `json:"pointCount"`
|
||||
PointCount int `json:"point_count"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user