Skip to content
Open
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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -370,10 +370,30 @@ ZAPIER_NLA_API_KEY=
#==================================================#

SEARCH=true

# Search Provider: 'meilisearch' (default), 'opensearch', or 'typesense'
# If not set, auto-detected from available env vars below.
# SEARCH_PROVIDER=meilisearch

#------- MeiliSearch (default) -------#
MEILI_NO_ANALYTICS=true
MEILI_HOST=http://0.0.0.0:7700
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt

#------- OpenSearch -------#
# Uncomment and configure to use OpenSearch instead of MeiliSearch.
# Setting OPENSEARCH_HOST will auto-select OpenSearch as the provider.
# OPENSEARCH_HOST=https://localhost:9200
# OPENSEARCH_USERNAME=admin
# OPENSEARCH_PASSWORD=
# OPENSEARCH_INSECURE=true

#------- Typesense -------#
# Uncomment and configure to use Typesense instead of MeiliSearch.
# Setting TYPESENSE_HOST + TYPESENSE_API_KEY will auto-select Typesense as the provider.
# TYPESENSE_HOST=http://localhost:8108
# TYPESENSE_API_KEY=

# Optional: Disable indexing, useful in a multi-node setup
# where only one instance should perform an index sync.
# MEILI_NO_SYNC=true
Expand Down
182 changes: 164 additions & 18 deletions api/db/indexSync.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const mongoose = require('mongoose');
const { MeiliSearch } = require('meilisearch');
const { logger } = require('@librechat/data-schemas');
const { logger, getSearchProvider, detectSearchProvider } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { isEnabled, FlowStateManager } = require('@librechat/api');
const { getLogStores } = require('~/cache');
Expand Down Expand Up @@ -188,7 +188,126 @@ async function ensureFilterableAttributes(client) {
}

/**
* Performs the actual sync operations for messages and conversations
* Ensures indexes have proper filterable attributes for non-MeiliSearch providers.
* @param {import('./search/searchProvider').SearchProvider} provider - Search provider instance
* @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>}
*/
async function ensureFilterableAttributesGeneric(provider) {
let settingsUpdated = false;
let hasOrphanedDocs = false;

try {
for (const indexName of ['messages', 'convos']) {
try {
const settings = await provider.getIndexSettings(indexName);
const filterableAttrs = settings.filterableAttributes || [];

if (!filterableAttrs.includes('user')) {
logger.info(`[indexSync] Configuring ${indexName} index to filter by user...`);
await provider.updateIndexSettings(indexName, {
filterableAttributes: ['user'],
});
logger.info(`[indexSync] ${indexName} index configured for user filtering`);
settingsUpdated = true;
}

// Check for orphaned documents
try {
const searchResult = await provider.search(indexName, '', { limit: 1 });
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
logger.info(
`[indexSync] Existing ${indexName} missing user field, will clean up orphaned documents...`,
);
hasOrphanedDocs = true;
}
} catch (searchError) {
logger.debug(`[indexSync] Could not check ${indexName} documents:`, searchError.message);
}
} catch (error) {
logger.warn(
`[indexSync] Could not check/update ${indexName} index settings:`,
error.message,
);
}
}

if (hasOrphanedDocs) {
for (const indexName of ['messages', 'convos']) {
try {
await deleteDocumentsWithoutUserFieldGeneric(provider, indexName);
} catch (error) {
logger.debug(`[indexSync] Could not clean up ${indexName}:`, error.message);
}
}
logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.');
}

if (settingsUpdated) {
logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.');
}
} catch (error) {
logger.error('[indexSync] Error ensuring filterable attributes:', error);
}

return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs };
}

