Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: "3.8"
services:
# PostgreSQL Database
postgres:
image: postgres:15
image: pgvector/pgvector:pg15
container_name: toolkit-postgres
environment:
POSTGRES_USER: postgres
Expand Down Expand Up @@ -48,4 +48,4 @@ services:
volumes:
postgres_data:
redis_data:
blob_data:
blob_data:
27 changes: 27 additions & 0 deletions prisma/migrations/20260521000100_file_embeddings/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TYPE "FileEmbeddingStatus" AS ENUM ('skipped', 'indexing', 'indexed', 'failed');

ALTER TABLE "File"
ADD COLUMN "embeddingStatus" "FileEmbeddingStatus" NOT NULL DEFAULT 'skipped';

CREATE TABLE "FileEmbeddingChunk" (
"id" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"chunkIndex" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"embedding" vector(384) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "FileEmbeddingChunk_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "FileEmbeddingChunk_fileId_chunkIndex_key" ON "FileEmbeddingChunk"("fileId", "chunkIndex");
CREATE INDEX "FileEmbeddingChunk_fileId_idx" ON "FileEmbeddingChunk"("fileId");
CREATE INDEX "FileEmbeddingChunk_userId_idx" ON "FileEmbeddingChunk"("userId");
CREATE INDEX "FileEmbeddingChunk_embedding_idx" ON "FileEmbeddingChunk" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);

ALTER TABLE "FileEmbeddingChunk"
ADD CONSTRAINT "FileEmbeddingChunk_fileId_fkey"
FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
40 changes: 32 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,37 @@ model Video {
}

model File {
id String @id @default(uuid())
userId String
name String
contentType String
url String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(uuid())
userId String
name String
contentType String
url String
embeddingStatus FileEmbeddingStatus @default(skipped)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
embeddingChunks FileEmbeddingChunk[]
}

enum FileEmbeddingStatus {
skipped
indexing
indexed
failed
}

model FileEmbeddingChunk {
id String @id @default(uuid())
fileId String
userId String
chunkIndex Int
content String @db.Text
embedding Unsupported("vector(384)")
createdAt DateTime @default(now())
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)

@@unique([fileId, chunkIndex])
@@index([fileId])
@@index([userId])
}

