Skip to main content
Version: 2.6

Developing Custom Strategies

This guide shows how to create custom strategies for both embedding and retrieval pipelines. All strategies follow the same pattern: write a pure TypeScript function, then convert it to an Aktor strategy.

Strategy Development Pattern​

1. Pure TypeScript Function​

First, write your logic as a pure TypeScript function that matches the strategy interface.

2. Convert to Aktor Strategy​

Use createAktorFunctionAsync to convert your function into an Aktor strategy.

3. Export Both Versions​

Export both the pure function and Aktor version for maximum flexibility.

Embedding Pipeline Custom Strategies​

Custom Document Loader Strategy​

Scenario: Process PDF files with Azure Document Intelligence

import { createAktorFunctionAsync } from '@operaide/aktor';
import type { DocumentContent } from '@operaide/vector';
import type { ToAktorStrategy } from '@operaide/vector';

// 1. Pure TypeScript function
export const pdfDocumentLoaderStrategy = async ({ file }: { file: File }): Promise<DocumentContent> => {
// Check if file is PDF
if (!file.name.toLowerCase().endsWith('.pdf')) {
throw new Error(`Unsupported file type: ${file.name}`);
}

// Process with Azure Document Intelligence
const markdown = await processFileWithDocumentIntelligence(file);

return {
id: generateDocumentId(file.name),
title: extractTitleFromPDF(file.name),
content: markdown,
metadata: {
type: 'pdf',
source: file.name,
createdAt: new Date().toISOString(),
processingMethod: 'azure_document_intelligence',
pageCount: await getPDFPageCount(file),
}
};
};

// 2. Convert to Aktor strategy
export type PdfDocumentLoaderStrategy = typeof pdfDocumentLoaderStrategy;
export type AktorPdfDocumentLoaderStrategy = ToAktorStrategy<PdfDocumentLoaderStrategy>;

export const aktorPdfDocumentLoaderStrategy: AktorPdfDocumentLoaderStrategy = createAktorFunctionAsync(
'aktorPdfDocumentLoaderStrategy',
pdfDocumentLoaderStrategy
);

// Helper functions
async function processFileWithDocumentIntelligence(file: File): Promise<string> {
// Azure Document Intelligence API call
const formData = new FormData();
formData.append('file', file);

const response = await fetch('/api/azure/document-intelligence', {
method: 'POST',
body: formData,
});

const result = await response.json();
return result.markdown;
}

function extractTitleFromPDF(filename: string): string {
return filename.replace('.pdf', '').replace(/[-_]/g, ' ');
}

async function getPDFPageCount(file: File): Promise<number> {
// Implementation to count PDF pages
return 1; // Placeholder
}

function generateDocumentId(filename: string): string {
return `doc_${Date.now()}_${filename.replace(/[^a-zA-Z0-9]/g, '_')}`;
}

Custom Semantic Chunking Strategy​

Scenario: Split documents using semantic boundaries instead of fixed sizes

import { createAktorFunctionAsync } from '@operaide/aktor';
import type { CleanedDocument, DocumentChunk } from '@operaide/vector';
import type { ToAktorStrategy } from '@operaide/vector';

// 1. Pure TypeScript function
export const semanticChunkingStrategy = async ({
document
}: {
document: CleanedDocument
}): Promise<DocumentChunk[]> => {
// Split by sentences first
const sentences = splitIntoSentences(document.content);

// Group sentences into semantic chunks
const semanticChunks = await createSemanticChunks(sentences, {
maxTokens: 512,
overlapSentences: 2,
coherenceThreshold: 0.7,
});

// Convert to DocumentChunk format
return semanticChunks.map((chunk, index) => ({
id: generateChunkId(document.id, index),
documentId: document.id,
content: chunk.text,
index,
metadata: {
...document.metadata,
chunkingMethod: 'semantic',
coherenceScore: chunk.coherenceScore,
sentenceCount: chunk.sentenceCount,
tokenCount: chunk.tokenCount,
}
}));
};

