Skip to content

Commit bbfff0b

Browse files
committed
refactor: introduce backend-neutral embedding store interface
1 parent be9cae1 commit bbfff0b

24 files changed

+367
-160
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
one at a time. Includes Select All / Unselect All toggle.
1111
- Vector search now available on the journal tab (previously tasks-only).
1212

13+
### Changed
14+
- Refactored the embedding pipeline behind a backend-neutral store interface to
15+
prepare alternate vector backends without another large call-site migration.
16+
1317
### Removed
1418
- Removed "Re-index All Embeddings" maintenance action (superseded by
1519
multi-category generate).

flatpak/com.matthiasn.lotti.metainfo.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
<release version="0.9.905" date="2026-03-06">
3535
<description>
3636
<p>Unified embedding generation: select multiple categories at once with a Select All toggle. Vector search is now available on the journal tab alongside tasks. Removed the separate "Re-index All Embeddings" action.</p>
37+
<p>Refactored the embedding pipeline behind a backend-neutral store interface to prepare alternate vector backends without another large call-site migration.</p>
3738
</description>
3839
</release>
3940
<release version="0.9.904" date="2026-03-06">

lib/features/agents/state/agent_providers.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import 'package:lotti/features/agents/workflow/improver_agent_workflow.dart';
2222
import 'package:lotti/features/agents/workflow/task_agent_workflow.dart';
2323
import 'package:lotti/features/agents/workflow/template_evolution_workflow.dart';
2424
import 'package:lotti/features/ai/conversation/conversation_repository.dart';
25-
import 'package:lotti/features/ai/database/embeddings_db.dart';
25+
import 'package:lotti/features/ai/database/embedding_store.dart';
2626
import 'package:lotti/features/ai/repository/ai_config_repository.dart';
2727
import 'package:lotti/features/ai/repository/ai_input_repository.dart';
2828
import 'package:lotti/features/ai/repository/cloud_inference_repository.dart';
@@ -742,8 +742,8 @@ Future<List<AgentDomainEntity>> evolutionNotes(
742742
TaskAgentWorkflow taskAgentWorkflow(Ref ref) {
743743
// Embedding dependencies are optional — the pipeline may not be available
744744
// (e.g. missing native sqlite-vec library on CI).
745-
final embeddingsDb = getIt.isRegistered<EmbeddingsDb>()
746-
? getIt<EmbeddingsDb>()
745+
final embeddingStore = getIt.isRegistered<EmbeddingStore>()
746+
? getIt<EmbeddingStore>()
747747
: null;
748748
final embeddingRepository = getIt.isRegistered<OllamaEmbeddingRepository>()
749749
? getIt<OllamaEmbeddingRepository>()
@@ -762,7 +762,7 @@ TaskAgentWorkflow taskAgentWorkflow(Ref ref) {
762762
syncService: ref.watch(agentSyncServiceProvider),
763763
templateService: ref.watch(agentTemplateServiceProvider),
764764
domainLogger: ref.watch(domainLoggerProvider),
765-
embeddingsDb: embeddingsDb,
765+
embeddingStore: embeddingStore,
766766
embeddingRepository: embeddingRepository,
767767
);
768768
}

lib/features/agents/state/agent_providers.g.dart

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/features/agents/workflow/task_agent_workflow.dart

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import 'package:lotti/features/agents/workflow/task_tool_dispatcher.dart';
2424
import 'package:lotti/features/agents/workflow/wake_result.dart';
2525
import 'package:lotti/features/ai/conversation/conversation_manager.dart';
2626
import 'package:lotti/features/ai/conversation/conversation_repository.dart';
27-
import 'package:lotti/features/ai/database/embeddings_db.dart';
27+
import 'package:lotti/features/ai/database/embedding_store.dart';
2828
import 'package:lotti/features/ai/model/inference_usage.dart';
2929
import 'package:lotti/features/ai/repository/ai_config_repository.dart';
3030
import 'package:lotti/features/ai/repository/ai_input_repository.dart';
@@ -76,7 +76,7 @@ class TaskAgentWorkflow {
7676
required this.syncService,
7777
required this.templateService,
7878
this.domainLogger,
79-
this.embeddingsDb,
79+
this.embeddingStore,
8080
this.embeddingRepository,
8181
});
8282

