Packages
fnord
0.9.35
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/ai/embeddings/migration.ex
defmodule AI.Embeddings.Migration do
@moduledoc """
Detects and performs one-time migration from OpenAI embeddings to the local
embedding model. Migration is triggered when stored embedding vectors have
a different dimensionality than the current model expects.
The migration deletes all stored embeddings so they can be regenerated by
the background indexers or a manual `fnord index` run.
"""
@doc """
Checks whether migration is needed and performs it if so.
Returns :ok in all cases (migration is best-effort).
`context` controls the user-facing messaging only:
* `:ask` - rebuild happens in the background while ask runs
* `:index` - full foreground reindex is about to start
* `:read_only` - read-only command (search, conversations, memory);
no background indexer is running, user must run `fnord index`
"""
@spec maybe_migrate(:ask | :index | :read_only) :: :ok
def maybe_migrate(context) do
case detect_stale_embeddings() do
:ok -> :ok
:stale -> do_migrate(context)
end
end
# Sample one embedding file to check dimensions. If no embeddings exist
# yet, there's nothing to migrate.
defp detect_stale_embeddings do
expected = AI.Embeddings.dimensions()
with {:ok, project} <- Store.get_project(),
sample when not is_nil(sample) <- sample_embedding(project) do
if length(sample) == expected, do: :ok, else: :stale
else
# No project or no embeddings on disk - nothing to migrate
_ -> :ok
end
end
# Try to find one existing embedding vector from any index type.
defp sample_embedding(project) do
# File embeddings
sample_file_embedding(project) ||
sample_commit_embedding(project) ||
sample_conversation_embedding(project) ||
sample_memory_embedding(project)
end
defp sample_file_embedding(project) do
project
|> Store.Project.files_root()
|> Path.join("*/embeddings.json")
|> Path.wildcard()
|> List.first()
|> read_embedding_file()
end
defp sample_commit_embedding(project) do
project
|> Store.Project.CommitIndex.root()
|> Path.join("*/embeddings.json")
|> Path.wildcard()
|> List.first()
|> read_embedding_file()
end
defp sample_conversation_embedding(project) do
project
|> Store.Project.ConversationIndex.root()
|> Path.join("*/embeddings.json")
|> Path.wildcard()
|> List.first()
|> read_embedding_file()
end
defp sample_memory_embedding(project) do
project_dir = Path.join(project.store_path, "memory")
global_dir = Path.join(Settings.fnord_home(), "memory")
sample_memory_embedding_in_dir(project_dir) || sample_memory_embedding_in_dir(global_dir)
end
defp sample_memory_embedding_in_dir(dir) do
dir
|> Path.join("*.json")
|> Path.wildcard()
|> Enum.find_value(fn path ->
case File.read(path) do
{:ok, data} ->
case SafeJson.decode(data) do
{:ok, %{"embeddings" => emb}} when is_list(emb) and length(emb) > 0 -> emb
_ -> nil
end
_ ->
nil
end
end)
end
defp read_embedding_file(nil), do: nil
defp read_embedding_file(path) do
case File.read(path) do
{:ok, data} ->
case SafeJson.decode(data) do
{:ok, list} when is_list(list) -> list
_ -> nil
end
_ ->
nil
end
end
defp do_migrate(context) do
UI.warn("""
[Embeddings Migration] Detected embeddings from a different model (wrong dimensions).
Wiping the file, commit, and conversation indexes and clearing memory embeddings
so everything can be regenerated with the local model.
""")
with {:ok, project} <- Store.get_project() do
delete_file_index(project)
delete_commit_index(project)
delete_conversation_index(project)
clear_memory_embeddings(project)
end
memory_warning()
case context do
:ask ->
UI.info("""
File/commit/conversation indexes cleared; they'll rebuild in the background
as the indexer runs. Run `fnord index` to reindex everything immediately.
""")
:index ->
UI.info("""
Indexes cleared. Full reindexing will begin now.
""")
:read_only ->
UI.info("""
File/commit/conversation indexes cleared. This command will return
limited or empty results until you run `fnord index` to rebuild.
""")
end
:ok
end
# Memories are different from the other indexes: their embeddings are
# cleared in place (the memory text itself is preserved), and they are
# backfilled by background services + `fnord index`. Until a memory is
# re-embedded, it is effectively unreachable via semantic search - the
# user needs to know that.
defp memory_warning do
UI.error("""
================================================================================
MEMORIES ARE TEMPORARILY UNAVAILABLE
================================================================================
All saved memories (project + global) had their embeddings cleared as part
of the model migration. The memory text is intact - only the vectors are
missing. Until each memory is re-embedded with the new model, semantic
recall against it will return nothing.
Re-embedding happens slowly in the background via the memory indexer.
To force it to finish immediately, run:
fnord index
================================================================================
""")
end
# Nuke the entire files/ tree. Deleting only embeddings.json would leave
# stale summaries on disk, generated against the previous embedding-input
# contract and prompt. Reindex regenerates summary + embeddings from
# scratch.
#
# This operation is destructive and non-transactional: if the process dies
# after wiping some indexes but before others, `detect_stale_embeddings`
# on the next run will still trigger correctly as long as *any* surviving
# on-disk embedding (file, commit, conversation, memory) has the old
# dimension. Memories have an in-place guard (Memory.do_search skips
# dim-mismatched entries) and are backfilled by the memory indexer.
defp delete_file_index(project) do
project
|> Store.Project.files_root()
|> File.rm_rf()
end
defp delete_commit_index(project) do
project
|> Store.Project.CommitIndex.root()
|> File.rm_rf()
end
defp delete_conversation_index(project) do
project
|> Store.Project.ConversationIndex.root()
|> File.rm_rf()
end
defp clear_memory_embeddings(project) do
# Project-scoped memories
clear_memory_embeddings_in_dir(Path.join(project.store_path, "memory"))
# Global memories
clear_memory_embeddings_in_dir(Path.join(Settings.fnord_home(), "memory"))
end
# Memory files are rewritten in place with embeddings set to nil. A
# non-atomic File.write/2 that gets interrupted mid-stream leaves a
# truncated file on disk. Since detect_stale_embeddings samples only
# one non-nil embedding, a corrupt file plus an otherwise-fully-migrated
# on-disk state would skip re-migration AND leave the memory
# unreadable by the Memory module. Write atomically via tmp + rename so
# the on-disk state is always either the pre-wipe or post-wipe JSON.
defp clear_memory_embeddings_in_dir(dir) do
dir
|> Path.join("*.json")
|> Path.wildcard()
|> Enum.each(fn path ->
case File.read(path) do
{:ok, data} ->
case SafeJson.decode(data) do
{:ok, %{"embeddings" => emb} = memory} when is_list(emb) ->
updated = Map.put(memory, "embeddings", nil)
case SafeJson.encode(updated) do
{:ok, json} ->
try do
Settings.write_atomic!(path, json)
rescue
e ->
UI.warn(
"[Embeddings Migration] Failed to clear embeddings in #{path}: " <>
Exception.message(e)
)
:ok
end
_ ->
:ok
end
_ ->
:ok
end
_ ->
:ok
end
end)
end
end