Changeset 0.14.0 (#67)
This commit is contained in:
244
server/knowledge/chunker.go
Normal file
244
server/knowledge/chunker.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ── Configuration ────────────────────────────
|
||||
|
||||
// ChunkConfig controls how text is split into chunks.
|
||||
type ChunkConfig struct {
|
||||
ChunkSize int // target chars per chunk (default 1000)
|
||||
ChunkOverlap int // overlap chars between adjacent chunks (default 200)
|
||||
Separators []string // split hierarchy, tried in order
|
||||
}
|
||||
|
||||
// DefaultChunkConfig returns sensible defaults for general documents.
|
||||
// ~250 tokens per chunk at ~4 chars/token.
|
||||
func DefaultChunkConfig() ChunkConfig {
|
||||
return ChunkConfig{
|
||||
ChunkSize: 1000,
|
||||
ChunkOverlap: 200,
|
||||
Separators: []string{"\n\n", "\n", ". ", " "},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────
|
||||
|
||||
// Chunk is a single text segment with position metadata.
|
||||
type Chunk struct {
|
||||
Content string // chunk text
|
||||
Index int // ordinal within document (0-based)
|
||||
TokenCount int // estimated token count (~chars/4)
|
||||
Metadata map[string]any // extensible: page, heading, etc.
|
||||
}
|
||||
|
||||
// ── Splitter ─────────────────────────────────
|
||||
|
||||
// SplitText splits text into overlapping chunks using recursive
|
||||
// character splitting. It tries separators in order, splitting on
|
||||
// the first one that produces segments smaller than ChunkSize.
|
||||
// Segments that are still too large are recursively split with the
|
||||
// next separator.
|
||||
func SplitText(text string, cfg ChunkConfig) []Chunk {
|
||||
if cfg.ChunkSize <= 0 {
|
||||
cfg.ChunkSize = 1000
|
||||
}
|
||||
if cfg.ChunkOverlap < 0 {
|
||||
cfg.ChunkOverlap = 0
|
||||
}
|
||||
if cfg.ChunkOverlap >= cfg.ChunkSize {
|
||||
cfg.ChunkOverlap = cfg.ChunkSize / 5
|
||||
}
|
||||
if len(cfg.Separators) == 0 {
|
||||
cfg.Separators = DefaultChunkConfig().Separators
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If text fits in one chunk, return it directly.
|
||||
if utf8.RuneCountInString(text) <= cfg.ChunkSize {
|
||||
return []Chunk{{
|
||||
Content: text,
|
||||
Index: 0,
|
||||
TokenCount: estimateTokens(text),
|
||||
Metadata: map[string]any{},
|
||||
}}
|
||||
}
|
||||
|
||||
// Recursively split, then merge into overlapping windows.
|
||||
segments := recursiveSplit(text, cfg.Separators, cfg.ChunkSize)
|
||||
return mergeSegments(segments, cfg.ChunkSize, cfg.ChunkOverlap)
|
||||
}
|
||||
|
||||
// recursiveSplit tries each separator in order. For the first separator
|
||||
// that actually splits the text, it checks if each piece fits within
|
||||
// chunkSize. Pieces that are still too large recurse with the remaining
|
||||
// separators. Pieces that fit are kept as-is.
|
||||
func recursiveSplit(text string, separators []string, chunkSize int) []string {
|
||||
if utf8.RuneCountInString(text) <= chunkSize {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
if len(separators) == 0 {
|
||||
// No separators left — hard split by character count.
|
||||
return hardSplit(text, chunkSize)
|
||||
}
|
||||
|
||||
sep := separators[0]
|
||||
rest := separators[1:]
|
||||
|
||||
parts := splitKeepSep(text, sep)
|
||||
if len(parts) <= 1 {
|
||||
// This separator didn't split anything — try next.
|
||||
return recursiveSplit(text, rest, chunkSize)
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(part) <= chunkSize {
|
||||
result = append(result, part)
|
||||
} else {
|
||||
// Still too large — recurse with remaining separators.
|
||||
result = append(result, recursiveSplit(part, rest, chunkSize)...)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// splitKeepSep splits text on sep. Unlike strings.Split, the separator
|
||||
// is kept at the end of each segment (except the last) to preserve
|
||||
// context like sentence-ending periods.
|
||||
func splitKeepSep(text, sep string) []string {
|
||||
parts := strings.Split(text, sep)
|
||||
if len(parts) <= 1 {
|
||||
return parts
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
if i < len(parts)-1 {
|
||||
result = append(result, part+sep)
|
||||
} else if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// hardSplit cuts text into pieces of at most chunkSize runes.
|
||||
// Last resort when no separator works.
|
||||
func hardSplit(text string, chunkSize int) []string {
|
||||
runes := []rune(text)
|
||||
var result []string
|
||||
for i := 0; i < len(runes); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
result = append(result, string(runes[i:end]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeSegments combines small segments into chunks up to chunkSize,
|
||||
// then applies overlap between adjacent chunks.
|
||||
func mergeSegments(segments []string, chunkSize, overlap int) []Chunk {
|
||||
if len(segments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First pass: merge small adjacent segments into windows up to chunkSize.
|
||||
var merged []string
|
||||
var current strings.Builder
|
||||
|
||||
for _, seg := range segments {
|
||||
segLen := utf8.RuneCountInString(seg)
|
||||
curLen := utf8.RuneCountInString(current.String())
|
||||
|
||||
if curLen > 0 && curLen+segLen > chunkSize {
|
||||
// Flush current buffer.
|
||||
merged = append(merged, strings.TrimSpace(current.String()))
|
||||
current.Reset()
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
current.WriteString(" ")
|
||||
}
|
||||
current.WriteString(seg)
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
merged = append(merged, strings.TrimSpace(current.String()))
|
||||
}
|
||||
|
||||
if overlap <= 0 || len(merged) <= 1 {
|
||||
// No overlap needed — convert directly to Chunks.
|
||||
chunks := make([]Chunk, len(merged))
|
||||
for i, text := range merged {
|
||||
chunks[i] = Chunk{
|
||||
Content: text,
|
||||
Index: i,
|
||||
TokenCount: estimateTokens(text),
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// Second pass: create overlapping chunks.
|
||||
// Each chunk includes up to `overlap` chars from the end of the
|
||||
// previous chunk as prefix context.
|
||||
chunks := make([]Chunk, 0, len(merged))
|
||||
for i, text := range merged {
|
||||
if i > 0 {
|
||||
prev := merged[i-1]
|
||||
suffix := overlapSuffix(prev, overlap)
|
||||
if suffix != "" {
|
||||
text = suffix + " " + text
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, Chunk{
|
||||
Content: text,
|
||||
Index: i,
|
||||
TokenCount: estimateTokens(text),
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// overlapSuffix returns the last `n` characters of s, breaking at a
|
||||
// word boundary to avoid splitting mid-word.
|
||||
func overlapSuffix(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
|
||||
start := len(runes) - n
|
||||
suffix := string(runes[start:])
|
||||
|
||||
// Try to break at a space to avoid mid-word splits.
|
||||
if idx := strings.Index(suffix, " "); idx >= 0 && idx < len(suffix)/2 {
|
||||
suffix = suffix[idx+1:]
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
// estimateTokens gives a rough token count. Most tokenizers average
|
||||
// ~4 characters per token for English text.
|
||||
func estimateTokens(s string) int {
|
||||
n := utf8.RuneCountInString(s)
|
||||
tokens := n / 4
|
||||
if tokens == 0 && n > 0 {
|
||||
tokens = 1
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
227
server/knowledge/chunker_test.go
Normal file
227
server/knowledge/chunker_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestSplitText_EmptyInput(t *testing.T) {
|
||||
chunks := SplitText("", DefaultChunkConfig())
|
||||
if chunks != nil {
|
||||
t.Errorf("expected nil for empty input, got %d chunks", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_WhitespaceOnly(t *testing.T) {
|
||||
chunks := SplitText(" \n\n ", DefaultChunkConfig())
|
||||
if chunks != nil {
|
||||
t.Errorf("expected nil for whitespace input, got %d chunks", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_SmallText(t *testing.T) {
|
||||
text := "Hello, world."
|
||||
chunks := SplitText(text, DefaultChunkConfig())
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(chunks))
|
||||
}
|
||||
if chunks[0].Content != text {
|
||||
t.Errorf("content mismatch: %q", chunks[0].Content)
|
||||
}
|
||||
if chunks[0].Index != 0 {
|
||||
t.Errorf("expected index 0, got %d", chunks[0].Index)
|
||||
}
|
||||
if chunks[0].TokenCount <= 0 {
|
||||
t.Error("expected positive token count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_ParagraphSplit(t *testing.T) {
|
||||
// Two paragraphs, each under chunk size, but together over.
|
||||
para := strings.Repeat("word ", 60) // ~300 chars each
|
||||
text := para + "\n\n" + para
|
||||
|
||||
cfg := ChunkConfig{ChunkSize: 400, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}}
|
||||
chunks := SplitText(text, cfg)
|
||||
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks from paragraph split, got %d", len(chunks))
|
||||
}
|
||||
|
||||
for i, c := range chunks {
|
||||
if utf8.RuneCountInString(c.Content) == 0 {
|
||||
t.Errorf("chunk %d is empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_OverlapPresent(t *testing.T) {
|
||||
// Build text that will split into multiple chunks.
|
||||
sentences := make([]string, 20)
|
||||
for i := range sentences {
|
||||
sentences[i] = "This is sentence number " + strings.Repeat("x", 40) + ". "
|
||||
}
|
||||
text := strings.Join(sentences, "")
|
||||
|
||||
cfg := ChunkConfig{ChunkSize: 200, ChunkOverlap: 50, Separators: []string{". ", " "}}
|
||||
chunks := SplitText(text, cfg)
|
||||
|
||||
if len(chunks) < 3 {
|
||||
t.Fatalf("expected at least 3 chunks, got %d", len(chunks))
|
||||
}
|
||||
|
||||
// Verify chunks have sequential indexes.
|
||||
for i, c := range chunks {
|
||||
if c.Index != i {
|
||||
t.Errorf("chunk %d has index %d", i, c.Index)
|
||||
}
|
||||
}
|
||||
|
||||
// With overlap > 0 and multiple chunks, later chunks should share
|
||||
// some content with the previous chunk's tail.
|
||||
// Just verify the overlap logic ran (chunks 1+ are longer or contain
|
||||
// content from the previous chunk's ending).
|
||||
if len(chunks) >= 2 {
|
||||
// Chunk 1 should contain some overlap from chunk 0's tail.
|
||||
// We can't check exact content easily, but the chunk should be
|
||||
// non-trivially sized.
|
||||
if len(chunks[1].Content) < 50 {
|
||||
t.Errorf("chunk 1 seems too short for overlap: %d chars", len(chunks[1].Content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_ZeroOverlap(t *testing.T) {
|
||||
text := strings.Repeat("A", 500) + "\n\n" + strings.Repeat("B", 500)
|
||||
cfg := ChunkConfig{ChunkSize: 400, ChunkOverlap: 0, Separators: []string{"\n\n"}}
|
||||
chunks := SplitText(text, cfg)
|
||||
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks, got %d", len(chunks))
|
||||
}
|
||||
|
||||
// Without overlap, chunks should not share content.
|
||||
for i, c := range chunks {
|
||||
if c.Index != i {
|
||||
t.Errorf("chunk %d index = %d", i, c.Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_HardSplitFallback(t *testing.T) {
|
||||
// No separators at all in the text — forces hard character split.
|
||||
text := strings.Repeat("x", 500)
|
||||
cfg := ChunkConfig{ChunkSize: 200, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}}
|
||||
chunks := SplitText(text, cfg)
|
||||
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected multiple chunks from hard split, got %d", len(chunks))
|
||||
}
|
||||
|
||||
// All chunks should be at most chunkSize.
|
||||
for i, c := range chunks {
|
||||
if utf8.RuneCountInString(c.Content) > cfg.ChunkSize {
|
||||
t.Errorf("chunk %d exceeds chunk size: %d > %d",
|
||||
i, utf8.RuneCountInString(c.Content), cfg.ChunkSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_TokenEstimate(t *testing.T) {
|
||||
text := strings.Repeat("word ", 100) // 500 chars
|
||||
chunks := SplitText(text, ChunkConfig{ChunkSize: 2000, ChunkOverlap: 0})
|
||||
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(chunks))
|
||||
}
|
||||
|
||||
// ~500 chars / 4 = ~125 tokens
|
||||
if chunks[0].TokenCount < 100 || chunks[0].TokenCount > 150 {
|
||||
t.Errorf("expected ~125 tokens, got %d", chunks[0].TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_InvalidConfig(t *testing.T) {
|
||||
text := "Hello world. This is a test."
|
||||
|
||||
// ChunkSize 0 should use default.
|
||||
chunks := SplitText(text, ChunkConfig{ChunkSize: 0})
|
||||
if len(chunks) != 1 {
|
||||
t.Errorf("expected 1 chunk with zero chunk size (uses default), got %d", len(chunks))
|
||||
}
|
||||
|
||||
// Overlap >= ChunkSize should be clamped.
|
||||
chunks = SplitText(strings.Repeat("word ", 200), ChunkConfig{
|
||||
ChunkSize: 100,
|
||||
ChunkOverlap: 100,
|
||||
})
|
||||
if len(chunks) == 0 {
|
||||
t.Error("expected chunks with overlap == chunk_size (should clamp)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_SentenceSplit(t *testing.T) {
|
||||
text := "First sentence. Second sentence. Third sentence. Fourth sentence. Fifth sentence."
|
||||
cfg := ChunkConfig{ChunkSize: 40, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}}
|
||||
chunks := SplitText(text, cfg)
|
||||
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected multiple sentence-split chunks, got %d", len(chunks))
|
||||
}
|
||||
|
||||
// Each chunk should be within bounds.
|
||||
for i, c := range chunks {
|
||||
if utf8.RuneCountInString(c.Content) == 0 {
|
||||
t.Errorf("chunk %d is empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitText_MetadataInitialized(t *testing.T) {
|
||||
chunks := SplitText("Some text here.", DefaultChunkConfig())
|
||||
if len(chunks) != 1 {
|
||||
t.Fatal("expected 1 chunk")
|
||||
}
|
||||
if chunks[0].Metadata == nil {
|
||||
t.Error("metadata should be initialized, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokens(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
minTok int
|
||||
maxTok int
|
||||
}{
|
||||
{"", 0, 0},
|
||||
{"hi", 1, 1},
|
||||
{"hello world", 2, 4},
|
||||
{strings.Repeat("a", 100), 20, 30},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := estimateTokens(tc.input)
|
||||
if got < tc.minTok || got > tc.maxTok {
|
||||
t.Errorf("estimateTokens(%q): got %d, want [%d, %d]", tc.input, got, tc.minTok, tc.maxTok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapSuffix(t *testing.T) {
|
||||
s := "the quick brown fox jumps over the lazy dog"
|
||||
|
||||
// Should return last ~20 chars, breaking at word boundary.
|
||||
suffix := overlapSuffix(s, 20)
|
||||
if len(suffix) == 0 {
|
||||
t.Error("expected non-empty suffix")
|
||||
}
|
||||
if len(suffix) > 25 { // some slack for word boundary adjustment
|
||||
t.Errorf("suffix too long: %d chars: %q", len(suffix), suffix)
|
||||
}
|
||||
|
||||
// Short string: return whole thing.
|
||||
short := "hello"
|
||||
if got := overlapSuffix(short, 100); got != short {
|
||||
t.Errorf("expected %q, got %q", short, got)
|
||||
}
|
||||
}
|
||||
184
server/knowledge/embedder.go
Normal file
184
server/knowledge/embedder.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Configuration ────────────────────────────
|
||||
|
||||
const (
|
||||
// DefaultBatchSize is the max chunks per embedding API call.
|
||||
// Most providers handle 100 inputs per request comfortably.
|
||||
DefaultBatchSize = 100
|
||||
|
||||
// MaxVectorDim is the column width in pgvector (vector(3072)).
|
||||
// Vectors shorter than this are zero-padded on storage.
|
||||
MaxVectorDim = 3072
|
||||
)
|
||||
|
||||
// ── Embedder ─────────────────────────────────
|
||||
|
||||
// Embedder generates vector embeddings for text chunks using the
|
||||
// embedding role resolver. It handles batching and dimension
|
||||
// normalization (zero-padding to MaxVectorDim).
|
||||
type Embedder struct {
|
||||
resolver *roles.Resolver
|
||||
stores store.Stores // optional — for usage tracking
|
||||
batchSize int
|
||||
}
|
||||
|
||||
// NewEmbedder creates an embedder that dispatches through the role resolver.
|
||||
func NewEmbedder(resolver *roles.Resolver) *Embedder {
|
||||
return &Embedder{
|
||||
resolver: resolver,
|
||||
batchSize: DefaultBatchSize,
|
||||
}
|
||||
}
|
||||
|
||||
// WithStores attaches stores for usage tracking. Returns self for chaining.
|
||||
func (e *Embedder) WithStores(s store.Stores) *Embedder {
|
||||
e.stores = s
|
||||
return e
|
||||
}
|
||||
|
||||
// EmbedResult holds the output of a batch embedding operation.
|
||||
type EmbedResult struct {
|
||||
Vectors [][]float64 // one per input chunk, zero-padded to MaxVectorDim
|
||||
Model string // model that produced the embeddings
|
||||
Dimensions int // native dimension before padding
|
||||
InputTokens int // total tokens consumed across all batches
|
||||
ConfigID string // provider config that was used
|
||||
ProviderScope string // "personal", "team", or "global"
|
||||
}
|
||||
|
||||
// EmbedChunks generates embeddings for a slice of text chunks.
|
||||
// Chunks are batched in groups of batchSize to avoid provider limits.
|
||||
// Uses the embedding role resolution chain: personal → team → global.
|
||||
//
|
||||
// Returns vectors in the same order as input chunks.
|
||||
func (e *Embedder) EmbedChunks(ctx context.Context, userID string, teamID *string, texts []string) (*EmbedResult, error) {
|
||||
if len(texts) == 0 {
|
||||
return &EmbedResult{Vectors: [][]float64{}}, nil
|
||||
}
|
||||
|
||||
// Verify embedding role is configured before starting
|
||||
cfg, err := e.resolver.GetConfig(ctx, roles.RoleEmbedding, userID, teamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding role not configured: %w", err)
|
||||
}
|
||||
if cfg.Primary == nil {
|
||||
return nil, fmt.Errorf("embedding role has no primary binding")
|
||||
}
|
||||
|
||||
allVectors := make([][]float64, 0, len(texts))
|
||||
var model string
|
||||
var nativeDim int
|
||||
var configID, provScope string
|
||||
totalTokens := 0
|
||||
|
||||
for i := 0; i < len(texts); i += e.batchSize {
|
||||
end := i + e.batchSize
|
||||
if end > len(texts) {
|
||||
end = len(texts)
|
||||
}
|
||||
batch := texts[i:end]
|
||||
|
||||
log.Printf(" embed batch %d/%d (%d chunks)",
|
||||
(i/e.batchSize)+1, (len(texts)+e.batchSize-1)/e.batchSize, len(batch))
|
||||
|
||||
result, err := e.resolver.Embed(ctx, roles.RoleEmbedding, userID, teamID, batch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embed batch starting at chunk %d: %w", i, err)
|
||||
}
|
||||
|
||||
if len(result.Embeddings) != len(batch) {
|
||||
return nil, fmt.Errorf("embed batch %d: expected %d vectors, got %d",
|
||||
i/e.batchSize, len(batch), len(result.Embeddings))
|
||||
}
|
||||
|
||||
// Capture model info from first batch
|
||||
if model == "" {
|
||||
model = result.Model
|
||||
configID = result.ConfigID
|
||||
provScope = result.ProviderScope
|
||||
if len(result.Embeddings) > 0 {
|
||||
nativeDim = len(result.Embeddings[0])
|
||||
}
|
||||
}
|
||||
|
||||
totalTokens += result.InputTokens
|
||||
allVectors = append(allVectors, result.Embeddings...)
|
||||
}
|
||||
|
||||
// Zero-pad to MaxVectorDim for uniform storage
|
||||
for i, vec := range allVectors {
|
||||
allVectors[i] = padVector(vec, MaxVectorDim)
|
||||
}
|
||||
|
||||
return &EmbedResult{
|
||||
Vectors: allVectors,
|
||||
Model: model,
|
||||
Dimensions: nativeDim,
|
||||
InputTokens: totalTokens,
|
||||
ConfigID: configID,
|
||||
ProviderScope: provScope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LogUsage records an embedding usage entry. Silently no-ops if stores
|
||||
// were not attached via WithStores or if there are no tokens to log.
|
||||
func (e *Embedder) LogUsage(ctx context.Context, userID string, channelID *string, result *EmbedResult) {
|
||||
if e.stores.Usage == nil || result == nil || result.InputTokens == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
role := "embedding"
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: strPtr(result.ConfigID),
|
||||
ProviderScope: result.ProviderScope,
|
||||
ModelID: result.Model,
|
||||
Role: &role,
|
||||
PromptTokens: result.InputTokens,
|
||||
CompletionTokens: 0,
|
||||
}
|
||||
|
||||
if err := e.stores.Usage.Log(ctx, entry); err != nil {
|
||||
log.Printf("⚠ Failed to log embedding usage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfigured returns true if the embedding role has at least a primary binding.
|
||||
func (e *Embedder) IsConfigured(ctx context.Context) bool {
|
||||
return e.resolver.IsConfigured(ctx, roles.RoleEmbedding)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
// padVector zero-pads a vector to the target dimension.
|
||||
// If the vector is already the target size or larger, it's truncated.
|
||||
func padVector(vec []float64, targetDim int) []float64 {
|
||||
if len(vec) == targetDim {
|
||||
return vec
|
||||
}
|
||||
if len(vec) > targetDim {
|
||||
return vec[:targetDim]
|
||||
}
|
||||
padded := make([]float64, targetDim)
|
||||
copy(padded, vec)
|
||||
return padded
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
258
server/knowledge/ingest.go
Normal file
258
server/knowledge/ingest.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Configuration ────────────────────────────
|
||||
|
||||
const (
|
||||
// DefaultConcurrency limits parallel ingestion goroutines.
|
||||
DefaultConcurrency = 3
|
||||
|
||||
// MaxTextLength is the upper bound for extracted text (10 MB).
|
||||
// Documents larger than this are truncated with a warning.
|
||||
MaxTextLength = 10 * 1024 * 1024
|
||||
|
||||
// contextTimeout for the entire ingestion of one document.
|
||||
ingestTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// textMIMETypes are content types we can extract text from directly.
|
||||
var textMIMETypes = map[string]bool{
|
||||
"text/plain": true,
|
||||
"text/markdown": true,
|
||||
"text/csv": true,
|
||||
"text/html": true,
|
||||
}
|
||||
|
||||
// ── Ingester ─────────────────────────────────
|
||||
|
||||
// Ingester manages the document ingestion pipeline.
|
||||
// It runs chunk + embed as background goroutines with a concurrency
|
||||
// semaphore to avoid overwhelming the embedding provider.
|
||||
type Ingester struct {
|
||||
stores store.Stores
|
||||
embedder *Embedder
|
||||
objStore storage.ObjectStore
|
||||
sem chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewIngester creates an ingestion pipeline.
|
||||
func NewIngester(stores store.Stores, embedder *Embedder, objStore storage.ObjectStore, concurrency int) *Ingester {
|
||||
if concurrency <= 0 {
|
||||
concurrency = DefaultConcurrency
|
||||
}
|
||||
return &Ingester{
|
||||
stores: stores,
|
||||
embedder: embedder,
|
||||
objStore: objStore,
|
||||
sem: make(chan struct{}, concurrency),
|
||||
}
|
||||
}
|
||||
|
||||
// IngestDocument starts async ingestion for a document.
|
||||
// The document must already exist in kb_documents with status "pending"
|
||||
// and the file must be stored at doc.StorageKey in the object store.
|
||||
//
|
||||
// The goroutine progresses through: pending → extracting → chunking →
|
||||
// embedding → ready (or → error on failure).
|
||||
func (ing *Ingester) IngestDocument(kb *models.KnowledgeBase, doc *models.KBDocument, userID string, teamID *string) {
|
||||
ing.wg.Add(1)
|
||||
go func() {
|
||||
defer ing.wg.Done()
|
||||
|
||||
// Acquire semaphore slot
|
||||
ing.sem <- struct{}{}
|
||||
defer func() { <-ing.sem }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ingestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := ing.ingest(ctx, kb, doc, userID, teamID); err != nil {
|
||||
errMsg := err.Error()
|
||||
log.Printf("❌ ingest doc %s (%s): %v", doc.ID, doc.Filename, err)
|
||||
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "error", &errMsg)
|
||||
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait blocks until all in-flight ingestion goroutines complete.
|
||||
// Call during graceful shutdown.
|
||||
func (ing *Ingester) Wait() {
|
||||
ing.wg.Wait()
|
||||
}
|
||||
|
||||
// ── Internal Pipeline ────────────────────────
|
||||
|
||||
func (ing *Ingester) ingest(ctx context.Context, kb *models.KnowledgeBase, doc *models.KBDocument, userID string, teamID *string) error {
|
||||
start := time.Now()
|
||||
log.Printf("▶ ingest %s (%s, %d bytes)", doc.Filename, doc.ContentType, doc.SizeBytes)
|
||||
|
||||
// Mark KB as processing
|
||||
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "processing"})
|
||||
|
||||
// ── Step 1: Extract text ────────────────
|
||||
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "extracting", nil)
|
||||
|
||||
text, err := ing.extractText(ctx, doc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return fmt.Errorf("extract: no text content found in %s", doc.Filename)
|
||||
}
|
||||
|
||||
// Truncate very large documents
|
||||
if len(text) > MaxTextLength {
|
||||
log.Printf(" ⚠ truncating %s from %d to %d chars", doc.Filename, len(text), MaxTextLength)
|
||||
text = text[:MaxTextLength]
|
||||
}
|
||||
|
||||
// Store extracted text for potential re-chunking later
|
||||
if err := ing.stores.KnowledgeBases.UpdateDocumentText(ctx, doc.ID, text, 0); err != nil {
|
||||
log.Printf(" ⚠ failed to store extracted text for %s: %v", doc.ID, err)
|
||||
}
|
||||
|
||||
// ── Step 2: Chunk ───────────────────────
|
||||
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "chunking", nil)
|
||||
|
||||
cfg := DefaultChunkConfig()
|
||||
chunks := SplitText(text, cfg)
|
||||
if len(chunks) == 0 {
|
||||
return fmt.Errorf("chunk: produced zero chunks from %s", doc.Filename)
|
||||
}
|
||||
|
||||
log.Printf(" ✓ chunked %s → %d chunks", doc.Filename, len(chunks))
|
||||
|
||||
// ── Step 3: Embed ───────────────────────
|
||||
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "embedding", nil)
|
||||
|
||||
texts := make([]string, len(chunks))
|
||||
for i, c := range chunks {
|
||||
texts[i] = c.Content
|
||||
}
|
||||
|
||||
embedResult, err := ing.embedder.EmbedChunks(ctx, userID, teamID, texts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf(" ✓ embedded %d chunks (model=%s, dim=%d, tokens=%d)",
|
||||
len(chunks), embedResult.Model, embedResult.Dimensions, embedResult.InputTokens)
|
||||
|
||||
// Track embedding usage (role = "embedding")
|
||||
ing.embedder.LogUsage(ctx, userID, nil, embedResult)
|
||||
|
||||
// ── Step 4: Store chunks with vectors ───
|
||||
dbChunks := make([]models.KBChunk, len(chunks))
|
||||
for i, c := range chunks {
|
||||
dbChunks[i] = models.KBChunk{
|
||||
KBID: kb.ID,
|
||||
DocumentID: doc.ID,
|
||||
ChunkIndex: c.Index,
|
||||
Content: c.Content,
|
||||
TokenCount: c.TokenCount,
|
||||
Embedding: embedResult.Vectors[i],
|
||||
Metadata: models.JSONMap{
|
||||
"char_start": i * (cfg.ChunkSize - cfg.ChunkOverlap), // approximate
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := ing.stores.KnowledgeBases.InsertChunks(ctx, dbChunks); err != nil {
|
||||
return fmt.Errorf("store chunks: %w", err)
|
||||
}
|
||||
|
||||
// ── Step 5: Update stats ────────────────
|
||||
// Update document status + chunk count
|
||||
if err := ing.stores.KnowledgeBases.UpdateDocumentText(ctx, doc.ID, text, len(chunks)); err != nil {
|
||||
log.Printf(" ⚠ failed to update chunk count for %s: %v", doc.ID, err)
|
||||
}
|
||||
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "ready", nil)
|
||||
|
||||
// Update KB-level stats
|
||||
if err := ing.stores.KnowledgeBases.UpdateStats(ctx, kb.ID); err != nil {
|
||||
log.Printf(" ⚠ failed to update KB stats: %v", err)
|
||||
}
|
||||
|
||||
// Store embedding model info in KB config if not set
|
||||
if kb.EmbeddingConfig == nil || kb.EmbeddingConfig["model"] == nil {
|
||||
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{
|
||||
"embedding_config": models.JSONMap{
|
||||
"model": embedResult.Model,
|
||||
"dimensions": embedResult.Dimensions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Mark KB as active
|
||||
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"})
|
||||
|
||||
log.Printf(" ✓ ingest complete: %s (%d chunks, %s)",
|
||||
doc.Filename, len(chunks), time.Since(start).Round(time.Millisecond))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Text Extraction ──────────────────────────
|
||||
|
||||
// extractText reads the document file and returns its text content.
|
||||
// For text-based files, reads directly from object store.
|
||||
// For complex formats (PDF, docx), returns an error — the extraction
|
||||
// sidecar integration is planned for a future phase.
|
||||
func (ing *Ingester) extractText(ctx context.Context, doc *models.KBDocument) (string, error) {
|
||||
// If extracted text was already stored (e.g. from a rebuild), use it
|
||||
if doc.ExtractedText != nil && *doc.ExtractedText != "" {
|
||||
return *doc.ExtractedText, nil
|
||||
}
|
||||
|
||||
// Text-based files: read directly
|
||||
if textMIMETypes[doc.ContentType] {
|
||||
return ing.readTextFile(ctx, doc.StorageKey)
|
||||
}
|
||||
|
||||
// Complex document types are not yet supported for inline extraction.
|
||||
// The extraction sidecar (v0.12.0) handles attachments but isn't yet
|
||||
// wired into the KB pipeline. Planned for a future phase.
|
||||
return "", fmt.Errorf("unsupported content type %q — text-based files only for now (txt, md, csv, html)", doc.ContentType)
|
||||
}
|
||||
|
||||
// readTextFile reads a text file from the object store and returns its contents.
|
||||
func (ing *Ingester) readTextFile(ctx context.Context, storageKey string) (string, error) {
|
||||
rc, size, _, err := ing.objStore.Get(ctx, storageKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", storageKey, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Guard against absurdly large files
|
||||
if size > MaxTextLength+1024 {
|
||||
// Read up to limit
|
||||
limited := io.LimitReader(rc, MaxTextLength)
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", storageKey, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", storageKey, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
Reference in New Issue
Block a user