model Image {
Expand Down Expand Up @@ -190,4 +214,4 @@ model Tool {
toolkit Toolkit @relation(fields: [toolkitId], references: [id], onDelete: Cascade)

@@id([id, toolkitId])
}
}
31 changes: 26 additions & 5 deletions src/app/(general)/_components/chat/input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ const PureMultimodalInput: React.FC<Props> = ({
"input",
"",
);
const [fileIndexingEnabled, setFileIndexingEnabled] = useLocalStorage<
boolean | null
>("file-indexing-enabled", null);

useEffect(() => {
if (textareaRef.current) {
Expand All @@ -121,6 +124,18 @@ const PureMultimodalInput: React.FC<Props> = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);

const requestFileIndexingPreference = useCallback(() => {
if (fileIndexingEnabled !== null) {
return fileIndexingEnabled;
}

const shouldIndex = window.confirm(
"Index uploaded files for retrieval? This stores searchable chunks in your database and enables the File Search toolkit.",
);
setFileIndexingEnabled(shouldIndex);
return shouldIndex;
}, [fileIndexingEnabled, setFileIndexingEnabled]);

const supportsImages = selectedChatModel?.capabilities?.includes(
LanguageModelCapability.Vision,
);
Expand Down Expand Up @@ -199,9 +214,10 @@ const PureMultimodalInput: React.FC<Props> = ({
]);

const uploadFile = useCallback(
async (file: File): Promise<Attachment | undefined> => {
async (file: File, indexFile: boolean): Promise<Attachment | undefined> => {
const formData = new FormData();
formData.append("file", file);
formData.append("indexFile", String(indexFile));

try {
const response = await fetch("/api/files/upload", {
Expand Down Expand Up @@ -235,11 +251,15 @@ const PureMultimodalInput: React.FC<Props> = ({
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
const indexFiles =
files.length > 0 ? requestFileIndexingPreference() : false;

setUploadQueue(files.map((file) => file.name));

try {
const uploadPromises = files.map((file) => uploadFile(file));
const uploadPromises = files.map((file) =>
uploadFile(file, indexFiles),
);
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment => attachment !== undefined,
Expand All @@ -255,7 +275,7 @@ const PureMultimodalInput: React.FC<Props> = ({
setUploadQueue([]);
}
},
[setAttachments, uploadFile],
[requestFileIndexingPreference, setAttachments, uploadFile],
);

useEffect(() => {
Expand Down Expand Up @@ -302,10 +322,11 @@ const PureMultimodalInput: React.FC<Props> = ({
return;
}

const indexFiles = requestFileIndexingPreference();
setUploadQueue(imageFiles.map((file) => file.name));

// Handle async upload in non-blocking way
Promise.all(imageFiles.map((file) => uploadFile(file)))
Promise.all(imageFiles.map((file) => uploadFile(file, indexFiles)))
.then((uploadedAttachments) => {
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment =>
Expand All @@ -332,7 +353,7 @@ const PureMultimodalInput: React.FC<Props> = ({
});
}
},
[supportsImages, setAttachments, uploadFile],
[requestFileIndexingPreference, supportsImages, setAttachments, uploadFile],
);

useEffect(() => {
Expand Down
11 changes: 11 additions & 0 deletions src/app/(general)/account/components/tabs/attachments/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function DataTableDemo() {
const [visibleColumns, setVisibleColumns] = React.useState({
name: true,
contentType: true,
embeddingStatus: true,
actions: true,
});

Expand Down Expand Up @@ -184,6 +185,9 @@ export function DataTableDemo() {
{visibleColumns.contentType && (
<TableHead>Content Type</TableHead>
)}
{visibleColumns.embeddingStatus && (
<TableHead>Indexing</TableHead>
)}
{visibleColumns.actions && (
<TableHead className="w-12"></TableHead>
)}
Expand Down Expand Up @@ -237,6 +241,13 @@ export function DataTableDemo() {
<div className="capitalize">{attachment.contentType}</div>
</TableCell>
)}
{visibleColumns.embeddingStatus && (
<TableCell>
<div className="capitalize">
{attachment.embeddingStatus}
</div>
</TableCell>
)}
{visibleColumns.actions && (
<TableCell>
<DropdownMenu>
Expand Down
15 changes: 14 additions & 1 deletion src/app/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { put } from "@vercel/blob";
import { auth } from "@/server/auth";
import { api } from "@/trpc/server";
import { FILE_MAX_SIZE, IS_DEVELOPMENT } from "@/lib/constants";
import { indexUploadedFile } from "@/server/rag/files";

// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
Expand All @@ -20,7 +21,7 @@ const FileSchema = z.object({
(file) =>
["image/jpeg", "image/png", "application/pdf"].includes(file.type),
{
message: "File type should be JPEG or PNG",
message: "File type should be JPEG, PNG, or PDF",
},
),
});
Expand Down Expand Up @@ -56,6 +57,7 @@ export async function POST(request: Request) {

// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
const shouldIndexFile = formData.get("indexFile") === "true";
const fileBuffer = await file.arrayBuffer();

try {
Expand All @@ -71,8 +73,19 @@ export async function POST(request: Request) {
name: filename,
url: data.url,
contentType: validatedFile.data.file.type,
embeddingStatus: shouldIndexFile ? "indexing" : "skipped",
});

if (shouldIndexFile) {
file.embeddingStatus = await indexUploadedFile({
fileId: file.id,
userId: session.user.id,
name: filename,
contentType: validatedFile.data.file.type,
arrayBuffer: fileBuffer,
});
}

if (IS_DEVELOPMENT) {
// In development, use base64 data URLs so openrouter can access the file
// We can't use the local blob storage because it's not accessible to openrouter
Expand Down
66 changes: 66 additions & 0 deletions src/lib/rag/embeddings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export const FILE_EMBEDDING_DIMENSIONS = 384;

const MAX_TOKENS = 2_000;
const MAX_CHARACTER_FEATURES = 6_000;

export function embedText(text: string) {
const vector = new Array<number>(FILE_EMBEDDING_DIMENSIONS).fill(0);
const normalized = text.normalize("NFKC").toLowerCase();
const tokens = normalized.match(/[\p{L}\p{N}]+/gu)?.slice(0, MAX_TOKENS);

if (tokens?.length) {
tokens.forEach((token) => addFeature(vector, `w:${token}`, 1));

for (let index = 0; index < tokens.length - 1; index++) {
addFeature(vector, `b:${tokens[index]!} ${tokens[index + 1]!}`, 0.7);
}
}

const compactText = normalized.replace(/\s+/g, " ");
const characterFeatureCount = Math.min(
compactText.length - 2,
MAX_CHARACTER_FEATURES,
);

for (let index = 0; index < characterFeatureCount; index++) {
addFeature(vector, `c:${compactText.slice(index, index + 3)}`, 0.25);
}

normalizeVector(vector);
return vector;
}

export function toVectorLiteral(vector: number[]) {
return `[${vector.map((value) => Number(value.toFixed(6))).join(",")}]`;
}

function addFeature(vector: number[], feature: string, weight: number) {
const hash = hashString(feature);
const index = hash % FILE_EMBEDDING_DIMENSIONS;
const sign = hash & 1 ? 1 : -1;
vector[index] = (vector[index] ?? 0) + sign * weight;
}

function normalizeVector(vector: number[]) {
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value ** 2, 0));

if (norm === 0) {
vector[0] = 1;
return;
}

for (let index = 0; index < vector.length; index++) {
vector[index] = vector[index]! / norm;
}
}

function hashString(value: string) {
let hash = 2166136261;

for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}

return hash >>> 0;
}
Loading