@@ -101,7 +101,7 @@ class TaskAgentWorkflow {
101101
/// Optional embedding dependencies. When both are provided, agent reports
102102
/// are embedded for vector search after persistence. The pipeline is
103103
/// non-essential — if unavailable, reports are still persisted normally.
104-
final EmbeddingsDb? embeddingsDb;
104+
final EmbeddingStore? embeddingStore;
105105
final OllamaEmbeddingRepository? embeddingRepository;
106106

107107
static const _uuid = Uuid();
@@ -672,9 +672,9 @@ class TaskAgentWorkflow {
672672
required String taskId,
673673
String? previousReportId,
674674
}) async {
675-
final db = embeddingsDb;
675+
final store = embeddingStore;
676676
final repo = embeddingRepository;
677-
if (db == null || repo == null) return;
677+
if (store == null || repo == null) return;
678678

679679
try {
680680
final baseUrl = await aiConfigRepository.resolveOllamaBaseUrl();
@@ -690,7 +690,7 @@ class TaskAgentWorkflow {
690690
taskId: taskId,
691691
categoryId: categoryId,
692692
subtype: AgentReportScopes.current,
693-
embeddingsDb: db,
693+
embeddingStore: store,
694694
embeddingRepository: repo,
695695
baseUrl: baseUrl,
696696
);
@@ -699,7 +699,7 @@ class TaskAgentWorkflow {
699699
// so we don't lose search coverage if the embedding call fails or
700700
// the content is too short.
701701
if (didEmbed && previousReportId != null) {
702-
db.deleteEntityEmbeddings(previousReportId);
702+
store.deleteEntityEmbeddings(previousReportId);
703703
}
704704
} catch (e, s) {
705705
_logError('failed to embed agent report', error: e, stackTrace: s);

lib/features/ai/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ The AI feature consists of several key components:
1616
8. **Automatic Setup**: Model pre-population and intelligent defaults
1717
9. **Error Recovery**: Comprehensive error handling and user-friendly messages
1818

19+
## Embedding Search
20+
21+
The AI feature also owns the local embedding pipeline used for semantic search.
22+
23+
- **`database/embedding_store.dart`** defines the backend-neutral `EmbeddingStore` contract used by services, repositories, and maintenance UI.
24+
- **`database/sqlite_embedding_store.dart`** adapts the existing sqlite-vec `EmbeddingsDb` to that contract.
25+
- Higher-level code depends on `EmbeddingStore`, not directly on sqlite-vec internals, so the vector backend can be swapped in a future change without another large call-site migration.
26+
1927
## Architecture
2028

2129
### Core Components
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import 'dart:typed_data';
2+
3+
/// The fixed embedding dimension used by the current embedding model.
4+
const kEmbeddingDimensions = 1024;
5+
6+
/// Result of a vector similarity search.
7+
class EmbeddingSearchResult {
8+
const EmbeddingSearchResult({
9+
required this.entityId,
10+
required this.distance,
11+
required this.entityType,
12+
this.chunkIndex = 0,
13+
this.taskId = '',
14+
this.subtype = '',
15+
});
16+
17+
final String entityId;
18+
final double distance;
19+
final String entityType;
20+
21+
/// The zero-based chunk index within the source entity.
22+
///
23+
/// For short content that fits in a single chunk this is 0.
24+
/// For chunked content this identifies which segment matched.
25+
final int chunkIndex;
26+
27+
/// The task ID this embedding relates to, for direct lookup.
28+
///
29+
/// Populated for agent report embeddings to link back to the parent task.
30+
/// Empty string when not applicable.
31+
final String taskId;
32+
33+
/// The subtype of the embedding, e.g. agent template name.
34+
///
35+
/// Used to distinguish between multiple agent reports for the same task.
36+
/// Empty string when not applicable.
37+
final String subtype;
38+
}
39+
40+
/// Backend-neutral store for derived vector embeddings.
41+
abstract class EmbeddingStore {
42+
String? getContentHash(String entityId);
43+
44+
bool hasEmbedding(String entityId);
45+
46+
int get count;
47+
48+
void replaceEntityEmbeddings({
49+
required String entityId,
50+
required String entityType,
51+
required String modelId,
52+
required String contentHash,
53+
required List<Float32List> embeddings,
54+
String categoryId = '',
55+
String taskId = '',
56+
String subtype = '',
57+
});
58+
59+
void deleteEntityEmbeddings(String entityId);
60+
61+
List<EmbeddingSearchResult> search({
62+
required Float32List queryVector,
63+
int k = 10,
64+
String? entityTypeFilter,
65+
Set<String>? categoryIds,
66+
});
67+
68+
void deleteAll();
69+
70+
void close();
71+
}

lib/features/ai/database/embeddings_db.dart

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,11 @@
11
import 'dart:typed_data';
22

33
import 'package:clock/clock.dart';
4+
import 'package:lotti/features/ai/database/embedding_store.dart';
45
import 'package:sqlite3/sqlite3.dart';
56

6-
/// The fixed embedding dimension used by the vec0 virtual table.
7-
///
8-
/// This must match the `float[N]` declaration in the vec0 schema.
9-
const kEmbeddingDimensions = 1024;
10-
11-
/// Result of a vector similarity search.
12-
class EmbeddingSearchResult {
13-
const EmbeddingSearchResult({
14-
required this.entityId,
15-
required this.distance,
16-
required this.entityType,
17-
this.chunkIndex = 0,
18-
this.taskId = '',
19-
this.subtype = '',
20-
});
21-
22-
final String entityId;
23-
final double distance;
24-
final String entityType;
25-
26-
/// The zero-based chunk index within the source entity.
27-
///
28-
/// For short content that fits in a single chunk this is 0.
29-
/// For chunked content this identifies which segment matched.
30-
final int chunkIndex;
31-
32-
/// The task ID this embedding relates to, for direct lookup.
33-
///
34-
/// Populated for agent report embeddings to link back to the parent task.
35-
/// Empty string when not applicable.
36-
final String taskId;
37-
38-
/// The subtype of the embedding, e.g. agent template name.
39-
///
40-
/// Used to distinguish between multiple agent reports for the same task.
41-
/// Empty string when not applicable.
42-
final String subtype;
43-
}
7+
export 'package:lotti/features/ai/database/embedding_store.dart'
8+
show EmbeddingSearchResult, kEmbeddingDimensions;
449

4510
/// Standalone vector-embedding database backed by sqlite-vec.
4611
///
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:lotti/features/ai/database/embedding_store.dart';
4+
import 'package:lotti/features/ai/database/embeddings_db.dart';
5+
6+
/// Adapter that exposes the current sqlite-vec backend via [EmbeddingStore].
7+
class SqliteEmbeddingStore implements EmbeddingStore {
8+
SqliteEmbeddingStore(this._db);
9+
10+
final EmbeddingsDb _db;
11+
12+
@override
13+
int get count => _db.count;
14+
15+
@override
16+
void close() => _db.close();
17+
18+
@override
19+
void deleteAll() => _db.deleteAll();
20+
21+
@override
22+
void deleteEntityEmbeddings(String entityId) =>
23+
_db.deleteEntityEmbeddings(entityId);
24+
25+
@override
26+
String? getContentHash(String entityId) => _db.getContentHash(entityId);
27+
28+
@override
29+
bool hasEmbedding(String entityId) => _db.hasEmbedding(entityId);
30+
31+
@override
32+
void replaceEntityEmbeddings({
33+
required String entityId,
34+
required String entityType,
35+
required String modelId,
36+
required String contentHash,
37+
required List<Float32List> embeddings,
38+
String categoryId = '',
39+
String taskId = '',
40+
String subtype = '',
41+
}) {
42+
_db.deleteEntityEmbeddings(entityId);
43+
for (var i = 0; i < embeddings.length; i++) {
44+
_db.upsertEmbedding(
45+
entityId: entityId,
46+
chunkIndex: i,
47+
entityType: entityType,
48+
modelId: modelId,
49+
embedding: embeddings[i],
50+
contentHash: contentHash,
51+
categoryId: categoryId,
52+
taskId: taskId,
53+
subtype: subtype,
54+
);
55+
}
56+
}
57+
58+
@override
59+
List<EmbeddingSearchResult> search({
60+
required Float32List queryVector,
61+
int k = 10,
62+
String? entityTypeFilter,
63+
Set<String>? categoryIds,
64+
}) {
65+
return _db.search(
66+
queryVector: queryVector,
67+
k: k,
68+
entityTypeFilter: entityTypeFilter,
69+
categoryIds: categoryIds,
70+
);
71+
}
72+
}

lib/features/ai/repository/ollama_embedding_repository.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import 'dart:io';
55
import 'dart:typed_data';
66

77
import 'package:http/http.dart' as http;
8-
import 'package:lotti/features/ai/database/embeddings_db.dart';
8+
import 'package:lotti/features/ai/database/embedding_store.dart';
99
import 'package:lotti/features/ai/repository/ollama_inference_repository.dart';
1010
import 'package:lotti/features/ai/state/consts.dart';
1111

1212
/// Repository for generating text embeddings via Ollama's `/api/embed` endpoint.
1313
///
1414
/// Uses `mxbai-embed-large` (1024 dimensions) by default. The returned
15-
/// [Float32List] can be stored directly in [EmbeddingsDb].
15+
/// [Float32List] can be stored directly in an [EmbeddingStore].
1616
///
1717
/// Follows the same HTTP/retry/error patterns as [OllamaInferenceRepository].
1818
class OllamaEmbeddingRepository {

0 commit comments

Comments
 (0)