/**
* Deletes documents without user field from a search index (generic provider version).
* @param {import('./search/searchProvider').SearchProvider} provider
* @param {string} indexName
* @returns {Promise<number>}
*/
async function deleteDocumentsWithoutUserFieldGeneric(provider, indexName) {
let deletedCount = 0;
let offset = 0;
const batchSize = 1000;
const primaryKey = indexName === 'messages' ? 'messageId' : 'conversationId';

try {
while (true) {
const searchResult = await provider.search(indexName, '', {
limit: batchSize,
offset: offset,
});

if (searchResult.hits.length === 0) {
break;
}

const idsToDelete = searchResult.hits
.filter((hit) => !hit.user)
.map((hit) => hit.id || hit[primaryKey])
.filter(Boolean);

if (idsToDelete.length > 0) {
logger.info(
`[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`,
);
await provider.deleteDocuments(indexName, idsToDelete);
deletedCount += idsToDelete.length;
}

if (searchResult.hits.length < batchSize) {
break;
}

offset += batchSize;
}

if (deletedCount > 0) {
logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`);
}
} catch (error) {
logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error);
}

return deletedCount;
}

/**
* Performs the actual sync operations for messages and conversations.
* Supports both MeiliSearch (legacy) and generic search providers (OpenSearch, etc.).
* @param {FlowStateManager} flowManager - Flow state manager instance
* @param {string} flowId - Flow identifier
* @param {string} flowType - Flow type
Expand All @@ -200,16 +319,39 @@ async function performSync(flowManager, flowId, flowType) {
return { messagesSync: false, convosSync: false };
}

const client = MeiliSearchClient.getInstance();
const providerType = detectSearchProvider ? detectSearchProvider() : 'meilisearch';
let settingsUpdated = false;
let _orphanedDocsFound = false;

const { status } = await client.health();
if (status !== 'available') {
throw new Error('Meilisearch not available');
}
if (providerType === 'meilisearch') {
// Legacy MeiliSearch path — fully backward compatible
const client = MeiliSearchClient.getInstance();

const { status } = await client.health();
if (status !== 'available') {
throw new Error('Meilisearch not available');
}

/** Ensures indexes have proper filterable attributes configured */
const result = await ensureFilterableAttributes(client);
settingsUpdated = result.settingsUpdated;
_orphanedDocsFound = result.orphanedDocsFound;
} else {
// Generic provider path (OpenSearch, etc.)
const provider = getSearchProvider ? getSearchProvider() : null;
if (!provider) {
throw new Error('Search provider not configured');
}

/** Ensures indexes have proper filterable attributes configured */
const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
await ensureFilterableAttributes(client);
const healthy = await provider.healthCheck();
if (!healthy) {
throw new Error(`${providerType} not available`);
}

const result = await ensureFilterableAttributesGeneric(provider);
settingsUpdated = result.settingsUpdated;
_orphanedDocsFound = result.orphanedDocsFound;
}

let messagesSync = false;
let convosSync = false;
Expand Down Expand Up @@ -239,7 +381,7 @@ async function performSync(flowManager, flowId, flowType) {

if (settingsUpdated || unindexedMessages > syncThreshold) {
logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
await Message.syncWithMeili();
await (Message.syncWithSearch || Message.syncWithMeili).call(Message);
messagesSync = true;
} else if (unindexedMessages > 0) {
logger.info(
Expand All @@ -265,7 +407,7 @@ async function performSync(flowManager, flowId, flowType) {
const unindexedConvos = convoCount - convosIndexed;
if (settingsUpdated || unindexedConvos > syncThreshold) {
logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
await Conversation.syncWithMeili();
await (Conversation.syncWithSearch || Conversation.syncWithMeili).call(Conversation);
convosSync = true;
} else if (unindexedConvos > 0) {
logger.info(
Expand Down Expand Up @@ -315,8 +457,9 @@ async function indexSync() {
});

// Use a unique flow ID for the sync operation
const flowId = 'meili-index-sync';
const flowType = 'MEILI_SYNC';
const providerType = detectSearchProvider ? detectSearchProvider() : 'meilisearch';
const flowId = `search-index-sync-${providerType}`;
const flowType = 'SEARCH_SYNC';

try {
// This will only execute the handler if no other instance is running the sync
Expand All @@ -341,14 +484,17 @@ async function indexSync() {
logger.debug('[indexSync] Creating indices...');
currentTimeout = setTimeout(async () => {
try {
await Message.syncWithMeili();
await Conversation.syncWithMeili();
await (Message.syncWithSearch || Message.syncWithMeili).call(Message);
await (Conversation.syncWithSearch || Conversation.syncWithMeili).call(Conversation);
} catch (err) {
logger.error('[indexSync] Trouble creating indices, try restarting the server.', err);
}
}, 750);
} else if (err.message.includes('Meilisearch not configured')) {
logger.info('[indexSync] Meilisearch not configured, search will be disabled.');
} else if (
err.message.includes('Meilisearch not configured') ||
err.message.includes('Search provider not configured')
) {
logger.info('[indexSync] Search provider not configured, search will be disabled.');
} else {
logger.error('[indexSync] error', err);
}
Expand Down
30 changes: 25 additions & 5 deletions api/models/Conversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,37 @@

if (search) {
try {
const meiliResults = await Conversation.meiliSearch(search, { filter: `user = "${user}"` });
const matchingIds = Array.isArray(meiliResults.hits)
? meiliResults.hits.map((result) => result.conversationId)
// Use Mongoose model's meiliSearch method which is aliased to searchIndex
// and works with all search providers (MeiliSearch, OpenSearch, Typesense)
const { Message } = require('~/db/models');

// Search both conversations (by title) and messages (by content)
const [convoResults, messageResults] = await Promise.all([
Conversation.meiliSearch(search, { filter: `user = "${user}"` }),
Message.meiliSearch(search, { filter: `user = "${user}"` }),
]);

logger.info(`[getConvosByCursor] Search results - convo hits: ${convoResults?.hits?.length || 0}, message hits: ${messageResults?.hits?.length || 0}`);

Check failure on line 200 in api/models/Conversation.js

View workflow job for this annotation

GitHub Actions / Run ESLint Linting

Replace ``[getConvosByCursor]·Search·results·-·convo·hits:·${convoResults?.hits?.length·||·0},·message·hits:·${messageResults?.hits?.length·||·0}`` with `⏎··········`[getConvosByCursor]·Search·results·-·convo·hits:·${convoResults?.hits?.length·||·0},·message·hits:·${messageResults?.hits?.length·||·0}`,⏎········`

const convoIds = Array.isArray(convoResults?.hits)
? convoResults.hits.map((result) => result.conversationId)
: [];
const messageConvoIds = Array.isArray(messageResults?.hits)
? messageResults.hits.map((result) => result.conversationId)
: [];

// Combine and deduplicate conversation IDs from both searches
const matchingIds = [...new Set([...convoIds, ...messageConvoIds])];

logger.info(`[getConvosByCursor] Total matching conversation IDs: ${matchingIds.length}`);

if (!matchingIds.length) {
return { conversations: [], nextCursor: null };
}
filters.push({ conversationId: { $in: matchingIds } });
} catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error);
throw new Error('Error during meiliSearch');
logger.error('[getConvosByCursor] Error during search', error);
throw new Error('Error during search');
}
}

Expand Down Expand Up @@ -228,7 +248,7 @@
},
],
};
} catch (err) {

Check warning on line 251 in api/models/Conversation.js

View workflow job for this annotation

GitHub Actions / Run ESLint Linting

'err' is defined but never used. Allowed unused caught errors must match /^_/u
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
}
if (cursorFilter) {
Expand Down
15 changes: 15 additions & 0 deletions api/server/routes/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
const { isEnabled } = require('@librechat/api');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');

const { getSearchProvider, detectSearchProvider } = require('@librechat/data-schemas');

const router = express.Router();

router.use(requireJwtAuth);
Expand All @@ -13,6 +15,19 @@
}

try {
const providerType = detectSearchProvider();

if (providerType && providerType !== 'meilisearch') {
// Use generic search provider (OpenSearch, etc.)
const provider = getSearchProvider();
if (!provider) {
return res.send(false);
}
const healthy = await provider.healthCheck();
return res.send(healthy);
}

// Default: MeiliSearch (backward compatible)
const client = new MeiliSearch({
host: process.env.MEILI_HOST,
apiKey: process.env.MEILI_MASTER_KEY,
Expand All @@ -20,7 +35,7 @@

const { status } = await client.health();
return res.send(status === 'available');
} catch (error) {

Check warning on line 38 in api/server/routes/search.js

View workflow job for this annotation

GitHub Actions / Run ESLint Linting

'error' is defined but never used. Allowed unused caught errors must match /^_/u
return res.send(false);
}
});
Expand Down
17 changes: 9 additions & 8 deletions config/reset-meili-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ const { batchResetMeiliFlags } = require('~/db/utils');
await connect();

console.purple('---------------------------------------');
console.purple('Reset MeiliSearch Synchronization Flags');
console.purple('Reset Search Synchronization Flags');
console.purple('---------------------------------------');
console.yellow('\nThis script will reset the MeiliSearch indexing flags in MongoDB.');
console.yellow('Use this when MeiliSearch data has been deleted or corrupted,');
console.yellow('and you need to trigger a full re-synchronization.\n');
console.yellow('\nThis script will reset the search indexing flags in MongoDB.');
console.yellow('Use this when search index data has been deleted or corrupted,');
console.yellow('and you need to trigger a full re-synchronization.');
console.yellow('Works with MeiliSearch, OpenSearch, and Typesense.\n');

const confirm = await askQuestion(
'Are you sure you want to reset all MeiliSearch sync flags? (y/N): ',
'Are you sure you want to reset all search sync flags? (y/N): ',
);

if (confirm.toLowerCase() !== 'y') {
Expand Down Expand Up @@ -55,12 +56,12 @@ const { batchResetMeiliFlags } = require('~/db/utils');
.countDocuments(queryTotal);

console.purple('\n---------------------------------------');
console.green('MeiliSearch sync flags have been reset successfully!');
console.green('Search sync flags have been reset successfully!');
console.cyan(`\nDocuments queued for sync:`);
console.cyan(`Messages: ${totalMessages}`);
console.cyan(`Conversations: ${totalConversations}`);
console.yellow('\nThe next time LibreChat starts or performs a sync check,');
console.yellow('all data will be re-indexed into MeiliSearch.');
console.yellow('all data will be re-indexed into your configured search provider.');
console.purple('---------------------------------------\n');

// Ask if user wants to see advanced options
Expand All @@ -81,7 +82,7 @@ const { batchResetMeiliFlags } = require('~/db/utils');

silentExit(0);
} catch (error) {
console.red('\nError resetting MeiliSearch sync flags:');
console.red('\nError resetting search sync flags:');
console.error(error);
silentExit(1);
}
Expand Down
Loading
Loading