Skip to content

RI-7192: add useFtCreateCommand() #4715

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: feature/RI-6855/vector-search
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { generateFtCreateCommand } from './generateFtCreateCommand'

describe('generateFtCreateCommand', () => {
it('should generate basic FT.CREATE for HASH with TEXT and NUMERIC fields', () => {
const result = generateFtCreateCommand({
indexName: 'idx:products',
dataType: 'HASH',
prefixes: ['products:'],
schema: [
{ name: 'name', type: 'TEXT', sortable: true },
{ name: 'price', type: 'NUMERIC' },
],
})

expect(result).toBe(
[
'FT.CREATE idx:products',
'ON HASH',
'PREFIX 1 "products:"',
'SCHEMA',
'"name" TEXT SORTABLE',
'"price" NUMERIC',
].join('\n'),
)
})

it('should support JSON paths with AS aliasing', () => {
const result = generateFtCreateCommand({
indexName: 'idx:users',
dataType: 'JSON',
schema: [
{ name: 'fullName', type: 'TEXT', as: 'name', nostem: true },
{ name: 'age', type: 'NUMERIC', sortable: true },
],
})

expect(result).toBe(
[
'FT.CREATE idx:users',
'ON JSON',
'SCHEMA',
'$.fullName AS name TEXT NOSTEM',
'$.age NUMERIC SORTABLE',
].join('\n'),
)
})

it('should include VECTOR fields with parameters', () => {
const result = generateFtCreateCommand({
indexName: 'idx:embeddings',
dataType: 'HASH',
prefixes: ['vec:'],
schema: [
{
name: 'embedding',
type: 'VECTOR',
algorithm: 'FLAT',
vectorType: 'FLOAT32',
dim: 768,
distance: 'L2',
initialCap: 100,
blockSize: 200,
},
],
})

expect(result).toContain('VECTOR "FLAT"')
expect(result).toContain('"TYPE" FLOAT32')
expect(result).toContain('"DIM" 768')
expect(result).toContain('"DISTANCE_METRIC" "L2"')
expect(result).toContain('"INITIAL_CAP" 100')
expect(result).toContain('"BLOCK_SIZE" 200')
})

it('should handle empty stopwords', () => {
const result = generateFtCreateCommand({
indexName: 'idx:noStop',
dataType: 'HASH',
schema: [{ name: 'text', type: 'TEXT' }],
stopwords: [],
})

expect(result).toContain('STOPWORDS 0')
})

it('should include quoted stopwords', () => {
const result = generateFtCreateCommand({
indexName: 'idx:stopwords',
dataType: 'HASH',
schema: [{ name: 'text', type: 'TEXT' }],
stopwords: ['the', 'and', 'but'],
})

expect(result).toContain('STOPWORDS 3 "the" "and" "but"')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { CreateIndexOptions, SchemaField } from './types'

function formatVectorField(
field: Extract<SchemaField, { type: 'VECTOR' }>,
): string[] {
const parts = [
`VECTOR "${field.algorithm}"`,
`"TYPE" ${field.vectorType}`,
`"DIM" ${field.dim}`,
`"DISTANCE_METRIC" "${field.distance}"`,
]

if (field.initialCap !== undefined)
parts.push(`"INITIAL_CAP" ${field.initialCap}`)
if (field.blockSize !== undefined)
parts.push(`"BLOCK_SIZE" ${field.blockSize}`)
if (field.m !== undefined) parts.push(`"M" ${field.m}`)
if (field.efConstruction !== undefined)
parts.push(`"EF_CONSTRUCTION" ${field.efConstruction}`)

return parts
}

function formatSchemaField(
field: SchemaField,
dataType: 'HASH' | 'JSON',
): string {
const parts: string[] = []

const fieldRef = dataType === 'JSON' ? `$.${field.name}` : `"${field.name}"`
parts.push(fieldRef)

if (field.as) parts.push(`AS ${field.as}`)

switch (field.type) {
case 'TEXT':
parts.push('TEXT')
if (field.nostem) parts.push('NOSTEM')
if (field.unf) parts.push('UNF')
if (field.sortable) parts.push('SORTABLE')
break
case 'NUMERIC':
parts.push('NUMERIC')
if (field.sortable) parts.push('SORTABLE')
break
case 'TAG':
parts.push('TAG')
if (field.separator) parts.push(`SEPARATOR "${field.separator}"`)
if (field.sortable) parts.push('SORTABLE')
break
case 'VECTOR': {
const aliasPart = field.as ? ` AS ${field.as}` : ''
const vectorPart = formatVectorField(field).join(' ')
return `${fieldRef}${aliasPart} ${vectorPart}`
}
default:
throw new Error(`Unsupported field type: ${(field as any).type}`)
}

return parts.join(' ')
}

export function generateFtCreateCommand(options: CreateIndexOptions): string {
const { indexName, dataType, prefixes = [], schema, stopwords } = options

const lines: string[] = [`FT.CREATE ${indexName}`, `ON ${dataType}`]

if (prefixes.length > 0) {
const quotedPrefixes = prefixes.map((p) => `"${p}"`).join(' ')
lines.push(`PREFIX ${prefixes.length} ${quotedPrefixes}`)
}

lines.push('SCHEMA')

schema.forEach((field) => {
lines.push(formatSchemaField(field, dataType))
})

if (stopwords) {
if (stopwords.length === 0) {
lines.push('STOPWORDS 0')
} else {
const quotedStopwords = stopwords.map((sw) => `"${sw}"`).join(' ')
lines.push(`STOPWORDS ${stopwords.length} ${quotedStopwords}`)
}
}

return lines.join('\n')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useFtCreateCommand } from './useFtCreateCommand'
export type { CreateIndexOptions, SchemaField } from './types'
50 changes: 50 additions & 0 deletions redisinsight/ui/src/services/hooks/useFtCreateCommand/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type DataType = 'HASH' | 'JSON'

export type VectorAlgorithm = 'FLAT' | 'HNSW'
export type VectorType = 'FLOAT32' | 'FLOAT64'
export type VectorDistance = 'L2' | 'IP' | 'COSINE'

export type BaseField = {
name: string
as?: string
}

export type TextField = BaseField & {
type: 'TEXT'
nostem?: boolean
unf?: boolean
sortable?: boolean
}

export type NumericField = BaseField & {
type: 'NUMERIC'
sortable?: boolean
}

export type TagField = BaseField & {
type: 'TAG'
separator?: string
sortable?: boolean
}

export type VectorField = BaseField & {
type: 'VECTOR'
algorithm: VectorAlgorithm
vectorType: VectorType
dim: number
distance: VectorDistance
initialCap?: number
blockSize?: number
m?: number
efConstruction?: number
}

export type SchemaField = TextField | NumericField | TagField | VectorField

export type CreateIndexOptions = {
indexName: string
dataType: DataType
prefixes?: string[]
schema: SchemaField[]
stopwords?: string[] // optional
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { renderHook } from '@testing-library/react'
import { useFtCreateCommand } from './useFtCreateCommand'

describe('useFtCreateCommand (hardcoded version)', () => {
it('returns the expected hardcoded FT.CREATE command', () => {
const { result } = renderHook(() => useFtCreateCommand())

expect(result.current).toBe(`FT.CREATE idx:bikes_vss
ON HASH
PREFIX 1 "bikes:"
SCHEMA
"model" TEXT NOSTEM SORTABLE
"brand" TEXT NOSTEM SORTABLE
"price" NUMERIC SORTABLE
"type" TAG
"material" TAG
"weight" NUMERIC SORTABLE
"description_embeddings" VECTOR "FLAT" 10
"TYPE" FLOAT32
"DIM" 768
"DISTANCE_METRIC" "L2"
"INITIAL_CAP" 111
"BLOCK_SIZE" 111`)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// TODO: Since v1 would use predefined data, return a hardcoded command
// instead of generating it dynamically. Below is the intended implementation.

// export function useFtCreateCommand(options: CreateIndexOptions): string {
// return useMemo(() => generateFtCreateCommand(options), [options])
// }

export function useFtCreateCommand(): string {
return `FT.CREATE idx:bikes_vss
ON HASH
PREFIX 1 "bikes:"
SCHEMA
"model" TEXT NOSTEM SORTABLE
"brand" TEXT NOSTEM SORTABLE
"price" NUMERIC SORTABLE
"type" TAG
"material" TAG
"weight" NUMERIC SORTABLE
"description_embeddings" VECTOR "FLAT" 10
"TYPE" FLOAT32
"DIM" 768
"DISTANCE_METRIC" "L2"
"INITIAL_CAP" 111
"BLOCK_SIZE" 111`
}
Loading