Skip to main content

The Problem with Sonamu Search API

You’re building a knowledge base app with Sonamu:
class DocumentModelClass extends BaseModel {
  @api({ httpMethod: 'POST' })
  async search(query: string) {
    // Keyword search
    const results = await this.findMany({
      wq: [
        ['title', 'LIKE', `%${query}%`],
        ['content', 'LIKE', `%${query}%`],
      ],
    });
    return results;
  }
}
What happens when a user searches for β€œTypeScript framework”?
  • β€œSonamu is a TypeScript framework” - Found
  • β€œSonamu is a Node.js API library” - Not found (different keywords)
  • β€œSonamu is a TS framework” - Not found (abbreviation)
  • β€œTypeScript framwork” - Not found (typo)
Limitations of keyword search:
  • Doesn’t handle synonyms
  • Fails when expressions differ
  • Vulnerable to typos
  • Fails when meaning is the same but words differ

Semantic Search is Needed

β€œTypeScript framework” and β€œNode.js API library” are semantically similar, even though the keywords are different. How can computers understand this meaning? Embeddings

What are Embeddings?

Embeddings convert text into high-dimensional numerical arrays (vectors). Semantically similar texts are placed close together in vector space. Key points:
  • Converting to numbers enables distance calculation
  • Close vectors = semantically similar texts
  • Distant vectors = semantically different texts

Flow in Sonamu

// 1. When saving a document: text -> embedding -> DB
await DocumentModel.saveOne({
  title: "Getting Started with Sonamu",
  content: "...",
  embedding: [0.2, 0.8, -0.3, ...],  // 1024 dimensions
});

// 2. When searching: query -> embedding -> similarity calculation
const queryEmbedding = [0.3, 0.7, -0.2, ...];
const results = await searchSimilar(queryEmbedding);

The Embedding Class

Sonamu provides an Embedding class to easily create embeddings:
import { Embedding } from "sonamu/vector";

// Single text embedding
const result = await Embedding.embedOne(
  "Sonamu is a TypeScript framework",
  'voyage'  // 'voyage' | 'openai'
);

console.log(result.embedding);  // [0.123, -0.456, ...] (1024 dimensions)
console.log(result.model);      // "voyage-3"
console.log(result.tokenCount); // 8

Which Provider Should You Choose?

Voyage AI vs OpenAI

Sonamu supports two embedding providers:
ItemVoyage AIOpenAI
Korean performanceExcellentGood
English performanceExcellentExcellent
Dimensions10241536
Max tokens32,0008,191
Batch size128100
Asymmetric embeddingsYes (document/query distinction)No
Sonamu recommendationHighly recommendedRecommended

Selection Criteria for Sonamu Projects

Recommend Voyage AI:
  • Korean services (excellent Korean performance)
  • Long document processing (32,000 tokens)
  • Search accuracy matters (asymmetric embeddings)
Recommend OpenAI:
  • Global services (balanced multilingual support)
  • Already using OpenAI API

Environment Setup

1. Install Packages

pnpm add voyageai
# or
pnpm add @ai-sdk/openai

2. Configure API Keys

# Voyage AI (recommended)
VOYAGE_API_KEY=pa-...

# OpenAI (alternative)
OPENAI_API_KEY=sk-...
Get API keys from each provider’s website:

Using in Sonamu Model

Generating Embeddings When Saving Documents

import { BaseModel, api } from "sonamu";
import { Embedding } from "sonamu/vector";

class DocumentModelClass extends BaseModel {
  @api({ httpMethod: 'POST' })
  async createDocument(title: string, content: string) {
    // 1. Generate embedding
    const result = await Embedding.embedOne(
      `${title}\n\n${content}`,
      'voyage',
      'document'  // document embedding
    );

    // 2. Save to DB (Sonamu's saveOne)
    const doc = await this.saveOne({
      title,
      content,
      embedding: result.embedding,  // array of 1024 numbers
      token_count: result.tokenCount,
    });

    return doc;
  }
}
Flow:
  1. User uploads document via POST /documents
  2. Sonamu API generates embedding via Voyage AI
  3. PostgreSQL stores text + embedding together

Search API (to be implemented later)

@api({ httpMethod: 'POST' })
async search(query: string) {
  // 1. Generate query embedding
  const result = await Embedding.embedOne(
    query,
    'voyage',
    'query'  // search embedding
  );

  // 2. Vector search (see vector-search.mdx)
  const results = await this.getPuri().raw(`
    SELECT title, content,
      1 - (embedding <=> ?) AS similarity
    FROM documents
    WHERE embedding IS NOT NULL
    ORDER BY similarity DESC
    LIMIT 10
  `, [JSON.stringify(result.embedding)]);

  return results.rows;
}

Asymmetric Embeddings (Voyage AI)

Voyage AI distinguishes between document and query embeddings.

Why the Distinction?

Document:
  • Long text
  • Detailed information
  • Storage purpose
Query:
  • Short text
  • Search terms
  • Search purpose
Documents and queries have different characteristics. Voyage AI considers this to provide more accurate search results (10-15% improvement).

Usage in Sonamu

// When saving documents: document
const docEmbedding = await Embedding.embedOne(
  "Sonamu is a TypeScript full-stack framework. It provides API, DB, authentication, and more.",
  'voyage',
  'document'  // for documents
);

// When searching: query
const queryEmbedding = await Embedding.embedOne(
  "TypeScript framework",
  'voyage',
  'query'  // for search
);
OpenAI does not support asymmetric embeddings:
// OpenAI ignores inputType
await Embedding.embedOne(text, 'openai', 'document');  // 'document' is ignored

Batch Processing

