In the last lesson, we took a crucial step towards professionalizing our vector database by designing a clean, stable public API. Using the opaque pointer pattern, we defined a header file, hnsw.h, that hides all our complex internal machinery behind a single handle, HnswIndex*. This established the contract between our library and its future users.
Now, we will bring that contract to life. This lesson focuses on implementing the core API functions we declared: hnsw_load, hnsw_destroy, hnsw_insert, and hnsw_search. You will be wiring the public-facing API to the sophisticated backend you've built over the past several modules, from memory-mapped I/O and custom allocators to the HNSW graph algorithms themselves. This is where the design meets the metal.
The Central Controller: The HnswIndex Struct
As a brief recap from our design session, all state associated with a database instance is managed through a single private struct. This struct is what the opaque HnswIndex* handle points to. Its definition, located in your hnsw.c implementation file, acts as the central nervous system of your library.
// In hnsw.c (The private implementation file)
#include "hnsw.h"
#include "hnsw_graph.h" // Your internal graph structures
#include "arena.h" // Your memory allocator
#include "wal.h" // Your write-ahead log
#include <fcntl.h> // For file control options
#include <unistd.h> // For ftruncate, close
#include <sys/mman.h> // For mmap, msync, munmap
// The private definition of our index structure.
struct HnswIndex {
int fd; // File descriptor for the database file
size_t file_size; // Current size of the file
void* map_start; // Start address of the memory-mapped region
HNSWGraph* graph; // Pointer to the graph structure (lives in the mapped memory)
Arena data_arena; // Arena for vector data (lives in the mapped memory)
Arena node_arena; // Arena for graph nodes (lives in the mapped memory)
wal_t* wal; // Pointer to the Write-Ahead Log
// ... any other global state, e.g., configuration parameters ...
};
Every API call will receive a pointer to this struct, giving it the context needed to perform its task. Let's now implement the functions that will operate on it.
hnsw_load(): The Gateway Function
The hnsw_load function is arguably the most critical part of the API. It serves as both the constructor for new databases and the loader for existing ones. It orchestrates file I/O, memory mapping, and the data recovery process you built in the previous module.
The logic follows a clear path:
- Allocate the Handle: First, allocate memory for the
HnswIndexstruct itself. This is the only part of the core data that lives on the heap; everything else will reside in the memory-mapped file. - Open or Create the File: Attempt to open the file at the given path. If it doesn't exist, create it.
- Map the File: Use
mmapto map the file into the process's address space. - Initialize or Recover:
- If it's a new file, you must initialize the memory-mapped region. This involves setting up a header, initializing the memory arenas, and creating an empty
HNSWGraphstructure at the start of the mapped region. This is the "serialization" of an empty database. - If it's an existing file, you will deserialize the state. This means pointing the
graphand arena pointers within yourHnswIndexstruct to the correct offsets in the mapped region. Crucially, you will then find and replay the Write-Ahead Log to recover from any potential crash and bring the database to a consistent state.
- If it's a new file, you must initialize the memory-mapped region. This involves setting up a header, initializing the memory arenas, and creating an empty
- Return the Handle: If all steps succeed, return the pointer to the
HnswIndexstruct. On any failure, you must meticulously clean up any allocated resources (close file descriptors, unmap memory, free the struct) and returnNULL.
This process of serialization and deserialization is fundamental to making data persistent.

When implementing this, remember the lessons on robust C programming. The code review on a kNN implementation you saw earlier highlighted the critical need for error checking.
memory management - k-Nearest Neighbors algorithm in C - Code Review Stack Exchange
This section of the code review critiques the original author's error handling.
Pay close attention to the commentary about handling errors. The reviewer notes that the original code has almost no checking for return values from malloc(), fgets(), or fopen_s(). It emphasizes that in C, you must consider error paths first. Apply this mindset rigorously in your hnsw_load implementation. Every call to open, mmap, or malloc can fail and must be checked.
hnsw_destroy(): The Cleanup Crew
The hnsw_destroy function is the counterpart to hnsw_load. Its purpose is to safely shut down the database instance and release all associated system resources.
- Synchronize Data: The most important step is to call
msyncwith theMS_SYNCflag. This flushes any "dirty" pages in the memory-mapped region to the underlying disk, guaranteeing that all insertions and modifications are durable. This directly implements the durability promise of our database. - Unmap Memory: Call
munmapto remove the file mapping from the process's address space. - Close File: Close the file descriptor using
close. - Free Handle: Finally,
freetheHnswIndexstruct itself.
This lifecycle of load and destroy is a standard pattern for managing resources in C libraries.
hnsw_insert() and hnsw_search(): The Workhorses
These two functions are the primary interface for interacting with the data. They act as thin wrappers that delegate to the complex HNSW algorithms you've already implemented.

