@retrivora-ai/rag-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +77 -0
- package/README.md +149 -0
- package/dist/DocumentChunker-BUrIrcPk.d.mts +43 -0
- package/dist/DocumentChunker-BUrIrcPk.d.ts +43 -0
- package/dist/RAGPipeline-BmkIv1HD.d.mts +298 -0
- package/dist/RAGPipeline-BmkIv1HD.d.ts +298 -0
- package/dist/chunk-NCG2JKXB.mjs +1254 -0
- package/dist/chunk-ZPXLQR5Q.mjs +67 -0
- package/dist/handlers/index.d.mts +68 -0
- package/dist/handlers/index.d.ts +68 -0
- package/dist/handlers/index.js +1319 -0
- package/dist/handlers/index.mjs +13 -0
- package/dist/index.d.mts +93 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +612 -0
- package/dist/index.mjs +551 -0
- package/dist/server.d.mts +211 -0
- package/dist/server.d.ts +211 -0
- package/dist/server.js +1457 -0
- package/dist/server.mjs +148 -0
- package/package.json +90 -0
- package/src/app/api/chat/route.ts +4 -0
- package/src/app/api/health/route.ts +4 -0
- package/src/app/api/ingest/route.ts +26 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +163 -0
- package/src/app/layout.tsx +35 -0
- package/src/app/page.tsx +506 -0
- package/src/components/ChatWidget.tsx +91 -0
- package/src/components/ChatWindow.tsx +248 -0
- package/src/components/ConfigProvider.tsx +56 -0
- package/src/components/MessageBubble.tsx +99 -0
- package/src/components/SourceCard.tsx +66 -0
- package/src/components/ThemeProvider.tsx +8 -0
- package/src/components/ThemeToggle.tsx +29 -0
- package/src/config/RagConfig.ts +159 -0
- package/src/config/UniversalProfiles.ts +83 -0
- package/src/config/serverConfig.ts +142 -0
- package/src/handlers/index.ts +202 -0
- package/src/hooks/useHydrated.ts +13 -0
- package/src/hooks/useRagChat.ts +167 -0
- package/src/hooks/useStoredMessages.ts +53 -0
- package/src/index.ts +27 -0
- package/src/llm/ILLMProvider.ts +60 -0
- package/src/llm/LLMFactory.ts +54 -0
- package/src/llm/providers/AnthropicProvider.ts +87 -0
- package/src/llm/providers/OllamaProvider.ts +102 -0
- package/src/llm/providers/OpenAIProvider.ts +92 -0
- package/src/llm/providers/UniversalLLMAdapter.ts +154 -0
- package/src/rag/DocumentChunker.ts +105 -0
- package/src/rag/RAGPipeline.ts +196 -0
- package/src/server.ts +30 -0
- package/src/types/pdf-parse.d.ts +13 -0
- package/src/utils/DocumentParser.ts +75 -0
- package/src/utils/templateUtils.ts +78 -0
- package/src/vectordb/IVectorDB.ts +75 -0
- package/src/vectordb/VectorDBFactory.ts +41 -0
- package/src/vectordb/adapters/MongoDbAdapter.ts +175 -0
- package/src/vectordb/adapters/PgVectorAdapter.ts +159 -0
- package/src/vectordb/adapters/PineconeAdapter.ts +115 -0
- package/src/vectordb/adapters/UniversalVectorDBAdapter.ts +177 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic LLM Provider interface.
|
|
3
|
+
* Covers both chat completion and embedding generation so a single
|
|
4
|
+
* provider can handle both responsibilities when appropriate.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ChatMessage {
|
|
8
|
+
role: 'user' | 'assistant' | 'system';
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChatOptions {
|
|
13
|
+
/** Override max tokens for this specific call */
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
/** Override temperature for this specific call */
|
|
16
|
+
temperature?: number;
|
|
17
|
+
/** Stop sequences */
|
|
18
|
+
stop?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EmbedOptions {
|
|
22
|
+
/** Override model for this specific embed call */
|
|
23
|
+
model?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ILLMProvider {
|
|
27
|
+
/**
|
|
28
|
+
* Send a chat completion request.
|
|
29
|
+
* @param messages – the full conversation history
|
|
30
|
+
* @param context – retrieved RAG context to inject
|
|
31
|
+
* @param options – optional per-call overrides
|
|
32
|
+
* @returns – the assistant's reply text
|
|
33
|
+
*/
|
|
34
|
+
chat(
|
|
35
|
+
messages: ChatMessage[],
|
|
36
|
+
context: string,
|
|
37
|
+
options?: ChatOptions
|
|
38
|
+
): Promise<string>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate an embedding vector for the given text.
|
|
42
|
+
* @param text – text to embed
|
|
43
|
+
* @param options – optional overrides
|
|
44
|
+
* @returns – float array (the embedding)
|
|
45
|
+
*/
|
|
46
|
+
embed(text: string, options?: EmbedOptions): Promise<number[]>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate embedding vectors for multiple texts in a single batch.
|
|
50
|
+
* @param texts – array of texts to embed
|
|
51
|
+
* @param options – optional overrides
|
|
52
|
+
* @returns – array of float arrays
|
|
53
|
+
*/
|
|
54
|
+
batchEmbed(texts: string[], options?: EmbedOptions): Promise<number[][]>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if the provider endpoint is reachable.
|
|
58
|
+
*/
|
|
59
|
+
ping(): Promise<boolean>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLMFactory — instantiates the correct ILLMProvider based on llmConfig.provider.
|
|
3
|
+
*
|
|
4
|
+
* Also accepts an optional EmbeddingConfig so providers that support embedding
|
|
5
|
+
* can be initialised with separate embedding credentials.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ILLMProvider } from './ILLMProvider';
|
|
9
|
+
import { LLMConfig, EmbeddingConfig, LLMProvider } from '../config/RagConfig';
|
|
10
|
+
import { OpenAIProvider } from './providers/OpenAIProvider';
|
|
11
|
+
import { AnthropicProvider } from './providers/AnthropicProvider';
|
|
12
|
+
import { OllamaProvider } from './providers/OllamaProvider';
|
|
13
|
+
import { UniversalLLMAdapter } from './providers/UniversalLLMAdapter';
|
|
14
|
+
|
|
15
|
+
export class LLMFactory {
|
|
16
|
+
static create(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig): ILLMProvider {
|
|
17
|
+
switch (llmConfig.provider) {
|
|
18
|
+
case 'openai':
|
|
19
|
+
return new OpenAIProvider(llmConfig, embeddingConfig);
|
|
20
|
+
case 'anthropic':
|
|
21
|
+
return new AnthropicProvider(llmConfig, embeddingConfig);
|
|
22
|
+
case 'ollama':
|
|
23
|
+
return new OllamaProvider(llmConfig, embeddingConfig);
|
|
24
|
+
case 'rest':
|
|
25
|
+
case 'universal_rest':
|
|
26
|
+
case 'custom':
|
|
27
|
+
return new UniversalLLMAdapter(llmConfig);
|
|
28
|
+
default:
|
|
29
|
+
// If baseUrl is provided but provider is unknown, fallback to Universal Adapter
|
|
30
|
+
if (llmConfig.baseUrl || (llmConfig.options as Record<string, unknown>)?.baseUrl) {
|
|
31
|
+
return new UniversalLLMAdapter(llmConfig);
|
|
32
|
+
}
|
|
33
|
+
throw new Error(
|
|
34
|
+
`[LLMFactory] Unknown provider "${llmConfig.provider}". ` +
|
|
35
|
+
`Supported: openai | anthropic | ollama | rest | custom`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a dedicated embedding-only provider.
|
|
42
|
+
* Useful when the LLM provider (e.g. Anthropic) doesn't support embeddings.
|
|
43
|
+
*/
|
|
44
|
+
static createEmbeddingProvider(embeddingConfig: EmbeddingConfig): ILLMProvider {
|
|
45
|
+
const fakeLLMConfig: LLMConfig = {
|
|
46
|
+
provider: embeddingConfig.provider as LLMProvider,
|
|
47
|
+
model: embeddingConfig.model,
|
|
48
|
+
apiKey: embeddingConfig.apiKey,
|
|
49
|
+
baseUrl: embeddingConfig.baseUrl,
|
|
50
|
+
options: embeddingConfig.options,
|
|
51
|
+
};
|
|
52
|
+
return LLMFactory.create(fakeLLMConfig, embeddingConfig);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic (Claude) LLM Provider
|
|
3
|
+
*
|
|
4
|
+
* Handles chat completion via Anthropic's Messages API.
|
|
5
|
+
* Note: Anthropic does NOT provide embedding models — use OpenAIProvider
|
|
6
|
+
* or OllamaProvider for embedding when pairing with this provider.
|
|
7
|
+
*
|
|
8
|
+
* Required LLMConfig fields:
|
|
9
|
+
* - apiKey: string – Anthropic API key
|
|
10
|
+
* - model: string – e.g. "claude-3-5-sonnet-20241022"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
14
|
+
import { ILLMProvider, ChatMessage, ChatOptions, EmbedOptions } from '../ILLMProvider';
|
|
15
|
+
import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
|
|
16
|
+
|
|
17
|
+
export class AnthropicProvider implements ILLMProvider {
|
|
18
|
+
private readonly client: Anthropic;
|
|
19
|
+
private readonly llmConfig: LLMConfig;
|
|
20
|
+
private readonly embeddingConfig?: EmbeddingConfig;
|
|
21
|
+
|
|
22
|
+
constructor(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig) {
|
|
23
|
+
if (!llmConfig.apiKey) throw new Error('[AnthropicProvider] llmConfig.apiKey is required');
|
|
24
|
+
this.client = new Anthropic({ apiKey: llmConfig.apiKey });
|
|
25
|
+
this.llmConfig = llmConfig;
|
|
26
|
+
this.embeddingConfig = embeddingConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
|
|
30
|
+
const systemPrompt =
|
|
31
|
+
this.llmConfig.systemPrompt ??
|
|
32
|
+
`You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
|
|
33
|
+
|
|
34
|
+
const system = systemPrompt.includes('{{context}}')
|
|
35
|
+
? systemPrompt.replace('{{context}}', context)
|
|
36
|
+
: `${systemPrompt}\n\nContext:\n${context}`;
|
|
37
|
+
|
|
38
|
+
// Anthropic requires alternating user/assistant messages
|
|
39
|
+
const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
|
|
40
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
41
|
+
content: m.content,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const response = await this.client.messages.create({
|
|
45
|
+
model: this.llmConfig.model,
|
|
46
|
+
max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
|
|
47
|
+
system,
|
|
48
|
+
messages: anthropicMessages,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const block = response.content[0];
|
|
52
|
+
return block.type === 'text' ? block.text : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Anthropic does not offer an embedding API.
|
|
57
|
+
* This method throws with a clear error so developers know to configure
|
|
58
|
+
* a separate embedding provider (OpenAI or Ollama).
|
|
59
|
+
*/
|
|
60
|
+
async embed(text: string, options?: EmbedOptions): Promise<number[]> {
|
|
61
|
+
void text;
|
|
62
|
+
void options;
|
|
63
|
+
throw new Error(
|
|
64
|
+
'[AnthropicProvider] Anthropic does not provide an embedding API. ' +
|
|
65
|
+
'Set embedding.provider to "openai" or "ollama" in your RagConfig.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async batchEmbed(texts: string[], options?: EmbedOptions): Promise<number[][]> {
|
|
70
|
+
void texts;
|
|
71
|
+
void options;
|
|
72
|
+
throw new Error(
|
|
73
|
+
'[AnthropicProvider] Anthropic does not provide an embedding API.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async ping(): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
// Lightweight model list call to verify connectivity
|
|
80
|
+
await this.client.models.list();
|
|
81
|
+
return true;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[AnthropicProvider] Ping failed:', err);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama LLM Provider (local / self-hosted)
|
|
3
|
+
*
|
|
4
|
+
* Communicates with an Ollama server via its REST API.
|
|
5
|
+
* Supports both chat completion and embedding (requires a model with embed support,
|
|
6
|
+
* e.g. "nomic-embed-text", "mxbai-embed-large").
|
|
7
|
+
*
|
|
8
|
+
* Required LLMConfig fields:
|
|
9
|
+
* - model: string – e.g. "llama3", "mistral", "gemma2"
|
|
10
|
+
* - baseUrl?: string – Ollama server URL (default "http://localhost:11434")
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import axios, { AxiosInstance } from 'axios';
|
|
14
|
+
import { ILLMProvider, ChatMessage, ChatOptions, EmbedOptions } from '../ILLMProvider';
|
|
15
|
+
import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
|
|
16
|
+
|
|
17
|
+
interface OllamaChatResponse {
|
|
18
|
+
message: { content: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OllamaEmbedResponse {
|
|
22
|
+
embedding: number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class OllamaProvider implements ILLMProvider {
|
|
26
|
+
private readonly http: AxiosInstance;
|
|
27
|
+
private readonly llmConfig: LLMConfig;
|
|
28
|
+
private readonly embeddingConfig?: EmbeddingConfig;
|
|
29
|
+
|
|
30
|
+
constructor(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig) {
|
|
31
|
+
const baseURL = llmConfig.baseUrl ?? 'http://localhost:11434';
|
|
32
|
+
this.http = axios.create({ baseURL, timeout: 120_000 });
|
|
33
|
+
this.llmConfig = llmConfig;
|
|
34
|
+
this.embeddingConfig = embeddingConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
|
|
38
|
+
const systemPrompt =
|
|
39
|
+
this.llmConfig.systemPrompt ??
|
|
40
|
+
`You are a helpful assistant. Use the provided context to answer the user's question.\n\nContext:\n${context}`;
|
|
41
|
+
|
|
42
|
+
const system = systemPrompt.includes('{{context}}')
|
|
43
|
+
? systemPrompt.replace('{{context}}', context)
|
|
44
|
+
: `${systemPrompt}\n\nContext:\n${context}`;
|
|
45
|
+
|
|
46
|
+
const { data } = await this.http.post<OllamaChatResponse>('/api/chat', {
|
|
47
|
+
model: this.llmConfig.model,
|
|
48
|
+
stream: false,
|
|
49
|
+
options: {
|
|
50
|
+
temperature: options?.temperature ?? this.llmConfig.temperature ?? 0.7,
|
|
51
|
+
num_predict: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
|
|
52
|
+
},
|
|
53
|
+
messages: [
|
|
54
|
+
{ role: 'system', content: system },
|
|
55
|
+
...messages.map((m) => ({ role: m.role, content: m.content })),
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return data.message.content;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async embed(text: string, options?: EmbedOptions): Promise<number[]> {
|
|
63
|
+
const model =
|
|
64
|
+
options?.model ??
|
|
65
|
+
this.embeddingConfig?.model ??
|
|
66
|
+
'nomic-embed-text';
|
|
67
|
+
|
|
68
|
+
const baseURL =
|
|
69
|
+
this.embeddingConfig?.baseUrl ?? this.llmConfig.baseUrl ?? 'http://localhost:11434';
|
|
70
|
+
|
|
71
|
+
const client =
|
|
72
|
+
baseURL !== (this.llmConfig.baseUrl ?? 'http://localhost:11434')
|
|
73
|
+
? axios.create({ baseURL, timeout: 60_000 })
|
|
74
|
+
: this.http;
|
|
75
|
+
|
|
76
|
+
const { data } = await client.post<OllamaEmbedResponse>('/api/embeddings', {
|
|
77
|
+
model,
|
|
78
|
+
prompt: text,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return data.embedding;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async batchEmbed(texts: string[], options?: EmbedOptions): Promise<number[][]> {
|
|
85
|
+
const vectors: number[][] = [];
|
|
86
|
+
// Sequential fallback for Ollama (batch support varies by version)
|
|
87
|
+
for (const text of texts) {
|
|
88
|
+
vectors.push(await this.embed(text, options));
|
|
89
|
+
}
|
|
90
|
+
return vectors;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async ping(): Promise<boolean> {
|
|
94
|
+
try {
|
|
95
|
+
await this.http.get('/api/tags');
|
|
96
|
+
return true;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[OllamaProvider] Ping failed:', err);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI LLM Provider
|
|
3
|
+
*
|
|
4
|
+
* Handles both chat completion (GPT-4o, GPT-4-turbo, etc.) and
|
|
5
|
+
* embedding (text-embedding-ada-002, text-embedding-3-small/large).
|
|
6
|
+
*
|
|
7
|
+
* Required LLMConfig fields:
|
|
8
|
+
* - apiKey: string – OpenAI API key
|
|
9
|
+
* - model: string – e.g. "gpt-4o"
|
|
10
|
+
*
|
|
11
|
+
* Required EmbeddingConfig fields (when used for embedding):
|
|
12
|
+
* - apiKey: string – same or separate key
|
|
13
|
+
* - model: string – e.g. "text-embedding-3-small"
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import OpenAI from 'openai';
|
|
17
|
+
import { ILLMProvider, ChatMessage, ChatOptions, EmbedOptions } from '../ILLMProvider';
|
|
18
|
+
import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
|
|
19
|
+
|
|
20
|
+
export class OpenAIProvider implements ILLMProvider {
|
|
21
|
+
private readonly client: OpenAI;
|
|
22
|
+
private readonly llmConfig: LLMConfig;
|
|
23
|
+
private readonly embeddingConfig?: EmbeddingConfig;
|
|
24
|
+
|
|
25
|
+
constructor(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig) {
|
|
26
|
+
if (!llmConfig.apiKey) throw new Error('[OpenAIProvider] llmConfig.apiKey is required');
|
|
27
|
+
this.client = new OpenAI({ apiKey: llmConfig.apiKey });
|
|
28
|
+
this.llmConfig = llmConfig;
|
|
29
|
+
this.embeddingConfig = embeddingConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
|
|
33
|
+
const systemContent =
|
|
34
|
+
this.llmConfig.systemPrompt ??
|
|
35
|
+
`You are a helpful assistant. Answer questions based on the provided context.\n\nContext:\n${context}`;
|
|
36
|
+
|
|
37
|
+
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
|
38
|
+
role: 'system',
|
|
39
|
+
content: systemContent.includes('{{context}}')
|
|
40
|
+
? systemContent.replace('{{context}}', context)
|
|
41
|
+
: `${systemContent}\n\nContext:\n${context}`,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const formattedMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
45
|
+
systemMessage,
|
|
46
|
+
...messages.map((m) => ({
|
|
47
|
+
role: m.role as 'user' | 'assistant',
|
|
48
|
+
content: m.content,
|
|
49
|
+
})),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const completion = await this.client.chat.completions.create({
|
|
53
|
+
model: this.llmConfig.model,
|
|
54
|
+
messages: formattedMessages,
|
|
55
|
+
max_tokens: options?.maxTokens ?? this.llmConfig.maxTokens ?? 1024,
|
|
56
|
+
temperature: options?.temperature ?? this.llmConfig.temperature ?? 0.7,
|
|
57
|
+
stop: options?.stop,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return completion.choices[0]?.message?.content ?? '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async embed(text: string, options?: EmbedOptions): Promise<number[]> {
|
|
64
|
+
const results = await this.batchEmbed([text], options);
|
|
65
|
+
return results[0];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async batchEmbed(texts: string[], options?: EmbedOptions): Promise<number[][]> {
|
|
69
|
+
const model =
|
|
70
|
+
options?.model ??
|
|
71
|
+
this.embeddingConfig?.model ??
|
|
72
|
+
'text-embedding-3-small';
|
|
73
|
+
|
|
74
|
+
const apiKey = this.embeddingConfig?.apiKey ?? this.llmConfig.apiKey;
|
|
75
|
+
const client = apiKey !== this.llmConfig.apiKey
|
|
76
|
+
? new OpenAI({ apiKey })
|
|
77
|
+
: this.client;
|
|
78
|
+
|
|
79
|
+
const response = await client.embeddings.create({ model, input: texts });
|
|
80
|
+
return response.data.map((d) => d.embedding);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async ping(): Promise<boolean> {
|
|
84
|
+
try {
|
|
85
|
+
await this.client.models.list();
|
|
86
|
+
return true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('[OpenAIProvider] Ping failed:', err);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
import { ILLMProvider, ChatMessage } from '../ILLMProvider';
|
|
3
|
+
import { LLMConfig, EmbeddingConfig } from '../../config/RagConfig';
|
|
4
|
+
import { resolvePath, buildPayload } from '../../utils/templateUtils';
|
|
5
|
+
import { LLM_PROFILES } from '../../config/UniversalProfiles';
|
|
6
|
+
|
|
7
|
+
interface UniversalLLMOptions {
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
chatPath?: string;
|
|
11
|
+
embedPath?: string;
|
|
12
|
+
pingPath?: string;
|
|
13
|
+
chatPayloadTemplate?: string;
|
|
14
|
+
embedPayloadTemplate?: string;
|
|
15
|
+
responseExtractPath?: string;
|
|
16
|
+
embedExtractPath?: string;
|
|
17
|
+
timeout?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class UniversalLLMAdapter implements ILLMProvider {
|
|
21
|
+
private readonly http: AxiosInstance;
|
|
22
|
+
private readonly model: string;
|
|
23
|
+
private readonly opts: UniversalLLMOptions;
|
|
24
|
+
private readonly systemPrompt: string;
|
|
25
|
+
private readonly maxTokens: number;
|
|
26
|
+
private readonly temperature: number;
|
|
27
|
+
|
|
28
|
+
constructor(config: LLMConfig | EmbeddingConfig) {
|
|
29
|
+
this.model = config.model;
|
|
30
|
+
|
|
31
|
+
// Type narrow to extract LLM-specific fields safely
|
|
32
|
+
const llmConfig = config as LLMConfig;
|
|
33
|
+
|
|
34
|
+
// Merge profile defaults if specified in options
|
|
35
|
+
const options = (llmConfig.options as Record<string, unknown>) ?? {};
|
|
36
|
+
let profile: Record<string, unknown> = {};
|
|
37
|
+
|
|
38
|
+
if (typeof options.profile === 'string') {
|
|
39
|
+
profile = (LLM_PROFILES as Record<string, Record<string, unknown>>)[options.profile] || {};
|
|
40
|
+
} else if (typeof options.profile === 'object' && options.profile !== null) {
|
|
41
|
+
profile = options.profile as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.opts = { ...profile, ...(llmConfig.options ?? {}) } as UniversalLLMOptions;
|
|
45
|
+
this.systemPrompt = llmConfig.systemPrompt ?? 'You are a helpful AI assistant. Use the provided context to answer the user.';
|
|
46
|
+
this.maxTokens = llmConfig.maxTokens ?? 1024;
|
|
47
|
+
this.temperature = llmConfig.temperature ?? 0;
|
|
48
|
+
|
|
49
|
+
const baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl;
|
|
50
|
+
if (!baseUrl) {
|
|
51
|
+
throw new Error('[UniversalLLMAdapter] baseUrl is required in config or config.options');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.http = axios.create({
|
|
55
|
+
baseURL: baseUrl,
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
59
|
+
...(this.opts.headers || {}),
|
|
60
|
+
},
|
|
61
|
+
timeout: this.opts.timeout ?? 60_000,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async chat(messages: ChatMessage[], context?: string): Promise<string> {
|
|
66
|
+
const path = this.opts.chatPath ?? '/chat/completions';
|
|
67
|
+
|
|
68
|
+
const formattedMessages = [
|
|
69
|
+
{ role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
|
|
70
|
+
...messages,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
let payload: unknown;
|
|
74
|
+
if (this.opts.chatPayloadTemplate) {
|
|
75
|
+
// The user wants to map messages and parameters to their unique JSON shape
|
|
76
|
+
payload = buildPayload(this.opts.chatPayloadTemplate, {
|
|
77
|
+
model: this.model,
|
|
78
|
+
messages: formattedMessages,
|
|
79
|
+
maxTokens: this.maxTokens,
|
|
80
|
+
temperature: this.temperature,
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
// Fallback to OpenAI standard which covers 90% of open-source models (vLLM, LMStudio, Ollama, etc.)
|
|
84
|
+
payload = {
|
|
85
|
+
model: this.model,
|
|
86
|
+
messages: formattedMessages,
|
|
87
|
+
max_tokens: this.maxTokens,
|
|
88
|
+
temperature: this.temperature,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { data } = await this.http.post(path, payload);
|
|
93
|
+
|
|
94
|
+
const extractPath = this.opts.responseExtractPath ?? 'choices[0].message.content';
|
|
95
|
+
const result = resolvePath(data, extractPath);
|
|
96
|
+
|
|
97
|
+
if (result === undefined) {
|
|
98
|
+
throw new Error(`[UniversalLLMAdapter] Could not extract text from path '${extractPath}' in response.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return String(result);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async embed(text: string): Promise<number[]> {
|
|
105
|
+
const path = this.opts.embedPath ?? '/embeddings';
|
|
106
|
+
|
|
107
|
+
let payload: unknown;
|
|
108
|
+
if (this.opts.embedPayloadTemplate) {
|
|
109
|
+
payload = buildPayload(this.opts.embedPayloadTemplate, {
|
|
110
|
+
model: this.model,
|
|
111
|
+
input: text,
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
// OpenAI standard fallback
|
|
115
|
+
payload = {
|
|
116
|
+
model: this.model,
|
|
117
|
+
input: text,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { data } = await this.http.post(path, payload);
|
|
122
|
+
|
|
123
|
+
const extractPath = this.opts.embedExtractPath ?? 'data[0].embedding';
|
|
124
|
+
const vector = resolvePath(data, extractPath);
|
|
125
|
+
|
|
126
|
+
if (!Array.isArray(vector)) {
|
|
127
|
+
throw new Error(`[UniversalLLMAdapter] Expected a number array at '${extractPath}' for embeddings.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return vector;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async batchEmbed(texts: string[]): Promise<number[][]> {
|
|
134
|
+
const vectors: number[][] = [];
|
|
135
|
+
// Sequential fallback if the provider doesn't have a specific batch template
|
|
136
|
+
// In production, we should add a batchEmbedPayloadTemplate to UniversalLLMOptions
|
|
137
|
+
for (const text of texts) {
|
|
138
|
+
vectors.push(await this.embed(text));
|
|
139
|
+
}
|
|
140
|
+
return vectors;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async ping(): Promise<boolean> {
|
|
144
|
+
try {
|
|
145
|
+
if (this.opts.pingPath) {
|
|
146
|
+
await this.http.get(this.opts.pingPath);
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error('[UniversalLLMAdapter] Ping failed:', err);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentChunker — splits long text into overlapping chunks
|
|
3
|
+
* suitable for vector embedding and retrieval.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: sentence-aware sliding window
|
|
6
|
+
* 1. Split on sentence boundaries
|
|
7
|
+
* 2. Accumulate sentences into chunks up to `chunkSize` characters
|
|
8
|
+
* 3. Carry over `chunkOverlap` characters from the previous chunk
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Chunk {
|
|
12
|
+
id: string;
|
|
13
|
+
content: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChunkOptions {
|
|
18
|
+
/** Target chunk size in characters (default 1000) */
|
|
19
|
+
chunkSize?: number;
|
|
20
|
+
/** Overlap between consecutive chunks in characters (default 200) */
|
|
21
|
+
chunkOverlap?: number;
|
|
22
|
+
/** Source document identifier used as ID prefix */
|
|
23
|
+
docId?: string;
|
|
24
|
+
/** Extra metadata to attach to every chunk */
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DocumentChunker {
|
|
29
|
+
private readonly chunkSize: number;
|
|
30
|
+
private readonly chunkOverlap: number;
|
|
31
|
+
|
|
32
|
+
constructor(chunkSize = 1000, chunkOverlap = 200) {
|
|
33
|
+
this.chunkSize = chunkSize;
|
|
34
|
+
this.chunkOverlap = chunkOverlap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split a single text string into overlapping chunks.
|
|
39
|
+
*/
|
|
40
|
+
chunk(text: string, options: ChunkOptions = {}): Chunk[] {
|
|
41
|
+
const {
|
|
42
|
+
chunkSize = this.chunkSize,
|
|
43
|
+
chunkOverlap = this.chunkOverlap,
|
|
44
|
+
docId = `doc_${Date.now()}`,
|
|
45
|
+
metadata = {},
|
|
46
|
+
} = options;
|
|
47
|
+
|
|
48
|
+
// Normalise whitespace
|
|
49
|
+
const cleaned = text.replace(/\r\n/g, '\n').trim();
|
|
50
|
+
if (!cleaned) return [];
|
|
51
|
+
|
|
52
|
+
// Split into sentences (handles . ! ? followed by space/newline)
|
|
53
|
+
const sentences = cleaned
|
|
54
|
+
.split(/(?<=[.!?])\s+|\n{2,}/)
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
const chunks: Chunk[] = [];
|
|
59
|
+
let current = '';
|
|
60
|
+
let chunkIndex = 0;
|
|
61
|
+
|
|
62
|
+
for (const sentence of sentences) {
|
|
63
|
+
if ((current + ' ' + sentence).trim().length <= chunkSize) {
|
|
64
|
+
current = current ? `${current} ${sentence}` : sentence;
|
|
65
|
+
} else {
|
|
66
|
+
if (current) {
|
|
67
|
+
chunks.push({
|
|
68
|
+
id: `${docId}_chunk_${chunkIndex++}`,
|
|
69
|
+
content: current.trim(),
|
|
70
|
+
metadata: { ...metadata, docId, chunkIndex: chunkIndex - 1 },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Start new chunk with overlap from the previous one
|
|
75
|
+
if (chunkOverlap > 0 && current.length > chunkOverlap) {
|
|
76
|
+
current = current.slice(-chunkOverlap) + ' ' + sentence;
|
|
77
|
+
} else {
|
|
78
|
+
current = sentence;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Push the last remaining chunk
|
|
84
|
+
if (current.trim()) {
|
|
85
|
+
chunks.push({
|
|
86
|
+
id: `${docId}_chunk_${chunkIndex}`,
|
|
87
|
+
content: current.trim(),
|
|
88
|
+
metadata: { ...metadata, docId, chunkIndex },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return chunks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Chunk multiple documents at once.
|
|
97
|
+
*/
|
|
98
|
+
chunkMany(
|
|
99
|
+
documents: Array<{ content: string; docId?: string; metadata?: Record<string, unknown> }>
|
|
100
|
+
): Chunk[] {
|
|
101
|
+
return documents.flatMap((doc) =>
|
|
102
|
+
this.chunk(doc.content, { docId: doc.docId, metadata: doc.metadata })
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|