FAISSVectorDatabase
On this page
Table of contents
- Overview
- Class attributes
- Constructor
- Initialization & validation (internal)
- Add operations
- Search operations
- Delete operations
- Persistence
- Statistics & utilities
- Async API
- Common mistakes
Overview
A wrapper around FAISS that stores vectors in a
local, in-process index plus a parallel Python list of DocumentChunk objects (so it can map
FAISS result indices back to chunk text/metadata). All state — index, chunks, and lookup
tables — lives in memory unless you call save.
Use it when you want exact or approximate nearest-neighbor search with no external services and full control over the index type.
Class attributes
SUPPORTED_INDEX_TYPES = {'flat', 'ivf', 'hnsw'}
SUPPORTED_METRICS = {'cosine', 'l2', 'ip'}
DEFAULT_IVF_CLUSTERS = 100
DEFAULT_IVF_TRAIN_SIZE = 100
DEFAULT_HNSW_M = 32
Constructor
FAISSVectorDatabase(
embedder: Optional[Any] = None,
embedding_dim: Optional[int] = None,
index_type: str = 'flat',
distance_metric: str = 'cosine',
ivf_clusters: Optional[int] = None,
hnsw_m: Optional[int] = None,
)
Raises: ImportError if faiss isn't installed; ValueError for an unsupported metric,
unsupported index type, non-positive embedding_dim, or if neither embedder nor
embedding_dim is given (or the embedder's .encode() call fails while probing dimension).
Initialization workflow
super().__init__(embedder)- Validate
distance_metricandindex_typeagainst the supported sets. - Resolve
embedding_dim— from the explicit argument, or by callingembedder.encode(["test"])and reading.shape[1](orlen(...)for 1-D output). - Store
ivf_clusters/hnsw_m(falling back to defaults if not given). - Initialize empty storage:
self.chunks = [],self._chunk_id_to_idx = {},self._doc_id_to_indices = {},self._total_vectors = 0. - Build the FAISS index via
_create_index():- metric
cosineorip→ base indexfaiss.IndexFlatIP - metric
l2→ base indexfaiss.IndexFlatL2 index_type='flat'→ the base index directlyindex_type='ivf'→faiss.IndexIVFFlat(quantizer, dim, ivf_clusters)— must be trained before it accepts additions (seeadd)index_type='hnsw'→faiss.IndexHNSWFlat(dim, hnsw_m)(source note: FAISS's HNSW is natively L2-based; cosine is approximated by normalizing vectors before insertion, not by a different index type)
- metric
Thread safety
Not documented or synchronized in source. Concurrent calls to add/search from multiple
threads are not guarded against races on self.chunks, the lookup dicts, or the FAISS index
itself.
Initialization & validation (internal)
These are private (_-prefixed) but explain observable errors:
_validate_metric/_validate_index_type— raiseValueErrorlisting supported values._determine_embedding_dim— see constructor above._create_index— see above._normalize_if_needed(embeddings)— L2-normalizes (faiss.normalize_L2) only whendistance_metric == 'cosine'; otherwise returns the array unchanged (cast tofloat32)._compute_embeddings(texts)— requiresself.embedder; raisesValueErrorif missing, wraps embedder failures inRuntimeError._validate_embedding_dimension(embeddings)— raisesValueErrorif not 2-D or if the second dimension doesn't matchself.embedding_dim.
Add operations
add_chunk
def add_chunk(self, chunk: DocumentChunk) -> None
Adds a single chunk. If chunk.embedding is None, computes it via _compute_embeddings, then
delegates to add(chunks=[chunk], embeddings=...).
Raises: ValueError if chunk isn't a DocumentChunk.
add
def add(self, chunks: List[DocumentChunk], embeddings: Optional[np.ndarray] = None) -> None
Internal workflow:
- If
chunksis empty, warn and return. - Validate every item is a
DocumentChunk(elseValueError). - Compute embeddings if not provided.
- Validate
len(chunks) == len(embeddings)and dimension consistency. - Normalize embeddings if using cosine.
- If using an untrained IVF index and total vectors (existing + new) reaches
DEFAULT_IVF_TRAIN_SIZE, train it first (_train_ivf_index). - Append each chunk (with
sanitize_metadata()applied to its metadata) toself.chunks, updating_chunk_id_to_idxand_doc_id_to_indices. self.index.add(normalized_embeddings), increment_total_vectors.
Raises: ValueError for invalid/mismatched inputs; RuntimeError wrapping any lower-level
failure during the try block.
[!WARNING] IVF index below the training threshold An
ivfindex only trains once cumulative added vectors reachDEFAULT_IVF_TRAIN_SIZE = 100. Adding fewer than 100 vectors total means the index is never trained, andfaiss.IndexIVFFlat.add()will raise if you attempt to add to an untrained index — this is FAISS's own behavior, not something this wrapper suppresses.
Complexity
flat:addis O(n) to append n vectors;searchis O(N·d) per query (N = stored vectors, d = dimension) — exact but linear.ivf: faster search after training (approximately O(clusters searched × points per cluster)), at the cost of a one-time training pass.hnsw: logarithmic-ish approximate search per FAISS's HNSW implementation; insertion cost is higher thanflatper vector.
(These complexity characterizations reflect FAISS's own documented algorithm behavior, not custom logic in this wrapper — the wrapper itself does no extra algorithmic work in the hot path beyond normalization and bookkeeping.)
Search operations
search
def search(
self,
query: Union[str, np.ndarray],
top_k: int = 5,
score_threshold: Optional[float] = None,
doc_id_filter: Optional[Union[str, List[str]]] = None,
) -> List[Tuple[DocumentChunk, float]]
Returns: List[Tuple[DocumentChunk, float]], sorted by FAISS result order (which is score
order for the underlying index type).
Internal workflow:
- If the database is empty, warn and return
[]immediately. - Validate
top_k > 0. - Encode/cast the query, validate its dimension matches
embedding_dim. - Normalize the query vector the same way stored vectors were normalized.
actual_k = min(top_k, total_vectors); ifdoc_id_filteris set, search foractual_k * 2candidates first (to leave room for post-filtering), elseactual_k.- Call
self.index.search(query_vec, search_k). - For each returned
(distance, idx): skipidx == -1(FAISS's "no result" sentinel) and any out-of-range index (logged as a warning — defensive check, shouldn't normally trigger); applyscore_threshold; applydoc_id_filter; stop oncetop_kresults are collected.
Raises: ValueError for empty/invalid top_k or dimension mismatch; RuntimeError
wrapping lower-level failures.
Side effects: none beyond logging.
[!NOTE]
doc_id_filterisn't guaranteed to returntop_kresults Because filtering happens after FAISS returnssearch_k = actual_k * 2candidates, a heavily-filtered query can return fewer thantop_kresults even if more matches exist further down FAISS's ranking. This is a direct consequence of the fixed* 2multiplier in the source and is not adaptive.
search_by_doc_id
def search_by_doc_id(self, doc_id: str, top_k: int = 5, exclude_same_doc: bool = True) -> List[Tuple[DocumentChunk, float]]
Finds chunks similar to every chunk belonging to doc_id, merges and deduplicates the
results by chunk_id, sorts by score descending, and truncates to top_k.
Returns [] with a warning if doc_id isn't found. Uses each source chunk's own
chunk.embedding as the query for search() (requesting extra results when
exclude_same_doc=True to compensate for filtered-out same-document hits).
Delete operations
remove_by_chunk_id
def remove_by_chunk_id(self, chunk_id: str) -> bool
Removes a single chunk. Returns False (with a warning) if not found; otherwise removes it
from lookup tables and calls _rebuild_index() (a full index rebuild — see below), returning
True.
remove_by_doc_id
def remove_by_doc_id(self, doc_id: str) -> int
Removes every chunk for doc_id. Returns 0 if the document isn't found. Otherwise removes
the matching entries from self.chunks (highest index first, to keep indices valid during the
loop), deletes the doc from _doc_id_to_indices, calls _rebuild_index(), and returns the
count removed.
clear
def clear(self) -> int
Empties chunks, both lookup dicts, resets _total_vectors to 0, and recreates a fresh
empty index via _create_index(). Returns the number of chunks that were present beforehand.
_rebuild_index (internal, but load-bearing)
Because FAISS indices don't support in-place deletion, every delete operation triggers a full rebuild: recreate the index, re-add every remaining chunk's stored embedding (re-training an IVF index if there are enough vectors), and rebuild both lookup dicts from scratch.
[!WARNING] Deletes are O(n) in the current vector count Every
remove_by_chunk_id/remove_by_doc_idcall rebuilds the entire index from the remaining chunks. For workloads with frequent deletes against a large index, this can be a meaningful performance cost — there is no incremental/soft-delete path in this backend.
Persistence
save
def save(self, path: Union[str, Path]) -> None
Writes three files under path (created if missing):
index.faiss— viafaiss.write_indexchunks.pkl— a pickled list of dicts (chunk_id,doc_id,text,metadata, andembeddingas a plain list orNone)metadata.pkl— a pickled dict ofembedding_dim,index_type,distance_metric,total_vectors,ivf_clusters,hnsw_m
Raises: RuntimeError wrapping any failure.
load
@classmethod
def load(cls, path: Union[str, Path], embedder: Optional[Any] = None) -> 'FAISSVectorDatabase'
Raises: FileNotFoundError if path doesn't exist or any of the three required files is
missing; RuntimeError wrapping any other failure.
Workflow: loads metadata.pkl first, constructs a new instance with that configuration
(optionally with a fresh embedder), then overwrites db.index with
faiss.read_index(...) and db._total_vectors from the saved metadata, then reconstructs each
DocumentChunk from chunks.pkl and rebuilds both lookup tables.
[!NOTE] Falsy embeddings are silently skipped on load The load loop checks
if chunk_dict.get('embedding'):before restoringchunk.embedding. An embedding stored as an all-zero vector (or any array whose truthiness check on the underlying list is falsy) would not be restored via this exact condition — this is what the source does, not an inferred edge case.
Statistics & utilities
get_stats
def get_stats(self) -> Dict[str, Any]
Returns:
{
'total_chunks': int,
'unique_docs': int,
'embedding_dim': int,
'index_type': str,
'distance_metric': str,
'ivf_clusters': int | None, # None unless index_type == 'ivf'
'hnsw_m': int | None, # None unless index_type == 'hnsw'
'is_trained': bool, # index.is_trained for ivf, else True
'has_embedder': bool,
}
get_chunk_by_id / get_chunks_by_doc_id / list_document_ids
Straightforward local-cache lookups via the two internal dicts; no I/O.
Dunder methods
def __len__(self) -> int # self._total_vectors
def __repr__(self) -> str # vectors, docs, dim, type, metric
def __contains__(self, chunk_id) -> bool # chunk_id in self._chunk_id_to_idx
Async API
async def asearch(self, query: str, top_k: int = 10, score_threshold: float = 0.0, **kwargs)
async def aadd(self, chunks, **kwargs)
async def aremove_by_doc_id(self, doc_id: str) -> int
async def asave(self, path) -> None
async def __aenter__(self) / async def __aexit__(self, ...)
Every method is asyncio.to_thread(self.<sync_method>, ...) — a thin thread-pool wrapper, not
a native async implementation. Note asearch's score_threshold default here is 0.0, not
None as in the base class or the synchronous search() — calling asearch() without
arguments therefore filters out any result scoring below 0.0, which is not the same as "no
filtering."
Common mistakes
- Passing
index_type='ivf'and adding fewer than 100 total vectors, then being surprised when the index either never trains or raises onadd/search— see the note under Add operations. - Assuming
remove_by_doc_id/remove_by_chunk_idare cheap — they trigger a full rebuild. - Relying on
asearch()'s default threshold matchingsearch()'s default — they differ (0.0vsNone).
Related APIs
VectorDatabaseBase— the contract this class implementssanitize_metadata— applied to every chunk's metadata inaddChromaVectorDatabase,PineconeVectorDatabase— other backends