// 2. Convert to Aktor strategy
export type SemanticChunkingStrategy = typeof semanticChunkingStrategy;
export type AktorSemanticChunkingStrategy = ToAktorStrategy<SemanticChunkingStrategy>;

export const aktorSemanticChunkingStrategy: AktorSemanticChunkingStrategy = createAktorFunctionAsync(
'aktorSemanticChunkingStrategy',
semanticChunkingStrategy
);

// Helper functions
function splitIntoSentences(text: string): string[] {
// Simple sentence splitting - could use more sophisticated NLP
return text.split(/[.!?]+/)
.map(s => s.trim())
.filter(s => s.length > 0);
}

interface SemanticChunk {
text: string;
coherenceScore: number;
sentenceCount: number;
tokenCount: number;
}

async function createSemanticChunks(
sentences: string[],
options: {
maxTokens: number;
overlapSentences: number;
coherenceThreshold: number;
}
): Promise<SemanticChunk[]> {
const chunks: SemanticChunk[] = [];
let currentChunk: string[] = [];
let currentTokens = 0;

for (let i = 0; i < sentences.length; i++) {
const sentence = sentences[i];
const sentenceTokens = estimateTokenCount(sentence);

// Check if adding this sentence would exceed token limit
if (currentTokens + sentenceTokens > options.maxTokens && currentChunk.length > 0) {
// Finalize current chunk
const chunkText = currentChunk.join('. ') + '.';
const coherenceScore = await calculateCoherenceScore(currentChunk);

chunks.push({
text: chunkText,
coherenceScore,
sentenceCount: currentChunk.length,
tokenCount: currentTokens,
});

// Start new chunk with overlap
const overlapStart = Math.max(0, currentChunk.length - options.overlapSentences);
currentChunk = currentChunk.slice(overlapStart);
currentTokens = currentChunk.reduce((sum, s) => sum + estimateTokenCount(s), 0);
}

currentChunk.push(sentence);
currentTokens += sentenceTokens;
}

// Add final chunk if it exists
if (currentChunk.length > 0) {
const chunkText = currentChunk.join('. ') + '.';
const coherenceScore = await calculateCoherenceScore(currentChunk);

chunks.push({
text: chunkText,
coherenceScore,
sentenceCount: currentChunk.length,
tokenCount: currentTokens,
});
}

return chunks;
}

function estimateTokenCount(text: string): number {
// Rough estimation: 1 token ≈ 4 characters
return Math.ceil(text.length / 4);
}

async function calculateCoherenceScore(sentences: string[]): Promise<number> {
// Placeholder for semantic coherence calculation
// In real implementation, you might use sentence transformers
return Math.random() * 0.3 + 0.7; // Mock score between 0.7-1.0
}

function generateChunkId(documentId: string, index: number): string {
return `${documentId}_chunk_${index}`;
}

Custom OpenAI Embedding Strategy​

Scenario: Use OpenAI's latest embedding models with custom configuration

import { createAktorFunctionAsync } from '@operaide/aktor';
import type { EnrichedChunk, EmbeddedChunk } from '@operaide/vector';
import type { ToAktorStrategy } from '@operaide/vector';

