Skip to content

Commit e534154

Browse files
gyaanclaude
andcommitted
Add multi-LLM provider support (Anthropic + OpenAI)
Introduce an LLMClient interface so the RAG chain can work with any LLM provider. Add OpenAI Chat Completions client alongside the existing Claude client, selectable via LLM_PROVIDER env var. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 121f0a5 commit e534154

31 files changed

Lines changed: 3066 additions & 0 deletions

.env_example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
ANTHROPIC_API_KEY=your_anthropic_api_key_here
2+
SERVER_PORT=8080
3+
KNOWLEDGE_BASE_PATH=./knowledge_base
4+
CHUNK_SIZE=500
5+
CHUNK_OVERLAP=50
6+
TOP_K=5
7+
CLAUDE_MODEL=claude-sonnet-4-5-20250929
8+
SESSION_TTL_HOURS=24
9+
LLM_PROVIDER=anthropic
10+
OPENAI_API_KEY=
11+
OPENAI_MODEL=gpt-4o

CLAUDE.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
**customer-support-rag** is a production-ready RAG (Retrieval-Augmented Generation) system that serves API endpoints for a frontend to answer customer queries. It uses a knowledge base of FAQs, product docs, and help center articles as context, with Claude as the LLM and a pure Go TF-IDF engine for embeddings with an in-memory vector store for semantic search.
8+
9+
- **Repository**: https://github.com/gyaan/knowledge-pipeline.git
10+
- **License**: MIT
11+
- **Language**: Go 1.25.6 (macOS ARM64)
12+
- **External dependencies**: None (pure standard library)
13+
14+
## Build & Run Commands
15+
16+
```bash
17+
go build ./... # build all packages
18+
go test ./... # run all tests
19+
go test -run TestName ./path/to/package # run a single test
20+
go vet ./... # static analysis
21+
22+
# Dry-run ingestion (loads KB, chunks, builds TF-IDF, prints stats)
23+
go run cmd/ingest/main.go
24+
25+
# Run the API server (ingests KB on startup, serves on :8080)
26+
go run cmd/server/main.go
27+
```
28+
29+
## Architecture
30+
31+
```
32+
Startup: Load docs → Split → Build TF-IDF vocab → Embed chunks → Store in vector DB
33+
Request: POST /api/chat → Embed query → Vector search → Build context → Claude LLM → Response
34+
```
35+
36+
### Entry Points
37+
- **cmd/ingest/** — Dry-run CLI: loads knowledge base, chunks, builds TF-IDF vocab, prints stats (no server)
38+
- **cmd/server/** — Ingests KB at startup, then serves HTTP API with graceful shutdown (SIGINT/SIGTERM)
39+
40+
### Internal Packages (data flow order)
41+
1. **config/** — Loads .env: API key, server port, KB path, chunk settings, model, session TTL
42+
2. **documents/**`loader.go` reads .txt files with category/filename metadata, skips empty; `splitter.go` chunks with overlap
43+
3. **embeddings/**`client.go` defines `Embedder` interface; `server.go` implements pure Go TF-IDF engine
44+
4. **vectordb/**`memory.go` in-memory store (no disk persistence); `search.go` cosine similarity
45+
5. **llm/**`client.go` defines `LLMClient` interface; `claude.go` Anthropic client; `openai.go` OpenAI client
46+
6. **rag/**`chain.go` orchestrates: query → embed → vector search → build prompt → call LLM → return
47+
7. **session/**`manager.go` manages conversation history with configurable TTL, crypto/rand IDs, stoppable cleanup
48+
8. **api/**`server.go` HTTP router with graceful shutdown; `handlers.go` defines ChatHandler + HealthHandler
49+
50+
### Shared Types
51+
- **pkg/models/**`types.go` defines shared structs (Document, ChatRequest/Response, Session, Claude/OpenAI API types)
52+
53+
### API Endpoints
54+
- `POST /api/chat``{"query": "...", "session_id": "..."}``{"session_id": "...", "answer": "...", "sources": [...]}`
55+
- `GET /api/health``{"status": "healthy", "active_sessions": N, "documents": N}`
56+
57+
### Knowledge Base (`knowledge_base/`)
58+
- **faq/** — general, billing, technical, account FAQs
59+
- **product_docs/** — getting started, features, API docs, integrations
60+
- **help_center/** — troubleshooting, best practices, tutorials
61+
62+
## Environment Variables (.env)
63+
64+
```
65+
ANTHROPIC_API_KEY= # Claude API key (required when LLM_PROVIDER=anthropic)
66+
SERVER_PORT=8080 # HTTP server port
67+
KNOWLEDGE_BASE_PATH=./knowledge_base
68+
CHUNK_SIZE=500
69+
CHUNK_OVERLAP=50
70+
TOP_K=5
71+
CLAUDE_MODEL=claude-sonnet-4-5-20250929
72+
SESSION_TTL_HOURS=24
73+
LLM_PROVIDER=anthropic # anthropic or openai
74+
OPENAI_API_KEY= # OpenAI API key (required when LLM_PROVIDER=openai)
75+
OPENAI_MODEL=gpt-4o # OpenAI model name
76+
```
77+
78+
## Conventions
79+
- Zero external dependencies — pure `net/http` + standard library only
80+
- In-memory vector store (no external DB, no disk persistence)
81+
- Pure Go TF-IDF embeddings (no Python, no external embedding service)
82+
- Standard Go project layout: `cmd/`, `internal/`, `pkg/`
83+
- Configuration via `.env` file (gitignored)
84+
- Graceful shutdown with signal handling

cmd/ingest/main.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"log"
6+
7+
"github.com/gyaan/knowledge-pipeline/internal/config"
8+
"github.com/gyaan/knowledge-pipeline/internal/documents"
9+
"github.com/gyaan/knowledge-pipeline/internal/embeddings"
10+
)
11+
12+
func main() {
13+
docsPath := flag.String("docs", "", "Path to documents directory (overrides config)")
14+
flag.Parse()
15+
16+
log.Println("Starting document ingestion (dry run)...")
17+
18+
cfg, err := config.Load()
19+
if err != nil {
20+
log.Fatalf("Failed to load config: %v", err)
21+
}
22+
23+
path := cfg.KnowledgeBasePath
24+
if *docsPath != "" {
25+
path = *docsPath
26+
}
27+
28+
// Load documents
29+
loader := documents.NewLoader()
30+
log.Printf("Loading documents from %s...", path)
31+
docs, err := loader.LoadFromDirectory(path)
32+
if err != nil {
33+
log.Fatalf("Failed to load documents: %v", err)
34+
}
35+
log.Printf("Loaded %d documents", len(docs))
36+
37+
for _, doc := range docs {
38+
log.Printf(" - %s (%s/%s, %d chars)",
39+
doc.Metadata["filename"],
40+
doc.Metadata["category"],
41+
doc.Metadata["type"],
42+
len(doc.Content))
43+
}
44+
45+
// Split into chunks
46+
splitter := documents.NewSplitter(cfg.ChunkSize, cfg.ChunkOverlap)
47+
chunks := splitter.Split(docs)
48+
log.Printf("Created %d chunks (size=%d, overlap=%d)", len(chunks), cfg.ChunkSize, cfg.ChunkOverlap)
49+
50+
// Build TF-IDF vocabulary
51+
tfidf := embeddings.NewTFIDFEngine()
52+
texts := make([]string, len(chunks))
53+
for i, chunk := range chunks {
54+
texts[i] = chunk.Content
55+
}
56+
tfidf.BuildVocabulary(texts)
57+
log.Printf("Built TF-IDF vocabulary: %d unique terms", tfidf.VocabSize())
58+
59+
// Embed all chunks
60+
vecs := tfidf.EmbedBatch(texts)
61+
62+
nonZero := 0
63+
for _, v := range vecs {
64+
for _, val := range v {
65+
if val != 0 {
66+
nonZero++
67+
break
68+
}
69+
}
70+
}
71+
72+
log.Printf("Generated %d embeddings (%d non-zero vectors, dim=%d)",
73+
len(vecs), nonZero, tfidf.VocabSize())
74+
log.Println("Dry run complete. Use cmd/server to run with live ingestion.")
75+
}

cmd/server/main.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log"
6+
"os/signal"
7+
"syscall"
8+
9+
"github.com/gyaan/knowledge-pipeline/internal/api"
10+
"github.com/gyaan/knowledge-pipeline/internal/config"
11+
"github.com/gyaan/knowledge-pipeline/internal/documents"
12+
"github.com/gyaan/knowledge-pipeline/internal/embeddings"
13+
"github.com/gyaan/knowledge-pipeline/internal/llm"
14+
"github.com/gyaan/knowledge-pipeline/internal/rag"
15+
"github.com/gyaan/knowledge-pipeline/internal/session"
16+
"github.com/gyaan/knowledge-pipeline/internal/vectordb"
17+
)
18+
19+
func main() {
20+
log.Println("Starting Customer Support RAG Server...")
21+
22+
// Load configuration
23+
cfg, err := config.Load()
24+
if err != nil {
25+
log.Fatalf("Failed to load config: %v", err)
26+
}
27+
28+
// Validate API key for selected provider
29+
var llmClient llm.LLMClient
30+
switch cfg.LLMProvider {
31+
case "openai":
32+
if cfg.OpenAIAPIKey == "" {
33+
log.Fatal("OPENAI_API_KEY must be set in .env when LLM_PROVIDER=openai")
34+
}
35+
llmClient = llm.NewOpenAIClient(cfg.OpenAIAPIKey, cfg.OpenAIModel)
36+
log.Printf("Using OpenAI provider (model: %s)", cfg.OpenAIModel)
37+
default:
38+
if cfg.AnthropicAPIKey == "" || cfg.AnthropicAPIKey == "your_anthropic_api_key_here" {
39+
log.Fatal("ANTHROPIC_API_KEY must be set in .env when LLM_PROVIDER=anthropic")
40+
}
41+
llmClient = llm.NewClaudeClient(cfg.AnthropicAPIKey, cfg.ClaudeModel)
42+
log.Printf("Using Anthropic provider (model: %s)", cfg.ClaudeModel)
43+
}
44+
45+
// Load documents
46+
log.Printf("Loading knowledge base from %s...", cfg.KnowledgeBasePath)
47+
loader := documents.NewLoader()
48+
docs, err := loader.LoadFromDirectory(cfg.KnowledgeBasePath)
49+
if err != nil {
50+
log.Fatalf("Failed to load documents: %v", err)
51+
}
52+
log.Printf("Loaded %d documents", len(docs))
53+
54+
// Split into chunks
55+
splitter := documents.NewSplitter(cfg.ChunkSize, cfg.ChunkOverlap)
56+
chunks := splitter.Split(docs)
57+
log.Printf("Created %d chunks", len(chunks))
58+
59+
// Build TF-IDF vocabulary and embed chunks
60+
tfidf := embeddings.NewTFIDFEngine()
61+
texts := make([]string, len(chunks))
62+
for i, chunk := range chunks {
63+
texts[i] = chunk.Content
64+
}
65+
tfidf.BuildVocabulary(texts)
66+
log.Printf("Built TF-IDF vocabulary: %d terms", tfidf.VocabSize())
67+
68+
vecs := tfidf.EmbedBatch(texts)
69+
for i := range chunks {
70+
chunks[i].Embedding = vecs[i]
71+
}
72+
73+
// Store in vector database
74+
vectorDB := vectordb.NewMemoryVectorDB()
75+
vectorDB.Add(chunks)
76+
log.Printf("Indexed %d chunks in vector store", vectorDB.Count())
77+
78+
// Initialize remaining components
79+
ragChain := rag.NewChain(tfidf, vectorDB, llmClient, cfg.TopK)
80+
sessionManager := session.NewManager(cfg.SessionTTLHours)
81+
handlers := api.NewHandlers(ragChain, sessionManager, vectorDB)
82+
server := api.NewServer(handlers, cfg.ServerPort)
83+
84+
// Graceful shutdown on SIGINT/SIGTERM
85+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
86+
defer stop()
87+
88+
if err := server.Start(ctx); err != nil {
89+
log.Printf("Server stopped: %v", err)
90+
}
91+
92+
sessionManager.Stop()
93+
log.Println("Shutdown complete")
94+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/gyaan/knowledge-pipeline
2+
3+
go 1.25.6

internal/api/handlers.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"time"
8+
9+
"github.com/gyaan/knowledge-pipeline/internal/rag"
10+
"github.com/gyaan/knowledge-pipeline/internal/session"
11+
"github.com/gyaan/knowledge-pipeline/internal/vectordb"
12+
"github.com/gyaan/knowledge-pipeline/pkg/models"
13+
)
14+
15+
type Handlers struct {
16+
ragChain *rag.Chain
17+
sessionManager *session.Manager
18+
vectorDB *vectordb.MemoryVectorDB
19+
}
20+
21+
func NewHandlers(ragChain *rag.Chain, sessionManager *session.Manager, vectorDB *vectordb.MemoryVectorDB) *Handlers {
22+
return &Handlers{
23+
ragChain: ragChain,
24+
sessionManager: sessionManager,
25+
vectorDB: vectorDB,
26+
}
27+
}
28+
29+
// ChatHandler handles chat requests.
30+
func (h *Handlers) ChatHandler(w http.ResponseWriter, r *http.Request) {
31+
if r.Method != http.MethodPost {
32+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
33+
return
34+
}
35+
36+
var req models.ChatRequest
37+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
38+
http.Error(w, "Invalid request body", http.StatusBadRequest)
39+
return
40+
}
41+
42+
if req.Query == "" {
43+
http.Error(w, "Query is required", http.StatusBadRequest)
44+
return
45+
}
46+
47+
sess := h.sessionManager.GetOrCreate(req.SessionID)
48+
49+
// Add user message to session
50+
userMsg := models.ChatMessage{
51+
Role: "user",
52+
Content: req.Query,
53+
Timestamp: time.Now(),
54+
}
55+
h.sessionManager.AddMessage(sess.ID, userMsg)
56+
57+
// Get chat history (exclude the message we just added)
58+
history := sess.Messages[:len(sess.Messages)-1]
59+
60+
response, err := h.ragChain.Query(r.Context(), req.Query, history)
61+
if err != nil {
62+
http.Error(w, fmt.Sprintf("Error processing query: %v", err), http.StatusInternalServerError)
63+
return
64+
}
65+
66+
// Add assistant message to session
67+
assistantMsg := models.ChatMessage{
68+
Role: "assistant",
69+
Content: response.Answer,
70+
Timestamp: time.Now(),
71+
}
72+
h.sessionManager.AddMessage(sess.ID, assistantMsg)
73+
74+
response.SessionID = sess.ID
75+
76+
w.Header().Set("Content-Type", "application/json")
77+
json.NewEncoder(w).Encode(response)
78+
}
79+
80+
// HealthHandler returns health status.
81+
func (h *Handlers) HealthHandler(w http.ResponseWriter, r *http.Request) {
82+
if r.Method != http.MethodGet {
83+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
84+
return
85+
}
86+
87+
w.Header().Set("Content-Type", "application/json")
88+
json.NewEncoder(w).Encode(map[string]interface{}{
89+
"status": "healthy",
90+
"active_sessions": h.sessionManager.Count(),
91+
"documents": h.vectorDB.Count(),
92+
})
93+
}

0 commit comments

Comments
 (0)