search method

Future<RagSearchResult> search(
  1. String query, {
  2. int topK = 10,
  3. int tokenBudget = 2000,
  4. ContextStrategy strategy = ContextStrategy.relevanceFirst,
  5. int adjacentChunks = 0,
  6. bool singleSourceMode = false,
  7. List<int>? sourceIds,
})

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);
}