Logging and Observation Channels
In SimilaritySearch.jl, execution contexts maintain two separate channels to distinguish between human-readable reporting and programmatically actionable structural observation:
- Reporters (
ctx.reporters): Receive diagnostic and progress messages viaINFORM. These format status lines for display onstderr, logs, or monitoring consoles. - Observers (
ctx.observers): Receive structural mutation events viaOBSERVE. These enable write-ahead logging, incremental checkpoints, and dynamic metric tracking.
Decoupling these channels ensures that suppressing console output does not disrupt data persistence or event hooks.
Controlling Console Output: Reporters
Silencing Progress Output
To disable all informational logging, set reporters to an empty array:
using SimilaritySearch
ctx = SearchGraphContext(; reporters=[])When reporters is empty, log messages are skipped entirely with negligible computational overhead.
Verbosity vs. Reporters
reporters: Controls the destination sinks for informational messages.verbose: A boolean flag determining whether detailed per-iteration diagnostics (such as optimization trajectories and hint selections) are emitted. Defaults tofalse.
ctx = SearchGraphContext(; verbose=true) # Emits detailed optimization diagnostics
ctx = SearchGraphContext(; reporters=[]) # Completely silences output regardless of verboseConfiguring InformativeLog
The default reporter is InformativeLog, which emits timestamped status lines containing index cardinality, live heap allocation, and resident memory (RSS).
Messages are rate-limited to emit at most once every dt seconds:
# Log to stderr with a 0.5-second throttle and custom prefix
ctx = SearchGraphContext(; reporters=InformativeLog(; dt=0.5, prompt="[Build]"))
# Direct logs to a file stream
io = open("build.log", "a")
ctx = SearchGraphContext(; reporters=[InformativeLog(), InformativeLog(io)])
# Disable rate-limiting (dt=0 logs all events synchronously)
ctx = GenericContext(; reporters=InformativeLog(; dt=0))Structural Events: Observers and the :add! Contract
Mutating operations (push_item!, append_items!, index!) trigger structural notifications by calling:
\[\text{OBSERVE}(\text{ctx}, \text{:add!}, \text{index}, sp, ep)\]
where $sp:ep$ defines the contiguous range of object identifiers inserted into the index.
Guarantees of the :add! Contract
- Single Emission: When high-level functions delegate work (e.g.,
append_items!extending the database and callingindex!), only the mutating layer emits the:add!event, preventing duplicate notifications. - Scope Isolation: Observers should be attached to a single index instance to ensure identifier ranges remain unambiguous.
Implementing Observers
SimilaritySearch.jl provides CallbackLog for functional hooks, or users can subtype SimilaritySearch.AbstractObserver:
# Using CallbackLog
ranges = Tuple{Int, Int}[]
ctx = SearchGraphContext(; observers=CallbackLog((index, sp, ep) -> push!(ranges, (Int(sp), Int(ep)))))
# Custom AbstractObserver implementation
struct CountingObserver <: SimilaritySearch.AbstractObserver
counts::Dict{Symbol, Int}
end
CountingObserver() = CountingObserver(Dict{Symbol, Int}())
function SimilaritySearch.OBSERVE(log::CountingObserver, event::Symbol, index, ctx, sp::Integer, ep::Integer)
log.counts[event] = get(log.counts, event, 0) + (ep - sp + 1)
end
ctx = SearchGraphContext(; observers=CountingObserver())Context Inheritance Rules
When library algorithms instantiate internal auxiliary contexts (such as scratch indexes created during parameter tuning), these child contexts inherit the parent's reporters (preserving output configuration), but do not inherit observers. This prevents auxiliary operations from emitting events into the primary index's observation stream.
Incremental Neighborhood Tracking
Because add!(index.adj, objID, ...) executes prior to the :add! event, an observer can inspect newly computed neighborhoods immediately upon insertion:
struct NeighborCaptureLog <: SimilaritySearch.AbstractObserver
captured::Dict{Int, Vector{UInt32}}
end
NeighborCaptureLog() = NeighborCaptureLog(Dict{Int, Vector{UInt32}}())
function SimilaritySearch.OBSERVE(log::NeighborCaptureLog, event::Symbol, index::SearchGraph, ctx::SearchGraphContext, sp::Integer, ep::Integer)
for i in sp:ep
log.captured[i] = collect(neighbors(index.adj, i))
end
end
X = MatrixDatabase(rand(Float32, 4, 500))
G = SearchGraph(Dist.L2(), X)
mylog = NeighborCaptureLog()
ctx = SearchGraphContext(; observers=mylog)
index!(G, ctx)
length(mylog.captured) # 500 objects recorded at insertion timeForward Neighbors vs. Bidirectional Connectivity
In SearchGraph, edge creation includes:
- Forward links: When object $j$ is inserted, it discovers nearest neighbors among $1, \dots, j-1$ and connects directed edges $j \to i$.
- Reverse links: Object $j$ also connects reverse edges $i \to j$ to ensure graph bidirectionality.
Consequently, the neighborhood captured for object $i$ at its insertion moment contains only its initial forward connections. To retrieve the complete bidirectional neighborhood after construction completes, inspect the final adjacency structure:
final_neighbors = Dict(i => collect(neighbors(G.adj, i)) for i in 1:length(G))In the next section, Inverted Files and Posting List Intersections, we explore inverted indexing for sparse vector search and set metrics.