Move ingestion to CLI and add server upsert API

This commit is contained in:
shenlan 2025-08-09 16:35:28 +08:00
parent 792eb04a71
commit 4f47c61837
4 changed files with 126 additions and 88 deletions

View File

@ -1,49 +1,111 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
rconfig "xcontrol/server/rag/config"
"xcontrol/server/rag/embed"
"xcontrol/server/rag/ingest"
"xcontrol/server/rag/store"
rsync "xcontrol/server/rag/sync"
)
// main loads server RAG configuration and triggers a manual sync by
// calling the running API server's /api/rag/sync endpoint.
// main performs cloning, parsing and embedding before sending documents to the server.
func main() {
configPath := flag.String("config", "", "Path to server RAG configuration file")
configPath := flag.String("config", "", "Path to RAG configuration file")
flag.Parse()
if *configPath != "" {
if _, err := rconfig.Load(*configPath); err != nil {
log.Fatalf("load config: %v", err)
}
if *configPath == "" {
log.Fatalf("config path required")
}
cfg, err := rconfig.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
baseURL := os.Getenv("SERVER_URL")
if baseURL == "" {
baseURL = "http://localhost:8080"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/rag/sync", nil)
if err != nil {
log.Fatalf("create request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("sync request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
log.Fatalf("sync failed: %s", string(b))
}
if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
log.Fatalf("read response: %v", err)
embCfg := cfg.ResolveEmbedding()
chunkCfg := cfg.ResolveChunking()
embedder := embed.NewOpenAI(embCfg.BaseURL, embCfg.APIKey, embCfg.Model, embCfg.Dimension)
for _, ds := range cfg.Global.Datasources {
workdir := filepath.Join(os.TempDir(), "xcontrol", ds.Name)
if _, err := rsync.SyncRepo(ctx, ds.Repo, workdir); err != nil {
log.Fatalf("sync repo %s: %v", ds.Name, err)
}
root := filepath.Join(workdir, ds.Path)
files, err := ingest.ListMarkdown(root, chunkCfg.IncludeExts, chunkCfg.IgnoreDirs, 0)
if err != nil {
log.Fatalf("list markdown: %v", err)
}
var rows []store.DocRow
for _, f := range files {
secs, err := ingest.ParseMarkdown(f)
if err != nil {
log.Printf("parse %s: %v", f, err)
continue
}
chunks, err := ingest.BuildChunks(secs, chunkCfg)
if err != nil {
log.Printf("chunk %s: %v", f, err)
continue
}
texts := make([]string, len(chunks))
rs := make([]store.DocRow, len(chunks))
for i, ch := range chunks {
texts[i] = ch.Text
rs[i] = store.DocRow{
Repo: ds.Repo,
Path: strings.TrimPrefix(f, workdir+"/"),
ChunkID: ch.ChunkID,
Content: ch.Text,
Metadata: ch.Meta,
ContentSHA: ch.SHA256,
}
}
vecs, _, err := embedder.Embed(ctx, texts)
if err != nil {
log.Printf("embed %s: %v", f, err)
continue
}
for i := range rs {
rs[i].Embedding = vecs[i]
}
rows = append(rows, rs...)
}
payload := struct {
Docs []store.DocRow `json:"docs"`
}{Docs: rows}
b, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/rag/upsert", bytes.NewReader(b))
if err != nil {
log.Fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("upsert request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("upsert failed: %s", resp.Status)
}
log.Printf("ingested %d rows for %s", len(rows), ds.Name)
}
}

View File

@ -1,13 +1,13 @@
package api
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"xcontrol/server/rag"
rconfig "xcontrol/server/rag/config"
"xcontrol/server/rag/store"
)
// ragSvc provides repository sync and retrieval operations.
@ -19,29 +19,29 @@ func initRAG() *rag.Service {
if err != nil {
return nil
}
svc := rag.New(cfg.ToConfig())
go svc.Sync(context.Background())
go svc.Watch(context.Background())
return svc
return rag.New(cfg.ToConfig())
}
// registerRAGRoutes wires the /api/rag endpoints.
func registerRAGRoutes(r *gin.RouterGroup) {
r.POST("/rag/sync", func(c *gin.Context) {
r.POST("/rag/upsert", func(c *gin.Context) {
if ragSvc == nil {
c.String(http.StatusOK, "rag service not initialized\n")
c.JSON(http.StatusOK, gin.H{"rows": 0})
return
}
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Status(http.StatusOK)
err := ragSvc.SyncWithProgress(c.Request.Context(), func(msg string) {
_, _ = c.Writer.Write([]byte(msg + "\n"))
c.Writer.Flush()
})
var req struct {
Docs []store.DocRow `json:"docs"`
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := ragSvc.Upsert(c.Request.Context(), req.Docs)
if err != nil {
_, _ = c.Writer.Write([]byte("error: " + err.Error() + "\n"))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rows": n})
})
r.POST("/rag/query", func(c *gin.Context) {

View File

@ -3,14 +3,13 @@ package rag
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
pgvector "github.com/pgvector/pgvector-go"
"xcontrol/server/rag/config"
"xcontrol/server/rag/embed"
"xcontrol/server/rag/ingest"
"xcontrol/server/rag/store"
)
type Service struct {
@ -21,49 +20,26 @@ func New(cfg *config.Config) *Service {
return &Service{cfg: cfg}
}
func (s *Service) Sync(ctx context.Context) error {
return s.SyncWithProgress(ctx, nil)
}
// Upsert stores pre-embedded documents into the vector database.
func (s *Service) Upsert(ctx context.Context, rows []store.DocRow) (int, error) {
if s == nil || s.cfg == nil || len(rows) == 0 {
return 0, nil
}
dsn := s.cfg.Global.VectorDB.DSN()
if dsn == "" {
return 0, nil
}
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return 0, err
}
defer conn.Close(ctx)
// SyncWithProgress performs a full sync while reporting progress via the provided callback.
//
// The progress callback may be nil. When non-nil it will receive human readable
// status messages as the sync operation progresses.
func (s *Service) SyncWithProgress(ctx context.Context, progress func(string)) error {
if s == nil || s.cfg == nil {
return nil
}
for _, ds := range s.cfg.Global.Datasources {
if progress != nil {
progress("syncing " + ds.Name)
}
if _, err := ingest.IngestRepo(ctx, s.cfg, ds, ingest.Options{}); err != nil {
if progress != nil {
progress("error syncing " + ds.Name + ": " + err.Error())
}
return err
}
if progress != nil {
progress("completed " + ds.Name)
}
}
return nil
}
func (s *Service) Watch(ctx context.Context) {
if s == nil || s.cfg == nil {
return
}
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = s.Sync(ctx)
}
dim := len(rows[0].Embedding)
if err := store.EnsureSchema(ctx, conn, dim, false); err != nil {
return 0, err
}
return store.UpsertDocuments(ctx, conn, rows)
}
type Document struct {

View File

@ -11,13 +11,13 @@ import (
// DocRow represents a row to be stored in the documents table.
type DocRow struct {
Repo string
Path string
ChunkID int
Content string
Embedding []float32
Metadata map[string]any
ContentSHA string
Repo string `json:"repo"`
Path string `json:"path"`
ChunkID int `json:"chunk_id"`
Content string `json:"content"`
Embedding []float32 `json:"embedding"`
Metadata map[string]any `json:"metadata"`
ContentSHA string `json:"content_sha"`
}
// EnsureSchema creates the documents table and indexes if they do not exist. It