// 1. Pure TypeScript function
export const openAIEmbeddingStrategy = async ({
chunks
}: {
chunks: EnrichedChunk[]
}): Promise<EmbeddedChunk[]> => {
const embeddingModel = 'text-embedding-3-large';
const batchSize = 100;
const maxRetries = 3;

const embeddedChunks: EmbeddedChunk[] = [];

// Process in batches for efficiency
for (let i = 0; i < chunks.length; i += batchSize) {
const batch = chunks.slice(i, i + batchSize);

try {
const embeddings = await generateEmbeddingsWithRetry(
batch.map(chunk => chunk.content),
embeddingModel,
maxRetries
);

// Combine chunks with their embeddings
batch.forEach((chunk, index) => {
embeddedChunks.push({
...chunk,
embedding: embeddings[index],
embeddingModel,
embeddedAt: new Date().toISOString(),
});
});

} catch (error) {
console.error(`Failed to embed batch starting at index ${i}:`, error);

// Add chunks with empty embeddings to maintain order
batch.forEach(chunk => {
embeddedChunks.push({
...chunk,
embedding: Array(3072).fill(0), // text-embedding-3-large dimensions
embeddingModel: `${embeddingModel}_failed`,
embeddedAt: new Date().toISOString(),
});
});
}
}

return embeddedChunks;
};

// 2. Convert to Aktor strategy
export type OpenAIEmbeddingStrategy = typeof openAIEmbeddingStrategy;
export type AktorOpenAIEmbeddingStrategy = ToAktorStrategy<OpenAIEmbeddingStrategy>;

export const aktorOpenAIEmbeddingStrategy: AktorOpenAIEmbeddingStrategy = createAktorFunctionAsync(
'aktorOpenAIEmbeddingStrategy',
openAIEmbeddingStrategy
);

// Helper functions
async function generateEmbeddingsWithRetry(
texts: string[],
model: string,
maxRetries: number
): Promise<number[][]> {
let lastError: Error;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: texts,
model: model,
}),
});

if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
}

const data = await response.json();
return data.data.map((item: any) => item.embedding);

} catch (error) {
lastError = error as Error;
console.warn(`Embedding attempt ${attempt} failed:`, error);

if (attempt < maxRetries) {
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}

throw lastError!;
}

Retrieval Pipeline Custom Strategies​

Custom Query Expansion Strategy​

Scenario: Expand queries with synonyms and related terms

import { createAktorFunctionAsync } from '@operaide/aktor';
import type { PreprocessedQuery } from '@operaide/vector';
import type { ToAktorStrategy } from '@operaide/vector';

// 1. Pure TypeScript function
export const queryExpansionStrategy = async ({
query
}: {
query: string
}): Promise<PreprocessedQuery> => {
const originalQuery = query.trim();

// Extract key terms
const keyTerms = extractKeyTerms(originalQuery);

// Get synonyms and related terms
const expandedTerms = await getExpandedTerms(keyTerms);

// Create expanded query
const processedQuery = createExpandedQuery(originalQuery, expandedTerms);

return {
originalQuery,
processedQuery,
queryType: 'semantic',
expandedTerms,
};
};

// 2. Convert to Aktor strategy
export type QueryExpansionStrategy = typeof queryExpansionStrategy;
export type AktorQueryExpansionStrategy = ToAktorStrategy<QueryExpansionStrategy>;

export const aktorQueryExpansionStrategy: AktorQueryExpansionStrategy = createAktorFunctionAsync(
'aktorQueryExpansionStrategy',
queryExpansionStrategy
);

// Helper functions
function extractKeyTerms(query: string): string[] {
// Remove stop words and extract important terms
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for']);

return query
.toLowerCase()
.split(/\s+/)
.filter(term => term.length > 2 && !stopWords.has(term));
}

async function getExpandedTerms(keyTerms: string[]): Promise<string[]> {
const expanded: string[] = [];

for (const term of keyTerms) {
// Get synonyms (could integrate with WordNet, thesaurus API, etc.)
const synonyms = await getSynonyms(term);
expanded.push(...synonyms);

// Get related technical terms (for technical documentation)
const relatedTerms = await getTechnicalRelatedTerms(term);
expanded.push(...relatedTerms);
}

return [...new Set(expanded)]; // Remove duplicates
}

