Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
255640f
feat(knowledge): add Ollama embedding types
teedonk Mar 22, 2026
b043bc2
feat(knowledge): add per-KB dynamic pgvector tables
teedonk Mar 22, 2026
61f05a7
feat(knowledge): add Ollama embedding generation with retry and smart…
teedonk Mar 22, 2026
546dd7c
feat(knowledge): store ollamaBaseUrl in KB config
teedonk Mar 22, 2026
616761d
feat(chunkers): add embeddingModel to ChunkerOptions
teedonk Mar 22, 2026
133f326
feat(chunkers): add model-aware token estimation ratio
teedonk Mar 22, 2026
2693251
feat(knowledge): pass embeddingModel to all chunkers
teedonk Mar 22, 2026
18e7ac2
feat(knowledge): add Ollama chunk size and overlap capping
teedonk Mar 22, 2026
983efc3
feat(knowledge): add Ollama model validation and auto-detect dimension
teedonk Mar 22, 2026
53a1423
feat(knowledge): update KB detail API for Ollama support
teedonk Mar 22, 2026
0b5d218
feat(knowledge): add provider routing and cross-provider score normal…
teedonk Mar 22, 2026
606b70b
feat(knowledge): add Ollama provider selection UI
teedonk Mar 22, 2026
da36fcd
feat(knowledge): add Ollama params to create KB hook
teedonk Mar 22, 2026
b9e6ab7
test(knowledge): update KB detail tests for Ollama support
teedonk Mar 22, 2026
b1e92b8
test(knowledge): update search tests for provider routing
teedonk Mar 22, 2026
3698a04
fix(knowledge): separate validation from runtime model info to preven…
teedonk Mar 22, 2026
988158e
fix(knowledge): parameterize query vector and accept transaction handle
teedonk Mar 22, 2026
166a7f3
fix(knowledge): wrap Ollama delete+insert in transaction with status …
teedonk Mar 22, 2026
2f30934
fix(knowledge): clean up orphaned KB on table creation failure
teedonk Mar 22, 2026
f88e9f9
fix(knowledge): replace native select with project Select component
teedonk Mar 23, 2026
863e497
fix(knowledge): sort and trim Ollama results to topK
teedonk Mar 23, 2026
546061e
fix(knowledge): restrict Ollama base URL to localhost and private net…
teedonk Mar 23, 2026
00b3c7d
fix(knowledge): filter deleted documents from Ollama search and dedup…
teedonk Mar 23, 2026
075b005
fix(knowledge): use OLLAMA_URL env var and allow Docker hostnames in …
teedonk Mar 23, 2026
ea59193
fix(knowledge): align dynamic table SQL types with shared schema
teedonk Mar 23, 2026
ee3cc30
fix(knowledge): remove hardcoded OpenAI defaults from updateKnowledge…
teedonk Mar 23, 2026
e6d0a60
fix(knowledge): add enabled field and fix token ratio for Ollama embe…
teedonk Mar 23, 2026
0812f3b
fix(knowledge): remove immutable fields from update schema
teedonk Mar 23, 2026
fd8d2b3
fix(knowledge): strengthen SSRF validation for Ollama base URL
teedonk Mar 23, 2026
5c872c4
fix(knowledge): remove dead code and fix Record type in search route
teedonk Mar 23, 2026
4571299
fix(knowledge): add missing dynamic-tables mock in test
teedonk Mar 23, 2026
322dc4e
fix(knowledge): block IPv6-mapped IPv4 SSRF bypass and fix ::1 hostna…
teedonk Mar 23, 2026
ef84871
fix(knowledge): use KB embedding model for search and fix single-resu…
teedonk Mar 23, 2026
d308fe0
fix(knowledge): preserve ollamaBaseUrl when updating chunkingConfig
teedonk Mar 23, 2026
aa452f4
fix(knowledge): validate Ollama auto-detected dimension against bounds
teedonk Mar 23, 2026
8445d7e
merge: resolve conflicts with upstream staging
teedonk Mar 24, 2026
185007a
fix(knowledge): prevent SSRF bypass via hostname prefix matching on d…
teedonk Mar 24, 2026
456eaa4
resolve merge conflict in create-base-modal
teedonk Mar 27, 2026
1570b02
fix(knowledge): validate dimension before sql.raw interpolation
teedonk Mar 29, 2026
0e1dcf7
fix(knowledge): remove any casts in search route
teedonk Mar 29, 2026
e2b8189
Merge remote-tracking branch 'origin/staging' into feat/ollama-embedd…
teedonk Mar 29, 2026
ea3dd08
fix(knowledge): add missing document filters to Ollama search queries
teedonk Mar 29, 2026
24779a7
fix(knowledge): preserve Ollama embedding table on soft delete
teedonk Mar 29, 2026
547de40
fix(knowledge): wrap BETWEEN compound conditions in parentheses
teedonk Mar 29, 2026
7afb708
fix(knowledge): wrap BETWEEN compound conditions in parentheses
teedonk Mar 29, 2026
2cdb519
fix(knowledge): add retry to Ollama search embedding generation
teedonk Mar 29, 2026
507cc36
docs(knowledge): clarify soft-delete table retention rationale
teedonk Mar 29, 2026
50858d4
fix(knowledge): validate UUID format in kbTableName
teedonk Mar 29, 2026
5bdfe15
chore: merge staging into feat/ollama-embedding-support
teedonk Apr 2, 2026
f6d121e
fix(knowledge): hard-delete KB row on creation rollback
teedonk Apr 2, 2026
d210669
fix(knowledge): use hardDeleteKnowledgeBase in cleanup path
teedonk Apr 2, 2026
ff08fb0
fix(knowledge): align drizzle schema id type to uuid
teedonk Apr 2, 2026
5cebdea
fix(knowledge): clamp single-result distance instead of forcing zero
teedonk Apr 2, 2026
2552edc
fix: use global score normalization across all providers
teedonk Apr 2, 2026
c6fde92
fix: validate embedding count matches chunk count before insert
teedonk Apr 2, 2026
71b1769
fix: prevent NaN on Ollama dimension input
teedonk Apr 2, 2026
61d7936
fix: correct overlap chunk size unit in JSDoc comment
teedonk Apr 2, 2026
1991604
fix: only normalize scores when mixing OpenAI and Ollama providers
teedonk Apr 2, 2026
dbabedd
fix: batch Ollama embeddings by item count, not cumulative chars
teedonk Apr 2, 2026
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
5 changes: 5 additions & 0 deletions apps/sim/app/api/knowledge/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { mockGetSession, mockDbChain } = vi.hoisted(() => {
limit: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
execute: vi.fn().mockResolvedValue(undefined),
}
return { mockGetSession, mockDbChain }
})
Expand Down Expand Up @@ -98,6 +99,10 @@ vi.mock('@sim/db/schema', () => ({

vi.mock('@/lib/audit/log', () => auditMock)

vi.mock('@/lib/knowledge/dynamic-tables', () => ({
dropKBEmbeddingTable: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('@/lib/knowledge/service', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/knowledge/service')>()
return {
Expand Down
9 changes: 6 additions & 3 deletions apps/sim/app/api/knowledge/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,14 @@ const logger = createLogger('KnowledgeBaseByIdAPI')
const UpdateKnowledgeBaseSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
description: z.string().optional(),
embeddingModel: z.literal('text-embedding-3-small').optional(),
embeddingDimension: z.literal(1536).optional(),
workspaceId: z.string().nullable().optional(),
chunkingConfig: z
.object({
/** Maximum chunk size in tokens (1 token ≈ 4 characters) */
maxSize: z.number().min(100).max(4000),
/** Minimum chunk size in characters */
minSize: z.number().min(1).max(2000),
/** Overlap between chunks in characters */
/** Overlap between chunks in tokens (1 token ≈ 4 characters) */
overlap: z.number().min(0).max(500),
})
.refine(
Expand Down Expand Up @@ -205,6 +203,11 @@ export async function DELETE(
}

await deleteKnowledgeBase(id, requestId)
// Note: per-KB embedding tables (kb_embeddings_{id}) are intentionally NOT dropped here.
// deleteKnowledgeBase performs a soft delete — the KB can be restored via the restore endpoint,
// which requires the per-KB table to still exist. This is consistent with how OpenAI embeddings
// remain in the shared table after a KB soft delete. Per-KB table cleanup should occur
// as part of a permanent purge/hard-delete operation.

try {
PlatformEvents.knowledgeBaseDeleted({
Expand Down
141 changes: 139 additions & 2 deletions apps/sim/app/api/knowledge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createKBEmbeddingTable,
dropKBEmbeddingTable,
parseEmbeddingModel,
} from '@/lib/knowledge/dynamic-tables'
import { getOllamaBaseUrl, validateOllamaModel } from '@/lib/knowledge/embeddings'
import {
createKnowledgeBase,
getKnowledgeBases,
hardDeleteKnowledgeBase,
KnowledgeBaseConflictError,
type KnowledgeBaseScope,
} from '@/lib/knowledge/service'
Expand All @@ -26,8 +33,71 @@ const CreateKnowledgeBaseSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
workspaceId: z.string().min(1, 'Workspace ID is required'),
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
embeddingDimension: z.literal(1536).default(1536),
embeddingModel: z
.union([
z.literal('text-embedding-3-small'),
z.literal('text-embedding-3-large'),
z.string().regex(/^ollama\/.+/, 'Ollama models must be prefixed with "ollama/"'),
])
.default('text-embedding-3-small'),
embeddingDimension: z.number().int().min(64).max(8192).default(1536),
ollamaBaseUrl: z
.string()
.url('Ollama base URL must be a valid URL')
.refine(
(url) => {
try {
const parsed = new URL(url)
// Only allow http/https schemes
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}
const hostname = parsed.hostname.toLowerCase()
// Block known cloud metadata endpoints
if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') {
return false
}
// Block IPv6 addresses (except loopback) — prevents IPv6-mapped IPv4 bypass
// URL.hostname keeps brackets for IPv6, e.g. "[::ffff:169.254.169.254]"
if (hostname.startsWith('[') && hostname !== '[::1]') {
return false
}
// Allow localhost and IPv6 loopback
if (hostname === 'localhost' || hostname === '[::1]') {
return true
}
// Allow private IPv4 ranges — only match actual IPs, not domains like "10.evil.com"
const ipv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
if (ipv4.test(hostname)) {
if (
hostname.startsWith('127.') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.')
) {
return true
}
if (hostname.startsWith('172.')) {
const second = Number.parseInt(hostname.split('.')[1], 10)
if (second >= 16 && second <= 31) return true
}
return false
}
// Allow Docker service hostnames (no dots = not a public domain)
// e.g. "ollama", "host.docker.internal"
if (!hostname.includes('.') || hostname.endsWith('.internal')) {
return true
}
return false
} catch {
return false
}
},
{
message:
'Ollama base URL must point to localhost, a private network address, or a Docker service hostname',
}
)
.optional(),
chunkingConfig: z
.object({
/** Maximum chunk size in tokens (1 token ≈ 4 characters) */
Expand Down Expand Up @@ -98,13 +168,80 @@ export async function POST(req: NextRequest) {
try {
const validatedData = CreateKnowledgeBaseSchema.parse(body)

const { provider, modelName } = parseEmbeddingModel(validatedData.embeddingModel)

// For Ollama models, validate the model is available and auto-detect dimension
let effectiveDimension = validatedData.embeddingDimension
if (provider === 'ollama') {
const ollamaBaseUrl = getOllamaBaseUrl(validatedData.ollamaBaseUrl)
try {
const modelInfo = await validateOllamaModel(modelName, ollamaBaseUrl)

// Auto-correct dimension if the model reports a different one
if (modelInfo.embeddingLength && modelInfo.embeddingLength !== effectiveDimension) {
if (modelInfo.embeddingLength < 64 || modelInfo.embeddingLength > 8192) {
return NextResponse.json(
{
error: `Ollama model "${modelName}" reported an unsupported embedding dimension (${modelInfo.embeddingLength}). Supported range: 64–8192.`,
},
{ status: 400 }
)
}
logger.info(
`[${requestId}] Auto-correcting embedding dimension from ${effectiveDimension} ` +
`to ${modelInfo.embeddingLength} (reported by Ollama model ${modelName})`
)
effectiveDimension = modelInfo.embeddingLength
}
} catch {
return NextResponse.json(
{
error:
`Cannot reach Ollama at ${ollamaBaseUrl} or model "${modelName}" is not available. ` +
`Make sure Ollama is running and the model is pulled (ollama pull ${modelName}).`,
},
{ status: 400 }
)
}
}

const createData = {
...validatedData,
embeddingDimension: effectiveDimension,
userId: session.user.id,
}

const newKnowledgeBase = await createKnowledgeBase(createData, requestId)

if (provider === 'ollama') {
try {
await createKBEmbeddingTable(newKnowledgeBase.id, effectiveDimension)
} catch (tableError) {
logger.error(
`[${requestId}] Failed to create embedding table for KB ${newKnowledgeBase.id}`,
tableError
)
// Hard-delete the KB row — this is a creation-time rollback, not a user-initiated
// deletion, so a soft delete would leave a restorable broken KB in the archive.
try {
await dropKBEmbeddingTable(newKnowledgeBase.id)
await hardDeleteKnowledgeBase(newKnowledgeBase.id)
logger.info(
`[${requestId}] Cleaned up orphaned KB ${newKnowledgeBase.id} after table creation failure`
)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up orphaned KB ${newKnowledgeBase.id}`,
cleanupError
)
}
return NextResponse.json(
{ error: 'Failed to create embedding storage. Please try again.' },
{ status: 500 }
)
}
Comment on lines 214 to +242
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Orphaned KB row when embedding table creation fails

createKnowledgeBase persists the KB row to the database at line 136 before createKBEmbeddingTable is called. If createKBEmbeddingTable throws (e.g. the pgvector extension isn't enabled or a naming collision occurs), the error is re-thrown and the request returns 500 — but the KB record is left behind in the database without a corresponding embedding table. Any subsequent document upload to this KB will fail with a table-not-found error and the user has no way to fix it from the UI.

A safe fix is to delete the orphaned KB row in the catch block before re-throwing:

} catch (tableError) {
  logger.error(...)
  // Clean up orphaned KB row
  await deleteKnowledgeBase(newKnowledgeBase.id, requestId).catch(() => {})
  throw tableError
}

}

try {
PlatformEvents.knowledgeBaseCreated({
knowledgeBaseId: newKnowledgeBase.id,
Expand Down
63 changes: 38 additions & 25 deletions apps/sim/app/api/knowledge/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const {
mockGetQueryStrategy,
mockGenerateSearchEmbedding,
mockGetDocumentNamesByIds,
mockParseEmbeddingModel,
mockSearchKBTable,
mockSearchKBTableTagOnly,
} = vi.hoisted(() => ({
mockDbChain: {
select: vi.fn().mockReturnThis(),
Expand All @@ -42,6 +45,9 @@ const {
mockGetQueryStrategy: vi.fn(),
mockGenerateSearchEmbedding: vi.fn(),
mockGetDocumentNamesByIds: vi.fn(),
mockParseEmbeddingModel: vi.fn(),
mockSearchKBTable: vi.fn(),
mockSearchKBTableTagOnly: vi.fn(),
}))

vi.mock('drizzle-orm', () => ({
Expand Down Expand Up @@ -188,6 +194,16 @@ vi.mock('./utils', () => ({
},
}))

vi.mock('@/lib/knowledge/dynamic-tables', () => ({
parseEmbeddingModel: mockParseEmbeddingModel,
searchKBTable: mockSearchKBTable,
searchKBTableTagOnly: mockSearchKBTableTagOnly,
}))

vi.mock('@/lib/knowledge/embeddings', () => ({
generateSearchEmbedding: mockGenerateSearchEmbedding,
}))

import { estimateTokenCount } from '@/lib/tokenization/estimators'
import { POST } from '@/app/api/knowledge/search/route'
import { calculateCost } from '@/providers/utils'
Expand Down Expand Up @@ -225,6 +241,18 @@ describe('Knowledge Search API Route', () => {
}
})

// KB config fetch: db.select().from().where() resolves to default single-KB config
mockDbChain.where.mockResolvedValue([
{ id: 'kb-123', embeddingModel: 'text-embedding-3-small', chunkingConfig: {} },
])

mockParseEmbeddingModel.mockReturnValue({
provider: 'openai',
modelName: 'text-embedding-3-small',
})
mockSearchKBTable.mockResolvedValue([])
mockSearchKBTableTagOnly.mockResolvedValue([])

mockHandleTagOnlySearch.mockClear()
mockHandleVectorOnlySearch.mockClear()
mockHandleTagAndVectorSearch.mockClear()
Expand Down Expand Up @@ -337,6 +365,11 @@ describe('Knowledge Search API Route', () => {
.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[0] })
.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[1] })

mockDbChain.where.mockResolvedValue([
{ id: 'kb-123', embeddingModel: 'text-embedding-3-small', chunkingConfig: {} },
{ id: 'kb-456', embeddingModel: 'text-embedding-3-small', chunkingConfig: {} },
])

mockDbChain.limit.mockResolvedValue([])

mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
Expand Down Expand Up @@ -1008,6 +1041,11 @@ describe('Knowledge Search API Route', () => {

mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)

mockDbChain.where.mockResolvedValue([
{ id: 'kb-123', embeddingModel: 'text-embedding-3-small', chunkingConfig: {} },
{ id: 'kb-456', embeddingModel: 'text-embedding-3-small', chunkingConfig: {} },
])

mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)

const req = createMockRequest('POST', multiKbTagData)
Expand Down Expand Up @@ -1065,13 +1103,6 @@ describe('Knowledge Search API Route', () => {
'doc-active': 'Active Document.pdf',
})

const mockTagDefs = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
}
mockDbChain.select.mockReturnValueOnce(mockTagDefs)

const req = createMockRequest('POST', {
knowledgeBaseIds: ['kb-123'],
query: 'test query',
Expand Down Expand Up @@ -1134,15 +1165,6 @@ describe('Knowledge Search API Route', () => {
'doc-active-tagged': 'Active Tagged Document.pdf',
})

const mockTagDefs = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi
.fn()
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
}
mockDbChain.select.mockReturnValueOnce(mockTagDefs)

const req = createMockRequest('POST', {
knowledgeBaseIds: ['kb-123'],
tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }],
Expand Down Expand Up @@ -1207,15 +1229,6 @@ describe('Knowledge Search API Route', () => {
'doc-active-combined': 'Active Combined Search.pdf',
})

const mockTagDefs = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi
.fn()
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
}
mockDbChain.select.mockReturnValueOnce(mockTagDefs)

const req = createMockRequest('POST', {
knowledgeBaseIds: ['kb-123'],
query: 'relevant content',
Expand Down
Loading