When processing multiple documents at once:
@api({ httpMethod: 'POST' })
async batchCreateDocuments(documents: Array<{
  title: string;
  content: string;
}>) {
  // 1. Prepare text array
  const texts = documents.map(doc =>
    `${doc.title}\n\n${doc.content}`
  );

  // 2. Batch embedding (automatically splits into batches of 128)
  const embeddings = await Embedding.embed(
    texts,
    'voyage',
    'document'
  );

  // 3. Save to DB
  const savedDocs = await Promise.all(
    documents.map((doc, i) =>
      this.saveOne({
        title: doc.title,
        content: doc.content,
        embedding: embeddings[i].embedding,
        token_count: embeddings[i].tokenCount,
      })
    )
  );

  return savedDocs;
}
Automatic splitting:
  • Voyage AI: 128 at a time
  • OpenAI: 100 at a time
Even 1000 documents are automatically split and processed.

Progress Display

You can show progress when processing many documents:
@api({ httpMethod: 'POST' })
async importDocuments(files: string[]) {
  const texts = files.map(f => readFile(f));

  const embeddings = await Embedding.embed(
    texts,
    'voyage',
    'document',
    (processed, total) => {
      const percent = Math.round((processed / total) * 100);
      console.log(`Progress: ${processed}/${total} (${percent}%)`);
    }
  );

  // Progress: 128/1000 (13%)
  // Progress: 256/1000 (26%)
  // ...
  // Progress: 1000/1000 (100%)
}

Practical Scenario

Scenario: Customer Support Knowledge Base

You’re building a customer support system with Sonamu. Step 1: Document Upload API
class KnowledgeBaseModelClass extends BaseModel {
  @upload({ mode: 'single' })
  @api({ httpMethod: 'POST' })
  async uploadDocument() {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // Use first file

    // Read file
    const content = await file.toBuffer().then(b => b.toString());

    // Generate embedding
    const embedding = await Embedding.embedOne(
      content,
      'voyage',
      'document'
    );

    // Save
    return await this.saveOne({
      title: file.filename,
      content,
      embedding: embedding.embedding,
    });
  }
}
Step 2: Search API
@api({ httpMethod: 'POST' })
async searchSimilar(query: string) {
  // Query embedding
  const embedding = await Embedding.embedOne(query, 'voyage', 'query');

  // Search similar documents
  const results = await this.getPuri().raw(`
    SELECT id, title, content,
      1 - (embedding <=> ?) AS similarity
    FROM knowledge_base
    WHERE embedding IS NOT NULL
    ORDER BY similarity DESC
    LIMIT 5
  `, [JSON.stringify(embedding.embedding)]);

  return results.rows;
}
Step 3: Handling User Requests
POST /api/knowledge-base/search
{ "query": "What is the refund policy?" }

-> Returns 5 similar documents:
1. "Refund and Exchange Guide" (similarity: 0.89)
2. "How to Cancel a Purchase" (similarity: 0.82)
3. "Refund Periods by Payment Method" (similarity: 0.78)
...

Error Handling

@api({ httpMethod: 'POST' })
async createDocument(title: string, content: string) {
  try {
    const embedding = await Embedding.embedOne(
      `${title}\n\n${content}`,
      'voyage',
      'document'
    );

    return await this.saveOne({
      title,
      content,
      embedding: embedding.embedding,
    });
  } catch (error) {
    if (error.message.includes('API_KEY')) {
      throw new Error('Please set your Voyage API key: VOYAGE_API_KEY');
    } else if (error.message.includes('token')) {
      throw new Error('Text is too long (max 32,000 tokens)');
    } else {
      throw new Error(`Failed to generate embedding: ${error.message}`);
    }
  }
}

Cost Considerations

Token Calculation

const result = await Embedding.embedOne(
  "Sonamu is a TypeScript full-stack framework",
  'voyage'
);

console.log(`Tokens: ${result.tokenCount}`);  // 12

Cost Estimation

Voyage AI ($0.13 per 1M tokens):
// 1000 documents, average 500 tokens
// = 500,000 tokens
// = $0.065
OpenAI ($0.02 per 1M tokens):
// 1000 documents, average 500 tokens
// = 500,000 tokens
// = $0.01

Cost Reduction Tips

1. Caching
const cache = new Map<string, number[]>();

async function getCachedEmbedding(text: string) {
  if (cache.has(text)) {
    return cache.get(text)!;
  }

  const result = await Embedding.embedOne(text, 'voyage');
  cache.set(text, result.embedding);
  return result.embedding;
}
2. Deduplication
// Embed identical texts only once
const uniqueTexts = [...new Set(texts)];
const embeddings = await Embedding.embed(uniqueTexts, 'voyage');

Cautions

Cautions when using embeddings in Sonamu:
  1. API key required: Set environment variables
    VOYAGE_API_KEY=pa-...
    
  2. Dimension match: Must match DB schema
    -- Voyage AI
    CREATE TABLE docs (embedding vector(1024));
    
    -- OpenAI
    CREATE TABLE docs (embedding vector(1536));
    
  3. document vs query: Voyage distinguishes, OpenAI ignores
    // When saving
    await Embedding.embedOne(text, 'voyage', 'document');
    
    // When searching
    await Embedding.embedOne(text, 'voyage', 'query');
    
  4. Token limits: Chunking needed for very long texts
    • Voyage AI: 32,000 tokens
    • OpenAI: 8,191 tokens
  5. NULL handling: Can store NULL on embedding failure
    embedding: result?.embedding || null
    
  6. Cost monitoring: Track token usage
    console.log(`Total tokens: ${result.tokenCount}`);
    

Next Steps

Embedding generation is complete. Now it’s time to implement the search API in Sonamu Model.