async function getSynonyms(term: string): Promise<string[]> {
// Mock implementation - integrate with actual thesaurus API
const synonymMap: Record<string, string[]> = {
'authentication': ['login', 'signin', 'auth', 'credentials'],
'database': ['db', 'storage', 'persistence', 'data store'],
'function': ['method', 'procedure', 'routine', 'operation'],
'user': ['account', 'profile', 'person', 'individual'],
};

return synonymMap[term.toLowerCase()] || [];
}

async function getTechnicalRelatedTerms(term: string): Promise<string[]> {
// Mock implementation - could use domain-specific knowledge bases
const techTermMap: Record<string, string[]> = {
'authentication': ['oauth', 'jwt', 'session', 'token', 'password'],
'database': ['sql', 'nosql', 'query', 'index', 'table'],
'function': ['api', 'endpoint', 'call', 'invoke', 'execute'],
'user': ['permission', 'role', 'access', 'identity'],
};

return techTermMap[term.toLowerCase()] || [];
}

function createExpandedQuery(originalQuery: string, expandedTerms: string[]): string {
if (expandedTerms.length === 0) {
return originalQuery;
}

// Add expanded terms with lower weight
return `${originalQuery} ${expandedTerms.slice(0, 5).join(' ')}`;
}

Custom Cross-Encoder Reranking Strategy​

Scenario: Use transformer models for precise relevance scoring

import { createAktorFunctionAsync } from '@operaide/aktor';
import type { FilteredResult, RankedResult, PreprocessedQuery } from '@operaide/vector';
import type { ToAktorStrategy } from '@operaide/vector';

// 1. Pure TypeScript function
export const crossEncoderRerankingStrategy = async ({
query,
results
}: {
query: PreprocessedQuery;
results: FilteredResult[];
}): Promise<RankedResult[]> => {
const rerankingModel = 'cross-encoder/ms-marco-MiniLM-L-6-v2';
const maxPairs = 100; // Limit for performance

// Prepare query-document pairs for cross-encoder
const queryDocumentPairs = results
.slice(0, maxPairs)
.map(result => ({
query: query.processedQuery,
document: result.content,
originalResult: result,
}));

try {
// Calculate relevance scores using cross-encoder
const relevanceScores = await calculateCrossEncoderScores(
queryDocumentPairs,
rerankingModel
);

// Create ranked results
const rankedResults: RankedResult[] = results.map((result, index) => ({
...result,
relevanceScore: index < maxPairs ? relevanceScores[index] : result.similarity,
rankingReason: index < maxPairs ? 'cross_encoder' : 'vector_similarity',
rerankedAt: new Date().toISOString(),
}));

// Sort by relevance score descending
rankedResults.sort((a, b) => b.relevanceScore - a.relevanceScore);

return rankedResults;

} catch (error) {
console.error('Cross-encoder reranking failed:', error);

// Fallback to similarity-based ranking
return results.map(result => ({
...result,
relevanceScore: result.similarity,
rankingReason: 'fallback_similarity',
rerankedAt: new Date().toISOString(),
}));
}
};

// 2. Convert to Aktor strategy
export type CrossEncoderRerankingStrategy = typeof crossEncoderRerankingStrategy;
export type AktorCrossEncoderRerankingStrategy = ToAktorStrategy<CrossEncoderRerankingStrategy>;

export const aktorCrossEncoderRerankingStrategy: AktorCrossEncoderRerankingStrategy = createAktorFunctionAsync(
'aktorCrossEncoderRerankingStrategy',
crossEncoderRerankingStrategy
);

// Helper functions
interface QueryDocumentPair {
query: string;
document: string;
originalResult: FilteredResult;
}

async function calculateCrossEncoderScores(
pairs: QueryDocumentPair[],
model: string
): Promise<number[]> {
// This would integrate with a transformer model service
// For example: Hugging Face Inference API, local model server, etc.

const inputs = pairs.map(pair => ({
query: pair.query,
document: truncateText(pair.document, 512), // Limit length for model
}));

const response = await fetch('/api/reranking/cross-encoder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model,
inputs: inputs,
}),
});

