unrag 0.1.1
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/README.md +47 -0
- package/dist/cli/index.js +535 -0
- package/package.json +39 -0
- package/registry/config/unrag.config.ts +32 -0
- package/registry/core/chunking.ts +62 -0
- package/registry/core/config.ts +27 -0
- package/registry/core/context-engine.ts +34 -0
- package/registry/core/index.ts +7 -0
- package/registry/core/ingest.ts +79 -0
- package/registry/core/retrieve.ts +48 -0
- package/registry/core/types.ts +111 -0
- package/registry/docs/unrag.md +82 -0
- package/registry/embedding/ai.ts +42 -0
- package/registry/store/drizzle-postgres-pgvector/index.ts +4 -0
- package/registry/store/drizzle-postgres-pgvector/schema.ts +63 -0
- package/registry/store/drizzle-postgres-pgvector/store.ts +145 -0
- package/registry/store/prisma-postgres-pgvector/index.ts +3 -0
- package/registry/store/prisma-postgres-pgvector/store.ts +133 -0
- package/registry/store/raw-sql-postgres-pgvector/index.ts +3 -0
- package/registry/store/raw-sql-postgres-pgvector/store.ts +154 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Chunker, ChunkingOptions, ChunkText } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CHUNK_SIZE = 200;
|
|
4
|
+
const DEFAULT_CHUNK_OVERLAP = 40;
|
|
5
|
+
|
|
6
|
+
export const defaultChunkingOptions: ChunkingOptions = {
|
|
7
|
+
chunkSize: DEFAULT_CHUNK_SIZE,
|
|
8
|
+
chunkOverlap: DEFAULT_CHUNK_OVERLAP,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const splitWords = (content: string) =>
|
|
12
|
+
content
|
|
13
|
+
.trim()
|
|
14
|
+
.split(/\s+/)
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
|
|
17
|
+
export const defaultChunker: Chunker = (
|
|
18
|
+
content: string,
|
|
19
|
+
options: ChunkingOptions
|
|
20
|
+
): ChunkText[] => {
|
|
21
|
+
const { chunkSize, chunkOverlap } = options;
|
|
22
|
+
const words = splitWords(content);
|
|
23
|
+
const chunks: ChunkText[] = [];
|
|
24
|
+
|
|
25
|
+
if (words.length === 0) {
|
|
26
|
+
return chunks;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let cursor = 0;
|
|
30
|
+
let index = 0;
|
|
31
|
+
|
|
32
|
+
const stride = Math.max(1, chunkSize - chunkOverlap);
|
|
33
|
+
|
|
34
|
+
while (cursor < words.length) {
|
|
35
|
+
const slice = words.slice(cursor, cursor + chunkSize);
|
|
36
|
+
const chunkContent = slice.join(" ").trim();
|
|
37
|
+
|
|
38
|
+
if (chunkContent.length === 0) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
chunks.push({
|
|
43
|
+
index,
|
|
44
|
+
content: chunkContent,
|
|
45
|
+
tokenCount: slice.length,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
cursor += stride;
|
|
49
|
+
index += 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return chunks;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const resolveChunkingOptions = (
|
|
56
|
+
overrides?: Partial<ChunkingOptions>
|
|
57
|
+
): ChunkingOptions => ({
|
|
58
|
+
...defaultChunkingOptions,
|
|
59
|
+
...overrides,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Chunker,
|
|
3
|
+
ContextEngineConfig,
|
|
4
|
+
ResolvedContextEngineConfig,
|
|
5
|
+
} from "./types";
|
|
6
|
+
import { defaultChunker, resolveChunkingOptions } from "./chunking";
|
|
7
|
+
|
|
8
|
+
export const defineConfig = (config: ContextEngineConfig): ContextEngineConfig =>
|
|
9
|
+
config;
|
|
10
|
+
|
|
11
|
+
const defaultIdGenerator = () => crypto.randomUUID();
|
|
12
|
+
|
|
13
|
+
export const resolveConfig = (
|
|
14
|
+
config: ContextEngineConfig
|
|
15
|
+
): ResolvedContextEngineConfig => {
|
|
16
|
+
const chunker: Chunker = config.chunker ?? defaultChunker;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
embedding: config.embedding,
|
|
20
|
+
store: config.store,
|
|
21
|
+
defaults: resolveChunkingOptions(config.defaults),
|
|
22
|
+
chunker,
|
|
23
|
+
idGenerator: config.idGenerator ?? defaultIdGenerator,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ingest } from "./ingest";
|
|
2
|
+
import { retrieve } from "./retrieve";
|
|
3
|
+
import { defineConfig, resolveConfig } from "./config";
|
|
4
|
+
import type {
|
|
5
|
+
ContextEngineConfig,
|
|
6
|
+
IngestInput,
|
|
7
|
+
IngestResult,
|
|
8
|
+
ResolvedContextEngineConfig,
|
|
9
|
+
RetrieveInput,
|
|
10
|
+
RetrieveResult,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
export class ContextEngine {
|
|
14
|
+
private readonly config: ResolvedContextEngineConfig;
|
|
15
|
+
|
|
16
|
+
constructor(config: ContextEngineConfig) {
|
|
17
|
+
this.config = resolveConfig(config);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async ingest(input: IngestInput): Promise<IngestResult> {
|
|
21
|
+
return ingest(this.config, input);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async retrieve(input: RetrieveInput): Promise<RetrieveResult> {
|
|
25
|
+
return retrieve(this.config, input);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const createContextEngine = (config: ContextEngineConfig) =>
|
|
30
|
+
new ContextEngine(config);
|
|
31
|
+
|
|
32
|
+
export { defineConfig };
|
|
33
|
+
|
|
34
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Chunk,
|
|
3
|
+
IngestInput,
|
|
4
|
+
IngestResult,
|
|
5
|
+
ResolvedContextEngineConfig,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
const now = () => performance.now();
|
|
9
|
+
|
|
10
|
+
export const ingest = async (
|
|
11
|
+
config: ResolvedContextEngineConfig,
|
|
12
|
+
input: IngestInput
|
|
13
|
+
): Promise<IngestResult> => {
|
|
14
|
+
const totalStart = now();
|
|
15
|
+
const chunkingStart = now();
|
|
16
|
+
|
|
17
|
+
const chunkingOptions = {
|
|
18
|
+
...config.defaults,
|
|
19
|
+
...input.chunking,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const metadata = input.metadata ?? {};
|
|
23
|
+
const documentId = config.idGenerator();
|
|
24
|
+
|
|
25
|
+
const chunks = config.chunker(input.content, chunkingOptions).map<Chunk>(
|
|
26
|
+
(chunk) => ({
|
|
27
|
+
id: config.idGenerator(),
|
|
28
|
+
documentId,
|
|
29
|
+
sourceId: input.sourceId,
|
|
30
|
+
index: chunk.index,
|
|
31
|
+
content: chunk.content,
|
|
32
|
+
tokenCount: chunk.tokenCount,
|
|
33
|
+
metadata,
|
|
34
|
+
documentContent: input.content,
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const chunkingMs = now() - chunkingStart;
|
|
39
|
+
const embeddingStart = now();
|
|
40
|
+
|
|
41
|
+
const embeddedChunks = await Promise.all(
|
|
42
|
+
chunks.map(async (chunk) => {
|
|
43
|
+
const embedding = await config.embedding.embed({
|
|
44
|
+
text: chunk.content,
|
|
45
|
+
metadata,
|
|
46
|
+
position: chunk.index,
|
|
47
|
+
sourceId: chunk.sourceId,
|
|
48
|
+
documentId: chunk.documentId,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...chunk,
|
|
53
|
+
embedding,
|
|
54
|
+
};
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const embeddingMs = now() - embeddingStart;
|
|
59
|
+
const storageStart = now();
|
|
60
|
+
|
|
61
|
+
await config.store.upsert(embeddedChunks);
|
|
62
|
+
|
|
63
|
+
const storageMs = now() - storageStart;
|
|
64
|
+
const totalMs = now() - totalStart;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
documentId,
|
|
68
|
+
chunkCount: embeddedChunks.length,
|
|
69
|
+
embeddingModel: config.embedding.name,
|
|
70
|
+
durations: {
|
|
71
|
+
totalMs,
|
|
72
|
+
chunkingMs,
|
|
73
|
+
embeddingMs,
|
|
74
|
+
storageMs,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RetrieveInput,
|
|
3
|
+
RetrieveResult,
|
|
4
|
+
ResolvedContextEngineConfig,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
const now = () => performance.now();
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TOP_K = 8;
|
|
10
|
+
|
|
11
|
+
export const retrieve = async (
|
|
12
|
+
config: ResolvedContextEngineConfig,
|
|
13
|
+
input: RetrieveInput
|
|
14
|
+
): Promise<RetrieveResult> => {
|
|
15
|
+
const totalStart = now();
|
|
16
|
+
|
|
17
|
+
const embeddingStart = now();
|
|
18
|
+
const queryEmbedding = await config.embedding.embed({
|
|
19
|
+
text: input.query,
|
|
20
|
+
metadata: {},
|
|
21
|
+
position: 0,
|
|
22
|
+
sourceId: "query",
|
|
23
|
+
documentId: "query",
|
|
24
|
+
});
|
|
25
|
+
const embeddingMs = now() - embeddingStart;
|
|
26
|
+
|
|
27
|
+
const retrievalStart = now();
|
|
28
|
+
const chunks = await config.store.query({
|
|
29
|
+
embedding: queryEmbedding,
|
|
30
|
+
topK: input.topK ?? DEFAULT_TOP_K,
|
|
31
|
+
scope: input.scope,
|
|
32
|
+
});
|
|
33
|
+
const retrievalMs = now() - retrievalStart;
|
|
34
|
+
|
|
35
|
+
const totalMs = now() - totalStart;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
chunks,
|
|
39
|
+
embeddingModel: config.embedding.name,
|
|
40
|
+
durations: {
|
|
41
|
+
totalMs,
|
|
42
|
+
embeddingMs,
|
|
43
|
+
retrievalMs,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type MetadataValue = string | number | boolean | null;
|
|
2
|
+
|
|
3
|
+
export type Metadata = Record<
|
|
4
|
+
string,
|
|
5
|
+
MetadataValue | MetadataValue[] | undefined
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export type Chunk = {
|
|
9
|
+
id: string;
|
|
10
|
+
documentId: string;
|
|
11
|
+
sourceId: string;
|
|
12
|
+
index: number;
|
|
13
|
+
content: string;
|
|
14
|
+
tokenCount: number;
|
|
15
|
+
metadata: Metadata;
|
|
16
|
+
embedding?: number[];
|
|
17
|
+
documentContent?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ChunkText = {
|
|
21
|
+
index: number;
|
|
22
|
+
content: string;
|
|
23
|
+
tokenCount: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ChunkingOptions = {
|
|
27
|
+
chunkSize: number;
|
|
28
|
+
chunkOverlap: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type Chunker = (content: string, options: ChunkingOptions) => ChunkText[];
|
|
32
|
+
|
|
33
|
+
export type EmbeddingInput = {
|
|
34
|
+
text: string;
|
|
35
|
+
metadata: Metadata;
|
|
36
|
+
position: number;
|
|
37
|
+
sourceId: string;
|
|
38
|
+
documentId: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type EmbeddingProvider = {
|
|
42
|
+
name: string;
|
|
43
|
+
dimensions?: number;
|
|
44
|
+
embed: (input: EmbeddingInput) => Promise<number[]>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type VectorStore = {
|
|
48
|
+
upsert: (chunks: Chunk[]) => Promise<void>;
|
|
49
|
+
query: (params: {
|
|
50
|
+
embedding: number[];
|
|
51
|
+
topK: number;
|
|
52
|
+
scope?: {
|
|
53
|
+
sourceId?: string;
|
|
54
|
+
};
|
|
55
|
+
}) => Promise<Array<Chunk & { score: number }>>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type IngestInput = {
|
|
59
|
+
sourceId: string;
|
|
60
|
+
content: string;
|
|
61
|
+
metadata?: Metadata;
|
|
62
|
+
chunking?: Partial<ChunkingOptions>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type IngestResult = {
|
|
66
|
+
documentId: string;
|
|
67
|
+
chunkCount: number;
|
|
68
|
+
embeddingModel: string;
|
|
69
|
+
durations: {
|
|
70
|
+
totalMs: number;
|
|
71
|
+
chunkingMs: number;
|
|
72
|
+
embeddingMs: number;
|
|
73
|
+
storageMs: number;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type RetrieveInput = {
|
|
78
|
+
query: string;
|
|
79
|
+
topK?: number;
|
|
80
|
+
scope?: {
|
|
81
|
+
sourceId?: string;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type RetrieveResult = {
|
|
86
|
+
chunks: Array<Chunk & { score: number }>;
|
|
87
|
+
embeddingModel: string;
|
|
88
|
+
durations: {
|
|
89
|
+
totalMs: number;
|
|
90
|
+
embeddingMs: number;
|
|
91
|
+
retrievalMs: number;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type ContextEngineConfig = {
|
|
96
|
+
embedding: EmbeddingProvider;
|
|
97
|
+
store: VectorStore;
|
|
98
|
+
defaults?: Partial<ChunkingOptions>;
|
|
99
|
+
chunker?: Chunker;
|
|
100
|
+
idGenerator?: () => string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type ResolvedContextEngineConfig = {
|
|
104
|
+
embedding: EmbeddingProvider;
|
|
105
|
+
store: VectorStore;
|
|
106
|
+
defaults: ChunkingOptions;
|
|
107
|
+
chunker: Chunker;
|
|
108
|
+
idGenerator: () => string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Unrag setup
|
|
2
|
+
|
|
3
|
+
Unrag installs a small RAG module into your codebase with:
|
|
4
|
+
- chunk → embed → store on ingest
|
|
5
|
+
- embed → vector similarity search on retrieve
|
|
6
|
+
|
|
7
|
+
## Environment variables
|
|
8
|
+
|
|
9
|
+
Add these to your environment:
|
|
10
|
+
- `DATABASE_URL` (Postgres connection string)
|
|
11
|
+
- `AI_GATEWAY_API_KEY` (required by the `ai` SDK when using Vercel AI Gateway)
|
|
12
|
+
- Optional: `AI_GATEWAY_MODEL` (defaults to `openai/text-embedding-3-small`)
|
|
13
|
+
|
|
14
|
+
## Database requirements
|
|
15
|
+
|
|
16
|
+
Enable pgvector:
|
|
17
|
+
|
|
18
|
+
```sql
|
|
19
|
+
create extension if not exists vector;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Schema (Postgres)
|
|
23
|
+
|
|
24
|
+
You are responsible for migrations. Create these tables:
|
|
25
|
+
|
|
26
|
+
```sql
|
|
27
|
+
create table documents (
|
|
28
|
+
id uuid primary key,
|
|
29
|
+
source_id text not null,
|
|
30
|
+
metadata jsonb,
|
|
31
|
+
created_at timestamp default now()
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
create table chunks (
|
|
35
|
+
id uuid primary key,
|
|
36
|
+
document_id uuid not null references documents(id) on delete cascade,
|
|
37
|
+
source_id text not null,
|
|
38
|
+
idx integer not null,
|
|
39
|
+
content text not null,
|
|
40
|
+
token_count integer not null,
|
|
41
|
+
metadata jsonb,
|
|
42
|
+
created_at timestamp default now()
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
create table embeddings (
|
|
46
|
+
chunk_id uuid primary key references chunks(id) on delete cascade,
|
|
47
|
+
embedding vector,
|
|
48
|
+
embedding_dimension integer,
|
|
49
|
+
created_at timestamp default now()
|
|
50
|
+
);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Recommended indexes:
|
|
54
|
+
|
|
55
|
+
```sql
|
|
56
|
+
create index if not exists chunks_source_id_idx on chunks(source_id);
|
|
57
|
+
create index if not exists documents_source_id_idx on documents(source_id);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
<!-- __UNRAG_ADAPTER_NOTES__ -->
|
|
61
|
+
|
|
62
|
+
## Usage (Next.js)
|
|
63
|
+
|
|
64
|
+
- Use the engine only on the server (Route Handlers / Server Actions).
|
|
65
|
+
- Prefer a singleton DB client/pool pattern to avoid hot-reload connection storms.
|
|
66
|
+
- If Unrag detected Next.js, it added:
|
|
67
|
+
- `@unrag/*` path alias to your installed module directory
|
|
68
|
+
- `@unrag/config` path alias to `./unrag.config.ts`
|
|
69
|
+
|
|
70
|
+
Example route handler:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createUnragEngine } from "@unrag/config";
|
|
74
|
+
|
|
75
|
+
export async function GET() {
|
|
76
|
+
const engine = createUnragEngine();
|
|
77
|
+
const result = await engine.retrieve({ query: "hello", topK: 5 });
|
|
78
|
+
return Response.json(result);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { embed } from "ai";
|
|
2
|
+
import type { EmbeddingProvider } from "../core/types";
|
|
3
|
+
|
|
4
|
+
export type AiEmbeddingConfig = {
|
|
5
|
+
/**
|
|
6
|
+
* AI Gateway model id, e.g. "openai/text-embedding-3-small"
|
|
7
|
+
*/
|
|
8
|
+
model?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MODEL = "openai/text-embedding-3-small";
|
|
13
|
+
|
|
14
|
+
export const createAiEmbeddingProvider = (
|
|
15
|
+
config: AiEmbeddingConfig = {}
|
|
16
|
+
): EmbeddingProvider => {
|
|
17
|
+
const model = config.model ?? process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
name: `ai-sdk:${model}`,
|
|
21
|
+
dimensions: undefined,
|
|
22
|
+
embed: async ({ text }) => {
|
|
23
|
+
const abortSignal = config.timeoutMs
|
|
24
|
+
? AbortSignal.timeout(config.timeoutMs)
|
|
25
|
+
: undefined;
|
|
26
|
+
|
|
27
|
+
const result = await embed({
|
|
28
|
+
model,
|
|
29
|
+
value: text,
|
|
30
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!result.embedding) {
|
|
34
|
+
throw new Error("Embedding missing from AI SDK response");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result.embedding;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
customType,
|
|
3
|
+
integer,
|
|
4
|
+
jsonb,
|
|
5
|
+
pgTable,
|
|
6
|
+
primaryKey,
|
|
7
|
+
text,
|
|
8
|
+
timestamp,
|
|
9
|
+
uuid,
|
|
10
|
+
} from "drizzle-orm/pg-core";
|
|
11
|
+
|
|
12
|
+
const vector = (name: string, dimensions?: number) =>
|
|
13
|
+
customType<{ data: number[]; driverData: string }>({
|
|
14
|
+
dataType: () => (dimensions ? `vector(${dimensions})` : "vector"),
|
|
15
|
+
toDriver: (value) => {
|
|
16
|
+
const content = Array.isArray(value) ? value : [];
|
|
17
|
+
return `[${content.join(",")}]`;
|
|
18
|
+
},
|
|
19
|
+
})(name);
|
|
20
|
+
|
|
21
|
+
export const documents = pgTable("documents", {
|
|
22
|
+
id: uuid("id").primaryKey(),
|
|
23
|
+
sourceId: text("source_id").notNull(),
|
|
24
|
+
content: text("content").notNull(),
|
|
25
|
+
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
|
26
|
+
createdAt: timestamp("created_at", { mode: "date", withTimezone: false }).defaultNow(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const chunks = pgTable("chunks", {
|
|
30
|
+
id: uuid("id").primaryKey(),
|
|
31
|
+
documentId: uuid("document_id")
|
|
32
|
+
.notNull()
|
|
33
|
+
.references(() => documents.id, { onDelete: "cascade" }),
|
|
34
|
+
sourceId: text("source_id").notNull(),
|
|
35
|
+
index: integer("idx").notNull(),
|
|
36
|
+
content: text("content").notNull(),
|
|
37
|
+
tokenCount: integer("token_count").notNull(),
|
|
38
|
+
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
|
39
|
+
createdAt: timestamp("created_at", { mode: "date", withTimezone: false }).defaultNow(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const embeddings = pgTable(
|
|
43
|
+
"embeddings",
|
|
44
|
+
{
|
|
45
|
+
chunkId: uuid("chunk_id")
|
|
46
|
+
.notNull()
|
|
47
|
+
.references(() => chunks.id, { onDelete: "cascade" }),
|
|
48
|
+
embedding: vector("embedding"),
|
|
49
|
+
embeddingDimension: integer("embedding_dimension"),
|
|
50
|
+
createdAt: timestamp("created_at", { mode: "date", withTimezone: false }).defaultNow(),
|
|
51
|
+
},
|
|
52
|
+
(table) => ({
|
|
53
|
+
pk: primaryKey({ columns: [table.chunkId] }),
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const schema = {
|
|
58
|
+
documents,
|
|
59
|
+
chunks,
|
|
60
|
+
embeddings,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { documents, chunks, embeddings } from "./schema";
|
|
2
|
+
import type { Chunk, VectorStore } from "../../core/types";
|
|
3
|
+
import { sql, type SQL } from "drizzle-orm";
|
|
4
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
5
|
+
|
|
6
|
+
type DrizzleDb = PgDatabase<any, any, any>;
|
|
7
|
+
|
|
8
|
+
const sanitizeMetadata = (metadata: unknown) => {
|
|
9
|
+
if (metadata === undefined) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(JSON.stringify(metadata));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const toDocumentRow = (chunk: Chunk) => ({
|
|
21
|
+
id: chunk.documentId,
|
|
22
|
+
sourceId: chunk.sourceId,
|
|
23
|
+
content: chunk.documentContent ?? "",
|
|
24
|
+
metadata: sanitizeMetadata(chunk.metadata) as Record<string, unknown> | null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const toChunkRow = (chunk: Chunk) => ({
|
|
28
|
+
id: chunk.id,
|
|
29
|
+
documentId: chunk.documentId,
|
|
30
|
+
sourceId: chunk.sourceId,
|
|
31
|
+
index: chunk.index,
|
|
32
|
+
content: chunk.content,
|
|
33
|
+
tokenCount: chunk.tokenCount,
|
|
34
|
+
metadata: sanitizeMetadata(chunk.metadata) as Record<string, unknown> | null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const createDrizzleVectorStore = (db: DrizzleDb): VectorStore => ({
|
|
38
|
+
upsert: async (chunkItems) => {
|
|
39
|
+
if (chunkItems.length === 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await db.transaction(async (tx) => {
|
|
44
|
+
const head = chunkItems[0]!;
|
|
45
|
+
const documentRow = toDocumentRow(head);
|
|
46
|
+
|
|
47
|
+
await tx
|
|
48
|
+
.insert(documents)
|
|
49
|
+
.values(documentRow)
|
|
50
|
+
.onConflictDoUpdate({
|
|
51
|
+
target: documents.id,
|
|
52
|
+
set: {
|
|
53
|
+
sourceId: documentRow.sourceId,
|
|
54
|
+
content: documentRow.content,
|
|
55
|
+
metadata: documentRow.metadata,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
for (const chunk of chunkItems) {
|
|
60
|
+
const chunkRow = toChunkRow(chunk);
|
|
61
|
+
|
|
62
|
+
await tx
|
|
63
|
+
.insert(chunks)
|
|
64
|
+
.values(chunkRow)
|
|
65
|
+
.onConflictDoUpdate({
|
|
66
|
+
target: chunks.id,
|
|
67
|
+
set: {
|
|
68
|
+
content: chunkRow.content,
|
|
69
|
+
tokenCount: chunkRow.tokenCount,
|
|
70
|
+
metadata: chunkRow.metadata,
|
|
71
|
+
index: chunkRow.index,
|
|
72
|
+
sourceId: chunkRow.sourceId,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!chunk.embedding) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await tx
|
|
81
|
+
.insert(embeddings)
|
|
82
|
+
.values({
|
|
83
|
+
chunkId: chunk.id,
|
|
84
|
+
embedding: chunk.embedding,
|
|
85
|
+
embeddingDimension: chunk.embedding.length,
|
|
86
|
+
})
|
|
87
|
+
.onConflictDoUpdate({
|
|
88
|
+
target: embeddings.chunkId,
|
|
89
|
+
set: {
|
|
90
|
+
embedding: chunk.embedding,
|
|
91
|
+
embeddingDimension: chunk.embedding.length,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
query: async ({ embedding, topK, scope = {} }) => {
|
|
99
|
+
const filters: SQL[] = [];
|
|
100
|
+
|
|
101
|
+
if (scope.sourceId) {
|
|
102
|
+
// Interpret scope.sourceId as a prefix so callers can namespace content
|
|
103
|
+
// (e.g. `tenant:acme:`) without needing separate tables.
|
|
104
|
+
filters.push(sql`c.source_id like ${scope.sourceId + "%"}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const whereClause =
|
|
108
|
+
filters.length > 0 ? sql`where ${sql.join(filters, sql` and `)}` : sql``;
|
|
109
|
+
|
|
110
|
+
const vectorLiteral = `[${embedding.join(",")}]`;
|
|
111
|
+
|
|
112
|
+
const rows = await db.execute(
|
|
113
|
+
sql`
|
|
114
|
+
select
|
|
115
|
+
c.id,
|
|
116
|
+
c.document_id,
|
|
117
|
+
c.source_id,
|
|
118
|
+
c.idx,
|
|
119
|
+
c.content,
|
|
120
|
+
c.token_count,
|
|
121
|
+
c.metadata,
|
|
122
|
+
(e.embedding <=> ${vectorLiteral}) as score
|
|
123
|
+
from ${chunks} as c
|
|
124
|
+
join ${embeddings} as e on e.chunk_id = c.id
|
|
125
|
+
join ${documents} as d on d.id = c.document_id
|
|
126
|
+
${whereClause}
|
|
127
|
+
order by score asc
|
|
128
|
+
limit ${topK}
|
|
129
|
+
`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return (rows as Array<Record<string, unknown>>).map((row) => ({
|
|
133
|
+
id: String(row.id),
|
|
134
|
+
documentId: String(row.document_id),
|
|
135
|
+
sourceId: String(row.source_id),
|
|
136
|
+
index: Number(row.idx),
|
|
137
|
+
content: String(row.content),
|
|
138
|
+
tokenCount: Number(row.token_count),
|
|
139
|
+
metadata: (row.metadata ?? {}) as Chunk["metadata"],
|
|
140
|
+
score: Number(row.score),
|
|
141
|
+
}));
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
|