search method
Search for relevant chunks and assemble context for LLM.
adjacentChunks - Number of adjacent chunks to include before/after each
matched chunk (default: 0). Setting this to 1 will include the chunk
before and after each matched chunk, helping with long articles.
singleSourceMode - If true, only include chunks from the most relevant source.
Implementation
Future<RagSearchResult> search(
String query, {
int topK = 10,
int tokenBudget = 2000,
ContextStrategy strategy = ContextStrategy.relevanceFirst,
int adjacentChunks = 0,
bool singleSourceMode = false,
List<int>? sourceIds,
}) async {
// 1. Generate query embedding
final queryEmbedding = await EmbeddingService.embed(query);
// DEBUG: Log embedding stats
final embNorm = queryEmbedding.fold<double>(0, (sum, v) => sum + v * v);
debugPrint('[DEBUG] Query: "$query"');
debugPrint(
'[DEBUG] Embedding norm: ${embNorm.toStringAsFixed(4)}, dims: ${queryEmbedding.length}',
);
debugPrint(
'[DEBUG] First 5 values: ${queryEmbedding.take(5).map((v) => v.toStringAsFixed(4)).toList()}',
);
// 2. Search chunks
late List<ChunkSearchResult> chunks;
try {
if (sourceIds != null && sourceIds.isNotEmpty) {
// Use hybrid search with filter for vector search capability is limited in filters
// For strictly vector search with filters, we'd need HNSW filtering which is more complex.
// For now, we'll use hybrid search with 1.0 vector weight if sourceIds are provided.
final hybridResults = await searchHybrid(
query,
topK: topK,
vectorWeight: 1.0,
bm25Weight: 0.0,
sourceIds: sourceIds,
);
chunks = hybridResults
.map(
(r) => ChunkSearchResult(
chunkId: r.docId,
sourceId: r.sourceId,
content: r.content,
chunkIndex: r.chunkIndex,
chunkType: 'general',
similarity: r.score,
metadata: r.metadata,
),
)
.toList();
} else {
chunks = await rust_rag.searchChunks(
queryEmbedding: queryEmbedding,
topK: topK,
);
}
} on RagError catch (e) {
e.when(
databaseError: (msg) =>
debugPrint('[SmartError] Search failed (database): $msg'),
ioError: (msg) => debugPrint('[SmartError] Search IO error: $msg'),
modelLoadError: (_) {},
invalidInput: (msg) =>
debugPrint('[SmartError] Invalid search parameters: $msg'),
internalError: (msg) =>
debugPrint('[SmartError] Search engine failure: $msg'),
unknown: (msg) => debugPrint('[SmartError] Unknown search error: $msg'),
);
rethrow;
}
// 3. Filter to single source FIRST (before adjacent expansion)
// Pass the original query for text matching
if (singleSourceMode && chunks.isNotEmpty) {
chunks = _filterToMostRelevantSource(chunks, query);
}
// 4. Expand with adjacent chunks (only for the selected source)
if (adjacentChunks > 0 && chunks.isNotEmpty) {
chunks = await _expandWithAdjacentChunks(chunks, adjacentChunks);
}
// 5. Assemble context (pass singleSourceMode to skip headers when single source)
final context = ContextBuilder.build(
searchResults: chunks,
tokenBudget: tokenBudget,
strategy: strategy,
singleSourceMode: singleSourceMode, // Pass through to skip headers
);
return RagSearchResult(chunks: chunks, context: context);
}