if (!response.ok) {
throw new Error(`Cross-encoder API error: ${response.status}`);
}

const data = await response.json();
return data.scores;
}

function truncateText(text: string, maxTokens: number): string {
// Rough token estimation and truncation
const estimatedTokens = Math.ceil(text.length / 4);

if (estimatedTokens <= maxTokens) {
return text;
}

const maxChars = maxTokens * 4;
return text.substring(0, maxChars) + '...';
}

Usage in Pipelines​

Using Custom Embedding Strategies​

import { aktorVektorEmbeddingPipeline } from '@operaide/vector';
import {
aktorPdfDocumentLoaderStrategy,
aktorSemanticChunkingStrategy,
aktorOpenAIEmbeddingStrategy
} from './custom-strategies';

// Use multiple custom strategies
const result = aktorVektorEmbeddingPipeline({
file: pdfFile,
client: dbClient,
documentLoaderStrategy: aktorPdfDocumentLoaderStrategy,
chunkingStrategy: aktorSemanticChunkingStrategy,
embeddingStrategy: aktorOpenAIEmbeddingStrategy,
});

Using Custom Retrieval Strategies​

import { aktorVektorRetrievalPipeline } from '@operaide/vector';
import {
aktorQueryExpansionStrategy,
aktorCrossEncoderRerankingStrategy
} from './custom-strategies';

// Use custom query expansion and reranking
const results = aktorVektorRetrievalPipeline({
client: dbClient,
query: userQuery,
limit: 10,
queryPreprocessingStrategy: aktorQueryExpansionStrategy,
rerankingStrategy: aktorCrossEncoderRerankingStrategy,
});

Testing Custom Strategies​

Unit Testing Pure Functions​

import { describe, it, expect } from 'vitest';
import { semanticChunkingStrategy } from './custom-strategies';

describe('semanticChunkingStrategy', () => {
it('should create semantic chunks with proper metadata', async () => {
const mockDocument = {
id: 'doc1',
title: 'Test Document',
content: 'First sentence. Second sentence. Third sentence.',
metadata: { type: 'text', source: 'test.txt' }
};

const result = await semanticChunkingStrategy({ document: mockDocument });

expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
documentId: 'doc1',
content: expect.stringContaining('First sentence'),
metadata: expect.objectContaining({
chunkingMethod: 'semantic',
coherenceScore: expect.any(Number),
})
});
});
});

Integration Testing with Aktors​

import { describe, it, expect } from 'vitest';
import { aktorSemanticChunkingStrategy } from './custom-strategies';

describe('aktorSemanticChunkingStrategy', () => {
it('should work as an Aktor strategy', async () => {
const mockDocument = /* test data */;

const chunksAktor = aktorSemanticChunkingStrategy({
document: () => mockDocument
});

const result = await chunksAktor.get();

expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
});

Best Practices​

1. Error Handling​

Always include try-catch blocks and fallback behavior:

export const robustStrategy = async ({ input }) => {
try {
return await primaryLogic(input);
} catch (error) {
console.error('Strategy failed:', error);
return await fallbackLogic(input);
}
};

2. Performance Optimization​

Use batching and caching for expensive operations:

export const optimizedStrategy = async ({ chunks }) => {
const batchSize = 100;
const results = [];

for (let i = 0; i < chunks.length; i += batchSize) {
const batch = chunks.slice(i, i + batchSize);
const batchResults = await processBatch(batch);
results.push(...batchResults);
}

return results;
};

3. Configuration​

Make strategies configurable:

export const createConfigurableStrategy = (config: StrategyConfig) => {
return async ({ input }) => {
// Use config to customize behavior
return await processWithConfig(input, config);
};
};

4. Type Safety​

Always use proper TypeScript types:

export const typedStrategy: CustomStrategyType = async ({ input }) => {
// TypeScript will enforce correct input/output types
return await processTypedInput(input);
};

Next Steps​