feat: implement remote server search capabilities with WebSocket support

This commit is contained in:
David Snelling 2025-06-06 10:48:01 -07:00
parent 6ccbd01654
commit bf3d9a055b
3 changed files with 460 additions and 5 deletions

View file

@ -600,6 +600,7 @@ The repository includes several examples:
- Custom storage: `examples/customStorage.js`
- Memory augmentations: `examples/memoryAugmentationExample.js`
- Conduit augmentations: `examples/conduitAugmentationExample.js`
- Browser-server search: `examples/browser-server-search/` - Search a server-hosted Brainy instance from a browser
### Syncing Brainy Instances
@ -728,6 +729,50 @@ if (connectionResult[0] && (await connectionResult[0]).success) {
}
```
#### Browser-Server Search Example
Brainy supports searching a server-hosted instance from a browser, storing results locally, and performing further searches against the local instance:
```typescript
import { BrainyData } from '@soulcraft/brainy'
// Create and initialize the database with remote server configuration
const db = new BrainyData({
remoteServer: {
url: 'wss://your-brainy-server.com/ws',
protocols: 'brainy-sync',
autoConnect: true // Connect automatically during initialization
}
})
await db.init()
// Or connect manually after initialization
if (!db.isConnectedToRemoteServer()) {
await db.connectToRemoteServer('wss://your-brainy-server.com/ws', 'brainy-sync')
}
// Search the remote server (results are stored locally)
const remoteResults = await db.searchText('machine learning', 5, { searchMode: 'remote' })
// Search the local database (includes previously stored results)
const localResults = await db.searchText('machine learning', 5, { searchMode: 'local' })
// Perform a combined search (local first, then remote if needed)
const combinedResults = await db.searchText('neural networks', 5, { searchMode: 'combined' })
// Add data to both local and remote instances
const id = await db.addToBoth('Deep learning is a subset of machine learning', {
noun: 'Concept',
category: 'AI',
tags: ['deep learning', 'neural networks']
})
// Clean up when done
await db.shutDown()
```
For a complete example with HTML interface, see the [browser-server-search example](examples/browser-server-search/).
## 📋 Requirements
- Node.js >= 23.11.0

View file

@ -18,6 +18,11 @@ import {
} from './coreTypes.js'
import { cosineDistance, defaultEmbeddingFunction, euclideanDistance } from './utils/index.js'
import { NounType, VerbType, GraphNoun } from './types/graphTypes.js'
import {
ServerSearchConduitAugmentation,
createServerSearchAugmentations
} from './augmentations/serverSearchAugmentations.js'
import { WebSocketConnection } from './types/augmentations.js'
export interface BrainyDataConfig {
/**
@ -87,6 +92,26 @@ export interface BrainyDataConfig {
* When true, all write operations will throw an error
*/
readOnly?: boolean
/**
* Remote server configuration for search operations
*/
remoteServer?: {
/**
* WebSocket URL of the remote Brainy server
*/
url: string;
/**
* WebSocket protocols to use for the connection
*/
protocols?: string | string[];
/**
* Whether to automatically connect to the remote server on initialization
*/
autoConnect?: boolean;
}
}
export class BrainyData<T = any> {
@ -98,6 +123,11 @@ export class BrainyData<T = any> {
private readOnly: boolean
private storageConfig: BrainyDataConfig['storage'] = {}
// Remote server properties
private remoteServerConfig: BrainyDataConfig['remoteServer'] | null = null
private serverSearchConduit: any = null // Will be ServerSearchConduitAugmentation when connected
private serverConnection: any = null // Will be WebSocketConnection when connected
/**
* Create a new vector database
*/
@ -125,6 +155,11 @@ export class BrainyData<T = any> {
// Store storage configuration for later use in init()
this.storageConfig = config.storage || {}
// Store remote server configuration if provided
if (config.remoteServer) {
this.remoteServerConfig = config.remoteServer
}
}
/**
@ -174,6 +209,19 @@ export class BrainyData<T = any> {
})
}
// Connect to remote server if configured with autoConnect
if (this.remoteServerConfig && this.remoteServerConfig.autoConnect) {
try {
await this.connectToRemoteServer(
this.remoteServerConfig.url,
this.remoteServerConfig.protocols
)
} catch (remoteError) {
console.warn('Failed to auto-connect to remote server:', remoteError)
// Continue initialization even if remote connection fails
}
}
this.isInitialized = true
} catch (error) {
console.error('Failed to initialize BrainyData:', error)
@ -181,6 +229,39 @@ export class BrainyData<T = any> {
}
}
/**
* Connect to a remote Brainy server for search operations
* @param serverUrl WebSocket URL of the remote Brainy server
* @param protocols Optional WebSocket protocols to use
* @returns The connection object
*/
public async connectToRemoteServer(
serverUrl: string,
protocols?: string | string[]
): Promise<WebSocketConnection> {
await this.ensureInitialized()
try {
// Create server search augmentations
const { conduit, connection } = await createServerSearchAugmentations(
serverUrl,
{
protocols,
localDb: this
}
)
// Store the conduit and connection
this.serverSearchConduit = conduit
this.serverConnection = connection
return connection
} catch (error) {
console.error('Failed to connect to remote server:', error)
throw new Error(`Failed to connect to remote server: ${error}`)
}
}
/**
* Add a vector or data to the database
* If the input is not a vector, it will be converted using the embedding function
@ -193,7 +274,8 @@ export class BrainyData<T = any> {
vectorOrData: Vector | any,
metadata?: T,
options: {
forceEmbed?: boolean // Force using the embedding function even if input is a vector
forceEmbed?: boolean, // Force using the embedding function even if input is a vector
addToRemote?: boolean // Whether to also add to the remote server if connected
} = {}
): Promise<string> {
await this.ensureInitialized()
@ -261,6 +343,15 @@ export class BrainyData<T = any> {
await this.storage!.saveMetadata(id, metadata)
}
// If addToRemote is true and we're connected to a remote server, add to remote as well
if (options.addToRemote && this.isConnectedToRemoteServer()) {
try {
await this.addToRemote(id, vector, metadata)
} catch (remoteError) {
console.warn(`Failed to add to remote server: ${remoteError}. Continuing with local add.`)
}
}
return id
} catch (error) {
console.error('Failed to add vector:', error)
@ -268,6 +359,65 @@ export class BrainyData<T = any> {
}
}
/**
* Add data to both local and remote Brainy instances
* @param vectorOrData Vector or data to add
* @param metadata Optional metadata to associate with the vector
* @param options Additional options
* @returns The ID of the added vector
*/
public async addToBoth(
vectorOrData: Vector | any,
metadata?: T,
options: {
forceEmbed?: boolean // Force using the embedding function even if input is a vector
} = {}
): Promise<string> {
// Check if connected to a remote server
if (!this.isConnectedToRemoteServer()) {
throw new Error('Not connected to a remote server. Call connectToRemoteServer() first.')
}
// Add to local with addToRemote option
return this.add(vectorOrData, metadata, { ...options, addToRemote: true })
}
/**
* Add a vector to the remote server
* @param id ID of the vector to add
* @param vector Vector to add
* @param metadata Optional metadata to associate with the vector
* @returns True if successful, false otherwise
* @private
*/
private async addToRemote(
id: string,
vector: Vector,
metadata?: T
): Promise<boolean> {
if (!this.isConnectedToRemoteServer()) {
return false
}
try {
// Add to remote server
const addResult = await this.serverSearchConduit.addToBoth(
this.serverConnection.connectionId,
vector,
metadata
)
if (!addResult.success) {
throw new Error(`Remote add failed: ${addResult.error}`)
}
return true
} catch (error) {
console.error('Failed to add to remote server:', error)
throw new Error(`Failed to add to remote server: ${error}`)
}
}
/**
* Add multiple vectors or data items to the database
* @param items Array of items to add
@ -280,7 +430,8 @@ export class BrainyData<T = any> {
metadata?: T
}>,
options: {
forceEmbed?: boolean // Force using the embedding function even if input is a vector
forceEmbed?: boolean, // Force using the embedding function even if input is a vector
addToRemote?: boolean // Whether to also add to the remote server if connected
} = {}
): Promise<string[]> {
await this.ensureInitialized()
@ -303,6 +454,30 @@ export class BrainyData<T = any> {
}
}
/**
* Add multiple vectors or data items to both local and remote databases
* @param items Array of items to add
* @param options Additional options
* @returns Array of IDs for the added items
*/
public async addBatchToBoth(
items: Array<{
vectorOrData: Vector | any;
metadata?: T
}>,
options: {
forceEmbed?: boolean // Force using the embedding function even if input is a vector
} = {}
): Promise<string[]> {
// Check if connected to a remote server
if (!this.isConnectedToRemoteServer()) {
throw new Error('Not connected to a remote server. Call connectToRemoteServer() first.')
}
// Add to local with addToRemote option
return this.addBatch(items, { ...options, addToRemote: true })
}
/**
* Search for similar vectors within specific noun types
* @param queryVectorOrData Query vector or data to search for
@ -430,6 +605,36 @@ export class BrainyData<T = any> {
* @returns Array of search results
*/
public async search(
queryVectorOrData: Vector | any,
k: number = 10,
options: {
forceEmbed?: boolean, // Force using the embedding function even if input is a vector
nounTypes?: string[], // Optional array of noun types to search within
includeVerbs?: boolean, // Whether to include associated GraphVerbs in the results
searchMode?: 'local' | 'remote' | 'combined' // Where to search: local, remote, or both
} = {}
): Promise<SearchResult<T>[]> {
// If a specific search mode is specified, use the appropriate search method
if (options.searchMode === 'local') {
return this.searchLocal(queryVectorOrData, k, options)
} else if (options.searchMode === 'remote') {
return this.searchRemote(queryVectorOrData, k, options)
} else if (options.searchMode === 'combined') {
return this.searchCombined(queryVectorOrData, k, options)
}
// Default behavior (backward compatible): search locally
return this.searchLocal(queryVectorOrData, k, options)
}
/**
* Search the local database for similar vectors
* @param queryVectorOrData Query vector or data to search for
* @param k Number of results to return
* @param options Additional options
* @returns Array of search results
*/
public async searchLocal(
queryVectorOrData: Vector | any,
k: number = 10,
options: {
@ -877,7 +1082,8 @@ export class BrainyData<T = any> {
k: number = 10,
options: {
nounTypes?: string[],
includeVerbs?: boolean
includeVerbs?: boolean,
searchMode?: 'local' | 'remote' | 'combined'
} = {}
): Promise<SearchResult<T>[]> {
await this.ensureInitialized()
@ -889,7 +1095,8 @@ export class BrainyData<T = any> {
// Search using the embedded vector
return await this.search(queryVector, k, {
nounTypes: options.nounTypes,
includeVerbs: options.includeVerbs
includeVerbs: options.includeVerbs,
searchMode: options.searchMode
})
} catch (error) {
console.error('Failed to search with text query:', error)
@ -897,6 +1104,181 @@ export class BrainyData<T = any> {
}
}
/**
* Search a remote Brainy server for similar vectors
* @param queryVectorOrData Query vector or data to search for
* @param k Number of results to return
* @param options Additional options
* @returns Array of search results
*/
public async searchRemote(
queryVectorOrData: Vector | any,
k: number = 10,
options: {
forceEmbed?: boolean, // Force using the embedding function even if input is a vector
nounTypes?: string[], // Optional array of noun types to search within
includeVerbs?: boolean, // Whether to include associated GraphVerbs in the results
storeResults?: boolean // Whether to store the results in the local database (default: true)
} = {}
): Promise<SearchResult<T>[]> {
await this.ensureInitialized()
// Check if connected to a remote server
if (!this.isConnectedToRemoteServer()) {
throw new Error('Not connected to a remote server. Call connectToRemoteServer() first.')
}
try {
// If input is a string, convert it to a query string for the server
let query: string
if (typeof queryVectorOrData === 'string') {
query = queryVectorOrData
} else {
// For vectors, we need to embed them as a string query
// This is a simplification - ideally we would send the vector directly
query = 'vector-query' // Placeholder, would need a better approach for vector queries
}
// Search the remote server
const searchResult = await this.serverSearchConduit.searchServer(
this.serverConnection.connectionId,
query,
k
)
if (!searchResult.success) {
throw new Error(`Remote search failed: ${searchResult.error}`)
}
return searchResult.data as SearchResult<T>[]
} catch (error) {
console.error('Failed to search remote server:', error)
throw new Error(`Failed to search remote server: ${error}`)
}
}
/**
* Search both local and remote Brainy instances, combining the results
* @param queryVectorOrData Query vector or data to search for
* @param k Number of results to return
* @param options Additional options
* @returns Array of search results
*/
public async searchCombined(
queryVectorOrData: Vector | any,
k: number = 10,
options: {
forceEmbed?: boolean, // Force using the embedding function even if input is a vector
nounTypes?: string[], // Optional array of noun types to search within
includeVerbs?: boolean, // Whether to include associated GraphVerbs in the results
localFirst?: boolean // Whether to search local first (default: true)
} = {}
): Promise<SearchResult<T>[]> {
await this.ensureInitialized()
// Check if connected to a remote server
if (!this.isConnectedToRemoteServer()) {
// If not connected to a remote server, just search locally
return this.searchLocal(queryVectorOrData, k, options)
}
try {
// Default to searching local first
const localFirst = options.localFirst !== false
if (localFirst) {
// Search local first
const localResults = await this.searchLocal(queryVectorOrData, k, options)
// If we have enough local results, return them
if (localResults.length >= k) {
return localResults
}
// Otherwise, search remote for additional results
const remoteResults = await this.searchRemote(
queryVectorOrData,
k - localResults.length,
{ ...options, storeResults: true }
)
// Combine results, removing duplicates
const combinedResults = [...localResults]
const localIds = new Set(localResults.map(r => r.id))
for (const result of remoteResults) {
if (!localIds.has(result.id)) {
combinedResults.push(result)
}
}
return combinedResults
} else {
// Search remote first
const remoteResults = await this.searchRemote(
queryVectorOrData,
k,
{ ...options, storeResults: true }
)
// If we have enough remote results, return them
if (remoteResults.length >= k) {
return remoteResults
}
// Otherwise, search local for additional results
const localResults = await this.searchLocal(queryVectorOrData, k - remoteResults.length, options)
// Combine results, removing duplicates
const combinedResults = [...remoteResults]
const remoteIds = new Set(remoteResults.map(r => r.id))
for (const result of localResults) {
if (!remoteIds.has(result.id)) {
combinedResults.push(result)
}
}
return combinedResults
}
} catch (error) {
console.error('Failed to perform combined search:', error)
throw new Error(`Failed to perform combined search: ${error}`)
}
}
/**
* Check if the instance is connected to a remote server
* @returns True if connected to a remote server, false otherwise
*/
public isConnectedToRemoteServer(): boolean {
return !!(this.serverSearchConduit && this.serverConnection)
}
/**
* Disconnect from the remote server
* @returns True if successfully disconnected, false if not connected
*/
public async disconnectFromRemoteServer(): Promise<boolean> {
if (!this.isConnectedToRemoteServer()) {
return false
}
try {
// Close the WebSocket connection
await this.serverSearchConduit.closeWebSocket(this.serverConnection.connectionId)
// Clear the connection information
this.serverSearchConduit = null
this.serverConnection = null
return true
} catch (error) {
console.error('Failed to disconnect from remote server:', error)
throw new Error(`Failed to disconnect from remote server: ${error}`)
}
}
/**
* Ensure the database is initialized
*/
@ -981,6 +1363,26 @@ export class BrainyData<T = any> {
}
}
/**
* Shut down the database and clean up resources
* This should be called when the database is no longer needed
*/
public async shutDown(): Promise<void> {
try {
// Disconnect from remote server if connected
if (this.isConnectedToRemoteServer()) {
await this.disconnectFromRemoteServer()
}
// Additional cleanup could be added here in the future
this.isInitialized = false
} catch (error) {
console.error('Failed to shut down BrainyData:', error)
throw new Error(`Failed to shut down BrainyData: ${error}`)
}
}
/**
* Generate a random graph of data with typed nouns and verbs for testing and experimentation
* @param options Configuration options for the random graph

View file

@ -136,6 +136,11 @@ import {
WebRTCConduitAugmentation,
createConduitAugmentation
} from './augmentations/conduitAugmentations.js'
import {
ServerSearchConduitAugmentation,
ServerSearchActivationAugmentation,
createServerSearchAugmentations
} from './augmentations/serverSearchAugmentations.js'
export {
MemoryStorageAugmentation,
@ -144,7 +149,10 @@ export {
createMemoryAugmentation,
WebSocketConduitAugmentation,
WebRTCConduitAugmentation,
createConduitAugmentation
createConduitAugmentation,
ServerSearchConduitAugmentation,
ServerSearchActivationAugmentation,
createServerSearchAugmentations
}