For hnsw_insert(HnswIndex* index, const float* vec, uint32_t id):
- Lock (Conceptual): In a multithreaded context (which we'll explore later), this is where you'd acquire an exclusive write lock to prevent simultaneous modifications.
- Log to WAL: Before modifying the graph, append the insertion operation (vector data and ID) to the Write-Ahead Log. This is essential for crash recovery.
- Call Internal Insertion: Invoke your internal
hnsw_graph_insertfunction, passing it theindex->graph, the vector, the ID, and any other required parameters likeMandefConstruction. This function will perform the multi-layer search and connection logic from Module 7. - Unlock (Conceptual): Release the write lock.
- Return Status: Return
0on success or a negative value on failure.
For hnsw_search(HnswIndex* index, ..., uint32_t* out_ids, float* out_dists):
- Lock (Conceptual): Acquire a shared read lock, allowing multiple searches to happen concurrently but blocking any insertions.
- Call Internal Search: Invoke your core
hnsw_graph_searchalgorithm from Module 6. This function will traverse the graph from the entry point down to layer 0, using the bounded max-heap to keep track of the best candidates. - Copy Results: Your internal search function likely returns the results in the max-heap. You must extract the final
knearest neighbors from the heap and copy their IDs and distances into the user-providedout_idsandout_distsarrays. - Unlock (Conceptual): Release the read lock.
- Return Count: Return the number of neighbors found.
A Professional Touch: Advanced Error Handling
Returning NULL or -1 is standard, but it doesn't tell the user why an operation failed. Was the file not found? Was there not enough memory? Did the data have the wrong dimensions? To build a truly robust library, you should provide more detailed error information.
A common and effective pattern in high-performance C APIs is to use a thread-local variable to store the last error message. This avoids the complexities of passing error objects around and is safe for multithreaded use. The C API for Facebook's Faiss library uses precisely this technique.
Taking the long road (Part 1): bindings for a vector similarity search library
This article describes the creation of a C API for the C++ Faiss library. It's a masterclass in professional C API design.
First, briefly review the section describing the design choices. Note the use of opaque pointers and function prefixes, which mirrors our own design. Then, focus carefully on the section Error Handling? Exceptional!. The author explains how they wrap C++ exceptions and store the error message in a global thread-local variable, making it accessible to the C caller without disrupting the function signature.
Inspired by this, you can add two things to your API:
- A new public function in
hnsw.h:const char* hnsw_get_last_error(); - A thread-local variable in
hnsw.c:_Thread_local const char* g_last_error = NULL;
Now, in your implementation of hnsw_load, if open() fails, you can do:
if (fd == -1) {
g_last_error = "Failed to open database file";
// ... clean up and return NULL ...
}
The user can then call hnsw_get_last_error() to get a descriptive message. This simple addition dramatically improves the usability and debuggability of your library.
Conclusion
You have now bridged the gap between your powerful internal engine and the outside world. By implementing the functions of your public API, you've created a complete, encapsulated, and durable vector database library. You've handled resource management, data persistence, and error handling in a way that is characteristic of professional, low-level systems software.
Key Takeaways:
- API Implementation: The core API functions (
load,destroy,insert,search) act as controlled gateways to your library's internal logic. - Resource Lifecycle:
hnsw_loadis responsible for acquiring all necessary resources (memory, file handles), whilehnsw_destroyis responsible for releasing them gracefully. - Durability via
msync: The call tomsyncinhnsw_destroyis the final step that guarantees data written to the memory map is persisted to disk. - Wrapping Internal Logic:
hnsw_insertandhnsw_searchare thin wrappers that add essential services like logging (forinsert) and data marshalling around the core HNSW algorithms. - Robust Error Reporting: Using a thread-local variable for error messages provides a clean and thread-safe way to communicate detailed error information to the user.
In the next lesson, we will move on to the final step of packaging. You will configure CMake to compile your project not as an executable, but as a dynamically linked shared library (.so on Linux), making it ready for other programsโand other programming languagesโto use.