Skip to content

codenib.index

Index-related components: sparse, dense, and regex.

Modules:

Name Description
embedding

Embedding module for CodeNib.

incremental

Incremental vector-indexing layer for CodeNib.

regex_idx
rerank

Cross-encoder rerankers (pairwise (query, doc) → relevance score).

sparse_idx

Sparse indexing module for CodeNib.

trigram

Trigram-based search backends.

Classes:

Name Description
CodeVectorStore

Vector store for code embeddings using FAISS and sentence-transformers.

RegexNodeIndex

In-memory regex-based index for CodeGraph nodes.

BM25CodeIndexer

A class that builds a BM25 index from CodeGraph nodes and provides

Functions:

Name Description
create_code_vector_store

Factory function to create a CodeVectorStore.

CodeVectorStore

CodeVectorStore(
    embedding_model: str = "text-embedding-ada-002",
    embedding_provider: str = "openai",
    dimension: int = 1536,
    index_type: str = "flat",
    index_metric: str = "ip",
    ivf_nlist: int = 100,
    ivf_nprobe: int = 8,
    store_path: str | None = None,
    profiler: Profiler | None = None,
    embedding: Any | None = None,
    **embedding_kwargs
)

Vector store for code embeddings using FAISS and sentence-transformers. Provides semantic search capabilities over code chunks.

Supports hierarchical indexing: - L0: File-level skeletons - L2: Function/method-level chunks for fine-grained retrieval (default)

Parameters:

Name Type Description Default
embedding_model str

Name of the embedding model to use

'text-embedding-ada-002'
embedding_provider str

Provider for embeddings ("openai", "huggingface")

'openai'
dimension int

Dimension of the embedding vectors

1536
index_type str

FAISS index type — "flat" (exact brute force, default) or "ivf" (IVF inverted-file; approximate, faster at scale). IVF indices are trained lazily on the first batch of vectors.

'flat'
index_metric str

Distance metric ("ip" for inner product, "l2" for L2 distance)

'ip'
ivf_nlist int

IVF only — number of Voronoi cells (coarse centroids). On small corpora it is clamped down to the training-set size, since FAISS k-means needs at least nlist training points.

100
ivf_nprobe int

IVF only — cells probed per query; the recall/latency knob. Clamped to the effective nlist.

8
store_path str | None

Path to store/load the vector store

None
profiler Profiler | None

Optional profiler instance to capture detailed timings

None
embedding Any | None

A pre-built embedding wrapper to reuse. When several stores share one model (e.g. one per repo), pass the same instance so the model is loaded onto the GPU only once.

None
**embedding_kwargs

Additional arguments for embedding model

{}

Methods:

Name Description
reuse_query_embedding

Reuse one query vector within a composed request, then discard it.

clear_query_cache

Clear the single-query embedding reused by consecutive search stages.

swap_index

Hot-swap the FAISS index without reloading the embedding model.

close

Release embeddings and FAISS resources to free memory.

add_code_chunks

Add code chunks to the vector store.

add_nodes_with_content

Add NodeInfo objects (with content) to the vector store.

search

Search for similar code chunks using semantic similarity.

search_with_content

Search and return results with content included.

search_within_ids

Search only within a restricted set of node IDs.

hierarchical_search

Note: This method is implemented by Claude and is just for future reference.

save

Save the vector store to disk.

load

Load the vector store from disk.

get_stats

Get statistics about the vector store.

get_embeddings_by_content_hash

Extract raw embedding vectors from the FAISS index, keyed by content hash.

rebuild_from_embeddings

Clear level and rebuild its FAISS index from pre-computed embeddings.

delta_update

Patch the FAISS index in place when the change set is small.

clear

Clear data from the vector store.

Source code in codenib/index/embedding/vector_store.py
def __init__(
    self,
    embedding_model: str = "text-embedding-ada-002",
    embedding_provider: str = "openai",
    dimension: int = 1536,
    index_type: str = "flat",
    index_metric: str = "ip",
    ivf_nlist: int = 100,
    ivf_nprobe: int = 8,
    store_path: Optional[str] = None,
    profiler: Optional[Profiler] = None,
    embedding: Optional[Any] = None,
    **embedding_kwargs,
):
    """
    Initialize the CodeVectorStore.

    Args:
        embedding_model: Name of the embedding model to use
        embedding_provider: Provider for embeddings ("openai", "huggingface")
        dimension: Dimension of the embedding vectors
        index_type: FAISS index type — "flat" (exact brute force, default)
            or "ivf" (IVF inverted-file; approximate, faster at scale).
            IVF indices are trained lazily on the first batch of vectors.
        index_metric: Distance metric ("ip" for inner product, "l2" for L2 distance)
        ivf_nlist: IVF only — number of Voronoi cells (coarse centroids). On
            small corpora it is clamped down to the training-set size, since
            FAISS k-means needs at least ``nlist`` training points.
        ivf_nprobe: IVF only — cells probed per query; the recall/latency
            knob. Clamped to the effective ``nlist``.
        store_path: Path to store/load the vector store
        profiler: Optional profiler instance to capture detailed timings
        embedding: A pre-built embedding wrapper to reuse. When several
            stores share one model (e.g. one per repo), pass the same
            instance so the model is loaded onto the GPU only once.
        **embedding_kwargs: Additional arguments for embedding model
    """
    self.embedding_model = embedding_model
    self.embedding_provider = embedding_provider
    self.dimension = dimension
    self.index_type = index_type.lower()
    if self.index_type not in ("flat", "ivf"):
        raise ValueError(
            f"Unsupported index_type: {index_type}. Must be 'flat' or 'ivf'."
        )
    self.index_metric = index_metric.lower()
    if self.index_metric not in ["ip", "l2"]:
        raise ValueError(
            f"Unsupported index_metric: {index_metric}. Must be 'ip' or 'l2'."
        )
    self.ivf_nlist = max(1, int(ivf_nlist))
    self.ivf_nprobe = max(1, int(ivf_nprobe))
    self.store_path = Path(store_path) if store_path else None
    self.profiler = profiler

    # Initialize the embedding model — or reuse a shared one so the same
    # model isn't loaded onto the GPU once per store.
    self.embedding = (
        embedding
        if embedding is not None
        else self._initialize_embedding_model(**embedding_kwargs)
    )
    self._cached_query_text: Optional[str] = None
    self._cached_query_vector: Optional[np.ndarray] = None
    self._query_cache_depth = 0
    self.dimension = self._infer_embedding_dimension(dimension)

    # Initialize L0 (file-level skeletons)
    self.l0_index = self._build_faiss_index()
    self.l0_documents: List[_Document] = []

    # Initialize L2 (function/method-level) - default
    self.l2_index = self._build_faiss_index()
    self.l2_documents: List[_Document] = []

    logger.info(
        f"Initialized CodeVectorStore with {embedding_provider}:{embedding_model}"
    )

reuse_query_embedding

reuse_query_embedding()

Reuse one query vector within a composed request, then discard it.

Source code in codenib/index/embedding/vector_store.py
@contextmanager
def reuse_query_embedding(self):
    """Reuse one query vector within a composed request, then discard it."""

    depth = getattr(self, "_query_cache_depth", 0)
    if depth == 0:
        self.clear_query_cache()
    self._query_cache_depth = depth + 1
    try:
        yield
    finally:
        self._query_cache_depth -= 1
        if self._query_cache_depth == 0:
            self.clear_query_cache()

clear_query_cache

clear_query_cache() -> None

Clear the single-query embedding reused by consecutive search stages.

Source code in codenib/index/embedding/vector_store.py
def clear_query_cache(self) -> None:
    """Clear the single-query embedding reused by consecutive search stages."""

    self._cached_query_text = None
    self._cached_query_vector = None

swap_index

swap_index(path: str) -> None

Hot-swap the FAISS index without reloading the embedding model.

Frees the current L0/L2 FAISS indices and documents from memory, then loads a new index from path. The embedding model is left intact so the caller can reuse the same model across many instances.

Source code in codenib/index/embedding/vector_store.py
def swap_index(self, path: str) -> None:
    """Hot-swap the FAISS index without reloading the embedding model.

    Frees the current L0/L2 FAISS indices and documents from memory, then
    loads a new index from *path*.  The embedding model is left intact so
    the caller can reuse the same model across many instances.
    """
    # Free current FAISS index memory (GPU or CPU).
    for index in (self.l0_index, self.l2_index):
        if index is None:
            continue
        reset = getattr(index, "reset", None)
        if callable(reset):
            reset()

    # Reinitialise to empty indices (guards against a partially-failed
    # subsequent load leaving the store in a mixed state).
    self.l0_index = self._build_faiss_index()
    self.l0_documents = []
    self.l2_index = self._build_faiss_index()
    self.l2_documents = []

    self.store_path = Path(path)
    self.load(path)

close

close() -> None

Release embeddings and FAISS resources to free memory.

Source code in codenib/index/embedding/vector_store.py
def close(self) -> None:
    """Release embeddings and FAISS resources to free memory."""
    for index in (self.l0_index, self.l2_index):
        if index is None:
            continue
        reset = getattr(index, "reset", None)
        if callable(reset):
            reset()

    self.l0_documents.clear()
    self.l2_documents.clear()
    self.l0_index = None
    self.l2_index = None

    self.embedding = None
    self._query_cache_depth = 0
    self._cached_query_text = None
    self._cached_query_vector = None

    try:
        import torch

        if torch.cuda.is_available():
            torch.cuda.empty_cache()
    except Exception:
        pass

add_code_chunks

add_code_chunks(code_chunks: list[dict[str, Any]], level: Level = 'l2') -> None

Add code chunks to the vector store.

Parameters:

Name Type Description Default
code_chunks list[dict[str, Any]]

List of code chunk dictionaries with content and metadata

required
level Level

Index level to add chunks to ("l0" for file skeletons, "l2" for functions/methods)

'l2'
Source code in codenib/index/embedding/vector_store.py
def add_code_chunks(
    self, code_chunks: List[Dict[str, Any]], level: Level = "l2"
) -> None:
    """
    Add code chunks to the vector store.

    Args:
        code_chunks: List of code chunk dictionaries with content and metadata
        level: Index level to add chunks to
            ("l0" for file skeletons, "l2" for functions/methods)
    """
    if not code_chunks:
        logger.warning("No code chunks provided")
        return

    index, documents_list = self._get_index_and_docs(level)
    logger.info(f"Adding {len(code_chunks)} code chunks to {level} vector store")

    # Convert chunks to _Document objects
    documents: List[_Document] = []
    for i, chunk in enumerate(code_chunks):
        content = chunk.get("content", "")
        content_hash = hashlib.md5(
            content.encode("utf-8", errors="replace")
        ).hexdigest()
        metadata = {
            "chunk_id": len(documents_list) + i,
            "chunk_type": chunk.get("chunk_type", "unknown"),
            "name": chunk.get("name", f"chunk_{i}"),
            "file": chunk.get("file", ""),
            "start_line": chunk.get("start_line", 0),
            "end_line": chunk.get("end_line", 0),
            "node_id": chunk.get("node_id", ""),
            "level": level,
            "content_hash": content_hash,
        }
        for key, value in chunk.items():
            if key not in ["content"] and key not in metadata:
                metadata[key] = value

        documents.append(_Document(page_content=content, metadata=metadata))

    # Store documents
    documents_list.extend(documents)

    texts = [doc.page_content for doc in documents]

    # Phase 1: Embed texts (typically the bottleneck)
    with self._profile_section(
        f"embedding_encode_{level}",
        {"num_documents": len(documents), "level": level},
    ):
        embeddings = self.embedding.embed_documents(texts)

    # Phase 2: Add pre-computed vectors to FAISS index
    with self._profile_section(
        f"faiss_index_add_{level}",
        {"num_vectors": len(embeddings), "level": level},
    ):
        vectors = np.array(embeddings, dtype=np.float32)
        self._add_to_index(level, vectors)

    logger.info(
        f"Successfully added {len(documents)} documents to {level} vector store"
    )

add_nodes_with_content

add_nodes_with_content(nodes: list[NodeInfo], level: Level = 'l2') -> None

Add NodeInfo objects (with content) to the vector store.

Parameters:

Name Type Description Default
nodes list[NodeInfo]

List of NodeInfo objects

required
level Level

Index level to add nodes to ("l0" or "l2")

'l2'
Source code in codenib/index/embedding/vector_store.py
def add_nodes_with_content(
    self, nodes: List[NodeInfo], level: Level = "l2"
) -> None:
    """
    Add NodeInfo objects (with content) to the vector store.

    Args:
        nodes: List of NodeInfo objects
        level: Index level to add nodes to ("l0" or "l2")
    """
    chunks = []
    for node in nodes:
        chunk = {
            "content": node.content,
            "chunk_type": node.type,
            "name": node.node_name,
            "file": node.file,
            "start_line": node.start_line,
            "end_line": node.end_line,
        }
        chunks.append(chunk)

    self.add_code_chunks(chunks, level=level)

search

search(
    query: str,
    top_k: int = 10,
    score_threshold: float | None = None,
    level: Level = "l2",
    mask_node_ids: set[str] | None = None,
) -> list[NodeInfo]

Search for similar code chunks using semantic similarity.

Parameters:

Name Type Description Default
query str

Search query text

required
top_k int

Number of top results to return

10
score_threshold float | None

Minimum similarity score threshold

None
level Level

Index level to search ("l0" for file skeletons, "l2" for functions/methods)

'l2'
mask_node_ids set[str] | None

Optional set of CodeChunk.node_id values to filter results.

None

Returns:

Type Description
list[NodeInfo]

List of NodeInfo objects with scores populated

Source code in codenib/index/embedding/vector_store.py
def search(
    self,
    query: str,
    top_k: int = 10,
    score_threshold: Optional[float] = None,
    level: Level = "l2",
    mask_node_ids: Optional[Set[str]] = None,
) -> List[NodeInfo]:
    """
    Search for similar code chunks using semantic similarity.

    Args:
        query: Search query text
        top_k: Number of top results to return
        score_threshold: Minimum similarity score threshold
        level: Index level to search ("l0" for file skeletons, "l2" for
            functions/methods)
        mask_node_ids: Optional set of CodeChunk.node_id values to filter results.

    Returns:
        List of NodeInfo objects with scores populated
    """
    index, documents = self._get_index_and_docs(level)

    if index is None or index.ntotal == 0:
        logger.warning(f"No {level} vector store available. Add code chunks first.")
        return []

    logger.debug(f"Searching {level} for: {query[:100]}...")

    docs_with_scores = self._search_index(query, index, documents, top_k)

    results = []
    for doc, score in docs_with_scores:
        metadata = doc.metadata

        if score_threshold is not None and self._should_filter_by_threshold(
            score, score_threshold
        ):
            continue

        node_with_score = NodeInfo(
            node_name=metadata.get("name", "unknown"),
            type=metadata.get("chunk_type", "unknown"),
            file=metadata.get("file", ""),
            node_id=metadata.get("node_id", ""),
            start_line=metadata.get("start_line", 0),
            end_line=metadata.get("end_line", 0),
            score=float(score),
        )
        results.append(node_with_score)

    if mask_node_ids:
        results = [r for r in results if r.node_id in mask_node_ids]
    if top_k:
        results = results[:top_k]

    logger.debug(
        f"Found {len(results)} results in {level} (masked={bool(mask_node_ids)})"
    )
    return results

search_with_content

search_with_content(
    query: str,
    top_k: int = 10,
    score_threshold: float | None = None,
    level: Level = "l2",
    mask_node_ids: set[str] | None = None,
) -> list[NodeInfo]

Search and return results with content included.

Parameters:

Name Type Description Default
query str

Search query text

required
top_k int

Number of top results to return

10
score_threshold float | None

Minimum similarity score threshold

None
level Level

Index level to search ("l0" for file skeletons, "l2" for functions/methods)

'l2'
mask_node_ids set[str] | None

Optional set of CodeChunk.node_id values to filter results.

None

Returns:

Type Description
list[NodeInfo]

List of NodeInfo objects with content populated

Source code in codenib/index/embedding/vector_store.py
def search_with_content(
    self,
    query: str,
    top_k: int = 10,
    score_threshold: Optional[float] = None,
    level: Level = "l2",
    mask_node_ids: Optional[Set[str]] = None,
) -> List[NodeInfo]:
    """
    Search and return results with content included.

    Args:
        query: Search query text
        top_k: Number of top results to return
        score_threshold: Minimum similarity score threshold
        level: Index level to search ("l0" for file skeletons, "l2" for
            functions/methods)
        mask_node_ids: Optional set of CodeChunk.node_id values to filter results.

    Returns:
        List of NodeInfo objects with content populated
    """
    index, documents = self._get_index_and_docs(level)

    if index is None or index.ntotal == 0:
        logger.warning(f"No {level} vector store available. Add code chunks first.")
        return []

    docs_with_scores = self._search_index(query, index, documents, top_k)

    results = []
    for doc, score in docs_with_scores:
        metadata = doc.metadata

        if score_threshold is not None and self._should_filter_by_threshold(
            score, score_threshold
        ):
            continue

        node_with_content = NodeInfo(
            node_name=metadata.get("name", "unknown"),
            type=metadata.get("chunk_type", "unknown"),
            file=metadata.get("file", ""),
            node_id=metadata.get("node_id", ""),
            start_line=metadata.get("start_line", 0),
            end_line=metadata.get("end_line", 0),
            score=float(score),
            content=doc.page_content,
        )
        results.append(node_with_content)

    if mask_node_ids:
        results = [r for r in results if r.node_id in mask_node_ids]
    if top_k:
        results = results[:top_k]

    logger.debug(
        f"Found {len(results)} results with content in {level} "
        f"(masked={bool(mask_node_ids)})"
    )
    return results

search_within_ids

search_within_ids(
    query: str, mask_node_ids: set[str], top_k: int = 10, level: Level = "l2"
) -> list[NodeInfo]

Search only within a restricted set of node IDs.

Instead of searching the full FAISS index globally and filtering afterwards, this method restricts the search space before computing similarity. It reconstructs stored vectors for matching documents and computes similarity against the query embedding directly.

Parameters:

Name Type Description Default
query str

Search query text.

required
mask_node_ids set[str]

Set of node_id / node_name values to restrict search to.

required
top_k int

Number of top results to return.

10
level Level

Index level to search.

'l2'

Returns:

Type Description
list[NodeInfo]

List of NodeInfo objects sorted by similarity score.

Source code in codenib/index/embedding/vector_store.py
def search_within_ids(
    self,
    query: str,
    mask_node_ids: Set[str],
    top_k: int = 10,
    level: Level = "l2",
) -> List[NodeInfo]:
    """Search only within a restricted set of node IDs.

    Instead of searching the full FAISS index globally and filtering
    afterwards, this method restricts the search space *before* computing
    similarity.  It reconstructs stored vectors for matching documents
    and computes similarity against the query embedding directly.

    Args:
        query: Search query text.
        mask_node_ids: Set of node_id / node_name values to restrict
            search to.
        top_k: Number of top results to return.
        level: Index level to search.

    Returns:
        List of NodeInfo objects sorted by similarity score.
    """
    index, documents = self._get_index_and_docs(level)
    if index is None or index.ntotal == 0:
        logger.warning(f"No {level} vector store available.")
        return []

    # Find documents whose node_id or name is in mask set
    matched: list[tuple[int, _Document]] = []
    for i, doc in enumerate(documents):
        meta = doc.metadata
        if (
            meta.get("node_id", "") in mask_node_ids
            or meta.get("name", "") in mask_node_ids
        ):
            matched.append((i, doc))

    if not matched:
        logger.debug("search_within_ids: no matching documents found")
        return []

    # Encode query
    query_vec = self._embed_query(query)

    # Reconstruct stored vectors and compute similarity
    results: list[NodeInfo] = []
    for faiss_idx, doc in matched:
        vec = index.reconstruct(faiss_idx)
        if self.index_metric == "ip":
            score = float(np.dot(query_vec, vec))
        else:  # l2 — lower is better
            score = float(np.sum((query_vec - vec) ** 2))

        metadata = doc.metadata
        results.append(
            NodeInfo(
                node_name=metadata.get("name", "unknown"),
                type=metadata.get("chunk_type", "unknown"),
                file=metadata.get("file", ""),
                node_id=metadata.get("node_id", ""),
                start_line=metadata.get("start_line", 0),
                end_line=metadata.get("end_line", 0),
                score=score,
                content=doc.page_content,
            )
        )

    best_by_node = {}
    for result in results:
        identity = result.node_id or (
            result.file,
            result.node_name,
            result.start_line,
            result.end_line,
        )
        existing = best_by_node.get(identity)
        if existing is None:
            best_by_node[identity] = result
            continue
        if self.index_metric == "ip":
            is_better = result.score > existing.score
        else:
            is_better = result.score < existing.score
        if is_better:
            best_by_node[identity] = result
    results = list(best_by_node.values())

    # Sort: ip → higher is better; l2 → lower is better
    results.sort(
        key=lambda r: r.score,
        reverse=(self.index_metric == "ip"),
    )

    logger.debug(
        "search_within_ids: %d matched, %d unique, returning top %d",
        len(matched),
        len(results),
        min(top_k, len(results)),
    )
    return results[:top_k]
hierarchical_search(
    query: str,
    l0_top_k: int = 5,
    l2_top_k: int = 10,
    l0_score_threshold: float | None = None,
    l2_score_threshold: float | None = None,
    filter_l2_by_l0: bool = True,
) -> dict[str, list[NodeInfo]]

Note: This method is implemented by Claude and is just for future reference. Perform hierarchical search: first L0 (files), then L2 (functions).

This implements a coarse-to-fine retrieval strategy: 1. Search L0 to find relevant files based on their skeletons 2. Search L2 for specific functions/methods 3. Optionally filter L2 results to only include those from L0 files

Parameters:

Name Type Description Default
query str

Search query text

required
l0_top_k int

Number of top L0 results (files)

5
l2_top_k int

Number of top L2 results (functions/methods)

10
l0_score_threshold float | None

Score threshold for L0 results

None
l2_score_threshold float | None

Score threshold for L2 results

None
filter_l2_by_l0 bool

If True, only return L2 results from files found in L0

True

Returns:

Type Description
dict[str, list[NodeInfo]]

Dict with 'l0' and 'l2' keys containing search results

Source code in codenib/index/embedding/vector_store.py
def hierarchical_search(
    self,
    query: str,
    l0_top_k: int = 5,
    l2_top_k: int = 10,
    l0_score_threshold: Optional[float] = None,
    l2_score_threshold: Optional[float] = None,
    filter_l2_by_l0: bool = True,
) -> Dict[str, List[NodeInfo]]:
    """
    Note: This method is implemented by Claude and is just for future reference.
    Perform hierarchical search: first L0 (files), then L2 (functions).

    This implements a coarse-to-fine retrieval strategy:
    1. Search L0 to find relevant files based on their skeletons
    2. Search L2 for specific functions/methods
    3. Optionally filter L2 results to only include those from L0 files

    Args:
        query: Search query text
        l0_top_k: Number of top L0 results (files)
        l2_top_k: Number of top L2 results (functions/methods)
        l0_score_threshold: Score threshold for L0 results
        l2_score_threshold: Score threshold for L2 results
        filter_l2_by_l0: If True, only return L2 results from files found in L0

    Returns:
        Dict with 'l0' and 'l2' keys containing search results
    """
    # Step 1: Search L0 to find relevant files
    l0_results = self.search(
        query, top_k=l0_top_k, score_threshold=l0_score_threshold, level="l0"
    )

    # Step 2: Search L2 for functions/methods (fetch more if filtering)
    l2_fetch_k = l2_top_k * 3 if filter_l2_by_l0 else l2_top_k
    l2_results = self.search(
        query, top_k=l2_fetch_k, score_threshold=l2_score_threshold, level="l2"
    )

    # Step 3: Optionally filter L2 results by L0 files
    if filter_l2_by_l0 and l0_results:
        l0_files = {result.file for result in l0_results}
        l2_results = [r for r in l2_results if r.file in l0_files]

    # Limit L2 results to top_k
    l2_results = l2_results[:l2_top_k]

    return {
        "l0": l0_results,
        "l2": l2_results,
    }

save

save(path: str | None = None) -> None

Save the vector store to disk.

Parameters:

Name Type Description Default
path str | None

Path to save the store (uses self.store_path if not provided)

None
Source code in codenib/index/embedding/vector_store.py
def save(self, path: Optional[str] = None) -> None:
    """
    Save the vector store to disk.

    Args:
        path: Path to save the store (uses self.store_path if not provided)
    """
    save_path = Path(path) if path else self.store_path
    if save_path is None:
        raise ValueError("No save path provided")

    save_path = Path(save_path)
    save_path.mkdir(parents=True, exist_ok=True)

    logger.info(f"Saving vector store to {save_path}")

    model_suffix = self.embedding_model.replace("/", "__")

    # Save L0
    if self.l0_index is not None and self.l0_documents:
        self._save_level(save_path, "l0", model_suffix)

    # Save L2
    if self.l2_index is not None and self.l2_documents:
        self._save_level(save_path, "l2", model_suffix)

    # Save top-level configuration
    config_path = save_path / f"config_{model_suffix}.json"
    config = {
        "embedding_model": self.embedding_model,
        "embedding_provider": self.embedding_provider,
        "dimension": self.dimension,
        "index_type": self.index_type,
        "index_metric": self.index_metric,
        "l0_documents": len(self.l0_documents),
        "l2_documents": len(self.l2_documents),
    }
    with open(config_path, "w") as f:
        json.dump(config, f, indent=2)

    logger.info("Vector store saved successfully")

load

load(path: str | None = None) -> None

Load the vector store from disk.

Parameters:

Name Type Description Default
path str | None

Path to load the store from (uses self.store_path if not provided)

None
Source code in codenib/index/embedding/vector_store.py
def load(self, path: Optional[str] = None) -> None:
    """
    Load the vector store from disk.

    Args:
        path: Path to load the store from (uses self.store_path if not provided)
    """
    load_path = Path(path) if path else self.store_path
    if load_path is None:
        raise ValueError("No load path provided")

    load_path = Path(load_path)
    if not load_path.exists():
        raise FileNotFoundError(f"Vector store not found at {load_path}")

    logger.info(f"Loading vector store from {load_path}")

    model_suffix = self.embedding_model.replace("/", "__")

    # Load top-level configuration
    config_path = load_path / f"config_{model_suffix}.json"
    if not config_path.exists():
        config_path = load_path / "config.json"

    if config_path.exists():
        with open(config_path, "r") as f:
            config = json.load(f)

        if config.get("dimension") != self.dimension:
            logger.warning(
                f"Dimension mismatch: expected {self.dimension}, "
                f"got {config.get('dimension')}"
            )
        saved_metric = config.get("index_metric")
        if saved_metric and saved_metric != self.index_metric:
            logger.warning(
                "Index metric mismatch: expected %s, got %s",
                self.index_metric,
                saved_metric,
            )
            self.index_metric = saved_metric

    # Load L0
    l0_path = load_path / "l0"
    if l0_path.exists():
        loaded = self._load_level(l0_path, model_suffix)
        if loaded is not None:
            self.l0_index, self.l0_documents = loaded
            logger.info(f"Loaded L0 store with {len(self.l0_documents)} documents")

    # Load L2
    l2_path = load_path / "l2"
    if l2_path.exists():
        loaded = self._load_level(l2_path, model_suffix)
        if loaded is not None:
            self.l2_index, self.l2_documents = loaded
            logger.info(f"Loaded L2 store with {len(self.l2_documents)} documents")

    total_docs = len(self.l0_documents) + len(self.l2_documents)
    logger.info(
        f"Vector store loaded successfully with {total_docs} total documents "
        f"(L0: {len(self.l0_documents)}, L2: {len(self.l2_documents)})"
    )

get_stats

get_stats() -> dict[str, Any]

Get statistics about the vector store.

Returns:

Type Description
dict[str, Any]

Dictionary with store statistics

Source code in codenib/index/embedding/vector_store.py
def get_stats(self) -> Dict[str, Any]:
    """
    Get statistics about the vector store.

    Returns:
        Dictionary with store statistics
    """
    stats = {
        "embedding_model": self.embedding_model,
        "embedding_provider": self.embedding_provider,
        "dimension": self.dimension,
        "index_type": self.index_type,
        "index_metric": self.index_metric,
        "l0_documents": len(self.l0_documents),
        "l2_documents": len(self.l2_documents),
        "total_documents": len(self.l0_documents) + len(self.l2_documents),
    }

    # Analyze L0 chunk types
    if self.l0_documents:
        l0_chunk_types = {}
        for doc in self.l0_documents:
            chunk_type = doc.metadata.get("chunk_type", "unknown")
            l0_chunk_types[chunk_type] = l0_chunk_types.get(chunk_type, 0) + 1
        stats["l0_chunk_types"] = l0_chunk_types

    # Analyze L2 chunk types
    if self.l2_documents:
        l2_chunk_types = {}
        for doc in self.l2_documents:
            chunk_type = doc.metadata.get("chunk_type", "unknown")
            l2_chunk_types[chunk_type] = l2_chunk_types.get(chunk_type, 0) + 1
        stats["l2_chunk_types"] = l2_chunk_types

    return stats

get_embeddings_by_content_hash

get_embeddings_by_content_hash(level: Level = 'l2') -> dict[str, ndarray]

Extract raw embedding vectors from the FAISS index, keyed by content hash.

This is used to seed the EmbeddingsCache after a full build so that the first incremental update achieves ~100% cache hit rate for unchanged chunks.

Each document's content is MD5-hashed to produce the key. If the document metadata already contains a content_hash field it is used directly; otherwise the hash is computed on the fly.

Returns:

Type Description
dict[str, ndarray]

Dict mapping content_hash → np.ndarray (float32 vectors).

Source code in codenib/index/embedding/vector_store.py
def get_embeddings_by_content_hash(
    self, level: Level = "l2"
) -> Dict[str, np.ndarray]:
    """
    Extract raw embedding vectors from the FAISS index, keyed by content hash.

    This is used to seed the ``EmbeddingsCache`` after a full build so that
    the first incremental update achieves ~100% cache hit rate for unchanged
    chunks.

    Each document's content is MD5-hashed to produce the key.  If the
    document metadata already contains a ``content_hash`` field it is used
    directly; otherwise the hash is computed on the fly.

    Returns:
        Dict mapping content_hash → np.ndarray (float32 vectors).
    """
    index, documents = self._get_index_and_docs(level)
    if not documents or index is None or index.ntotal == 0:
        return {}

    result: Dict[str, np.ndarray] = {}
    for i, doc in enumerate(documents):
        content_hash = doc.metadata.get("content_hash")
        if content_hash is None:
            content_hash = hashlib.md5(
                doc.page_content.encode("utf-8", errors="replace")
            ).hexdigest()

        vec = index.reconstruct(i)
        result[content_hash] = np.asarray(vec, dtype=np.float32)

    logger.info(
        "Extracted %d embedding vectors from %s FAISS index for cache seeding.",
        len(result),
        level,
    )
    return result

rebuild_from_embeddings

rebuild_from_embeddings(
    documents: list, embeddings: list[ndarray], level: Level = "l2"
) -> None

Clear level and rebuild its FAISS index from pre-computed embeddings.

Used by the incremental update path: unchanged chunks contribute their cached vectors, so only genuinely new/modified chunks require model inference. No embedding model calls are made by this method.

Parameters:

Name Type Description Default
documents list

Document-like objects with page_content and metadata attributes (_Document or compatible).

required
embeddings list[ndarray]

Corresponding embedding vectors as np.ndarray (shape [dim], dtype float32).

required
level Level

Which index level to rebuild ("l0" or "l2").

'l2'

Raises:

Type Description
ValueError

If documents and embeddings have different lengths.

Source code in codenib/index/embedding/vector_store.py
def rebuild_from_embeddings(
    self,
    documents: list,
    embeddings: List[np.ndarray],
    level: Level = "l2",
) -> None:
    """
    Clear *level* and rebuild its FAISS index from pre-computed embeddings.

    Used by the incremental update path: unchanged chunks contribute their
    cached vectors, so only genuinely new/modified chunks require model
    inference.  No embedding model calls are made by this method.

    Args:
        documents: Document-like objects with ``page_content`` and
            ``metadata`` attributes (``_Document`` or compatible).
        embeddings: Corresponding embedding vectors as ``np.ndarray``
            (shape ``[dim]``, dtype ``float32``).
        level: Which index level to rebuild (``"l0"`` or ``"l2"``).

    Raises:
        ValueError: If *documents* and *embeddings* have different lengths.
    """
    if len(documents) != len(embeddings):
        raise ValueError(
            f"documents ({len(documents)}) and embeddings ({len(embeddings)}) "
            "must have the same length."
        )

    # Wipe the existing index for this level
    self.clear(level)

    if not documents:
        logger.debug(
            "rebuild_from_embeddings: no documents; level %s cleared.", level
        )
        return

    # Convert to _Document if needed and add vectors to the raw FAISS index
    native_docs = [_to_document(d) for d in documents]
    vectors = np.array(
        [
            emb if isinstance(emb, np.ndarray) else np.asarray(emb)
            for emb in embeddings
        ],
        dtype=np.float32,
    )

    self._add_to_index(level, vectors)
    if level == "l0":
        self.l0_documents = native_docs
    else:
        self.l2_documents = native_docs

    logger.info(
        "rebuild_from_embeddings: %s index rebuilt with %d documents.",
        level,
        len(documents),
    )

delta_update

delta_update(
    all_documents: list,
    all_embeddings: list[ndarray],
    changed_content_hashes: set[str],
    level: Level = "l2",
    threshold: float = 0.1,
) -> None

Patch the FAISS index in place when the change set is small.

When the fraction of changed chunks is below threshold, this uses IndexFlat.remove_ids + add to modify only the affected rows, keeping unchanged vectors and their aligned documents untouched. If the change ratio exceeds the threshold (or the index is empty), it falls back to a full rebuild via :meth:rebuild_from_embeddings.

Parameters:

Name Type Description Default
all_documents list

The complete desired set of documents for level after the update. Must carry content_hash in metadata.

required
all_embeddings list[ndarray]

Corresponding embedding vectors, aligned with all_documents.

required
changed_content_hashes set[str]

Content hashes of chunks that were added, removed, or modified in this update cycle. Used both to decide between delta/rebuild and to identify stale rows.

required
level Level

Which index level to update.

'l2'
threshold float

Maximum change ratio (changed/total) for the delta path; above this a full rebuild is performed.

0.1
Source code in codenib/index/embedding/vector_store.py
def delta_update(
    self,
    all_documents: list,
    all_embeddings: List[np.ndarray],
    changed_content_hashes: Set[str],
    level: Level = "l2",
    threshold: float = 0.1,
) -> None:
    """
    Patch the FAISS index in place when the change set is small.

    When the fraction of changed chunks is below *threshold*, this uses
    ``IndexFlat.remove_ids`` + ``add`` to modify only the affected rows,
    keeping unchanged vectors and their aligned documents untouched.  If
    the change ratio exceeds the threshold (or the index is empty), it
    falls back to a full rebuild via :meth:`rebuild_from_embeddings`.

    Args:
        all_documents: The complete desired set of documents for *level*
            after the update.  Must carry ``content_hash`` in metadata.
        all_embeddings: Corresponding embedding vectors, aligned with
            *all_documents*.
        changed_content_hashes: Content hashes of chunks that were
            added, removed, or modified in this update cycle.  Used both
            to decide between delta/rebuild and to identify stale rows.
        level: Which index level to update.
        threshold: Maximum change ratio (changed/total) for the delta
            path; above this a full rebuild is performed.
    """
    total = len(all_documents)

    if total == 0:
        self.clear(level)
        return

    index, current_docs = self._get_index_and_docs(level)
    change_ratio = len(changed_content_hashes) / total

    # Fall back to full rebuild when the delta path can't help.
    if (
        index is None
        or index.ntotal == 0
        or not current_docs
        or change_ratio > threshold
    ):
        logger.info(
            "delta_update: %d/%d changed (%.0f%%) → full rebuild of %s.",
            len(changed_content_hashes),
            total,
            change_ratio * 100,
            level,
        )
        self.rebuild_from_embeddings(all_documents, all_embeddings, level=level)
        return

    # --- Delta path: in-place patch -------------------------------
    # Use a list per hash so duplicate-content docs (same code in
    # different files) are all preserved.
    from collections import defaultdict

    target_by_hash: Dict[str, List[Tuple[object, np.ndarray]]] = defaultdict(list)
    for doc, emb in zip(all_documents, all_embeddings, strict=True):
        ch = doc.metadata.get("content_hash")
        if ch is None:
            # Can't align by hash → safest to rebuild.
            logger.warning(
                "delta_update: target doc missing content_hash → full rebuild."
            )
            self.rebuild_from_embeddings(all_documents, all_embeddings, level=level)
            return
        target_by_hash[ch].append((doc, emb))

    current_hashes = [d.metadata.get("content_hash") for d in current_docs]

    # For each hash, allow at most target-count survivors (handles
    # both duplicate-content additions and removals correctly).
    target_avail: Dict[str, int] = {h: len(v) for h, v in target_by_hash.items()}
    rows_to_remove: List[int] = []
    for i, h in enumerate(current_hashes):
        if (
            h is None
            or h not in target_avail
            or h in changed_content_hashes
            or target_avail[h] <= 0
        ):
            rows_to_remove.append(i)
        else:
            target_avail[h] -= 1

    # Unclaimed target entries become additions.
    docs_to_add: List[Tuple[object, np.ndarray]] = []
    for h, entries in target_by_hash.items():
        claimed = len(entries) - target_avail.get(h, 0)
        docs_to_add.extend(entries[claimed:])

    if rows_to_remove:
        selector = faiss.IDSelectorBatch(np.array(rows_to_remove, dtype=np.int64))
        index.remove_ids(selector)

    # Survivors: prefer the fresh target doc (same content_hash) so that
    # pure metadata changes — file rename, start_line shift, name edit —
    # are reflected without requiring a full rebuild.  The vector is
    # identical because content_hash is identical, so no FAISS op needed.
    remove_set = set(rows_to_remove)
    survivor_idx: Dict[str, int] = {}
    new_docs_list: List[_Document] = []
    for i, d in enumerate(current_docs):
        if i in remove_set:
            continue
        h = current_hashes[i]
        idx = survivor_idx.get(h, 0)
        survivor_idx[h] = idx + 1
        entries = target_by_hash.get(h)
        if entries and idx < len(entries):
            new_docs_list.append(_to_document(entries[idx][0]))
        else:
            new_docs_list.append(d)

    if docs_to_add:
        add_vectors = np.array(
            [np.asarray(e, dtype=np.float32) for _, e in docs_to_add],
            dtype=np.float32,
        )
        self._add_to_index(level, add_vectors)
        new_docs_list.extend(_to_document(d) for d, _ in docs_to_add)

    if level == "l0":
        self.l0_documents = new_docs_list
    else:
        self.l2_documents = new_docs_list

    logger.info(
        "delta_update: %s patched in place — removed %d, added %d "
        "(ntotal=%d, %.0f%% changed).",
        level,
        len(rows_to_remove),
        len(docs_to_add),
        index.ntotal,
        change_ratio * 100,
    )

clear

clear(level: Level | None = None) -> None

Clear data from the vector store.

Parameters:

Name Type Description Default
level Level | None

If specified, only clear that level ("l0" or "l2"). If None, clear both levels.

None
Source code in codenib/index/embedding/vector_store.py
def clear(self, level: Optional[Level] = None) -> None:
    """
    Clear data from the vector store.

    Args:
        level: If specified, only clear that level ("l0" or "l2").
               If None, clear both levels.
    """
    if level is None or level == "l0":
        logger.info("Clearing L0 vector store")
        self.l0_index = self._build_faiss_index()
        self.l0_documents = []

    if level is None or level == "l2":
        logger.info("Clearing L2 vector store")
        self.l2_index = self._build_faiss_index()
        self.l2_documents = []

    logger.info("Vector store cleared")

RegexNodeIndex

RegexNodeIndex(code_graph: CodeGraph)

In-memory regex-based index for CodeGraph nodes. Supports regex pattern matching on node content with glob filtering.

Parameters:

Name Type Description Default
code_graph CodeGraph

CodeGraph instance containing nodes to index

required

Methods:

Name Description
search

Search for pattern in node content (grep-like functionality).

Source code in codenib/index/regex_idx/regex_idx.py
def __init__(self, code_graph: CodeGraph):
    """
    Initialize RegexNodeIndex and build index from CodeGraph.

    Args:
        code_graph: CodeGraph instance containing nodes to index
    """
    self.code_graph = code_graph
    self.nodes: List[NodeInfo] = []
    self._build_index()

    logger.info(f"RegexNodeIndex initialized with {len(self.nodes)} nodes")

search

search(
    pattern: str,
    file_glob: str | None = None,
    node_type: str | None = None,
    case_sensitive: bool = False,
    use_regex: bool = True,
) -> list[NodeInfo]

Search for pattern in node content (grep-like functionality).

Parameters:

Name Type Description Default
pattern str

Search pattern (regex or plain string)

required
file_glob str | None

Optional glob to filter by file path (e.g., '.py', '*/calc.py')

None
node_type str | None

Optional node type to filter (e.g., 'function', 'class', 'file')

None
case_sensitive bool

Whether search is case-sensitive (default: False)

False
use_regex bool

Whether to use regex (default: True) or plain string matching

True

Returns:

Type Description
list[NodeInfo]

List of NodeInfo objects matching the pattern

Examples:

>>> idx.search(r'def\s+\w+', file_glob='*.py')  # Find function defs
>>> idx.search('calculator', use_regex=False)  # Plain string search
>>> idx.search('class', node_type='file')  # Search in file nodes only
Source code in codenib/index/regex_idx/regex_idx.py
def search(
    self,
    pattern: str,
    file_glob: Optional[str] = None,
    node_type: Optional[str] = None,
    case_sensitive: bool = False,
    use_regex: bool = True,
) -> List[NodeInfo]:
    r"""
    Search for pattern in node content (grep-like functionality).

    Args:
        pattern: Search pattern (regex or plain string)
        file_glob: Optional glob to filter by file path (e.g., '*.py', '**/calc.py')
        node_type: Optional node type to filter (e.g., 'function', 'class', 'file')
        case_sensitive: Whether search is case-sensitive (default: False)
        use_regex: Whether to use regex (default: True) or plain string matching

    Returns:
        List of NodeInfo objects matching the pattern

    Examples:
        >>> idx.search(r'def\s+\w+', file_glob='*.py')  # Find function defs
        >>> idx.search('calculator', use_regex=False)  # Plain string search
        >>> idx.search('class', node_type='file')  # Search in file nodes only
    """
    # Step 1: Filter by structural attributes (file, type)
    candidates = self.nodes

    if file_glob:
        candidates = [
            n for n in candidates if n.file and fnmatch(n.file, file_glob)
        ]

    if node_type:
        candidates = [n for n in candidates if n.type == node_type]

    # Step 2: Search in content using regex or plain string
    matches = []

    if use_regex:
        # Regex matching
        flags = 0 if case_sensitive else re.IGNORECASE
        try:
            regex = re.compile(pattern, flags)
        except re.error as e:
            logger.error(f"Invalid regex pattern {pattern!r}: {e}")
            raise ValueError(f"Invalid regex pattern: {e}") from e

        matches = [n for n in candidates if n.content and regex.search(n.content)]
    else:
        # Plain string matching
        if case_sensitive:
            matches = [n for n in candidates if n.content and pattern in n.content]
        else:
            pattern_lower = pattern.lower()
            matches = [
                n
                for n in candidates
                if n.content and pattern_lower in n.content.lower()
            ]

    logger.debug(
        f"Search pattern={pattern!r} file_glob={file_glob} node_type={node_type}: "
        f"found {len(matches)} matches from {len(candidates)} candidates"
    )

    return matches

BM25CodeIndexer

BM25CodeIndexer(
    code_graph=None,
    chunks=None,
    max_k: int = 15,
    language: str = "english",
    project_root: str | None = None,
)

A class that builds a BM25 index from CodeGraph nodes and provides search functionality with stemming support.

Parameters:

Name Type Description Default
code_graph

CodeGraph instance containing nodes to index. If provided, the index will be built immediately.

None
chunks

List of CodeChunk objects to index. If provided, the index will be built immediately.

None
max_k int

Maximum number of results to return in searches

15
language str

Language for stopword removal Default is "english" which works well for processing code tokens as it treats special characters as separators

'english'
project_root str | None

Repository root used to resolve relative source paths

None

Methods:

Name Description
build_index_from_graph

Build a BM25 index from a CodeGraph.

build_index_from_chunks

Build a BM25 index from a list of CodeChunk objects.

search

Search the index for nodes matching the query.

search_identifier_occurrences

Find source chunks that reference an exact code identifier.

save_index

Save the index to a directory.

load_index

Load the index from a directory.

Source code in codenib/index/sparse_idx/bm25_index.py
def __init__(
    self,
    code_graph=None,
    chunks=None,
    max_k: int = 15,
    language: str = "english",
    project_root: Optional[str] = None,
):
    """
    Initialize the BM25CodeIndexer and optionally build the index immediately.

    Args:
        code_graph: CodeGraph instance containing nodes to index. If provided,
                   the index will be built immediately.
        chunks: List of CodeChunk objects to index. If provided, the index will
               be built immediately.
        max_k: Maximum number of results to return in searches
        language: Language for stopword removal
                  Default is "english" which works well for processing code tokens
                  as it treats special characters as separators
        project_root: Repository root used to resolve relative source paths
    """
    self.max_k = max_k
    self.language = language
    self.documents = []
    self.retriever = None
    self.code_graph: CodeGraph = None
    self.project_root = project_root
    self.nodes: List[str] = []

    # Build the index immediately if a code_graph is provided
    if code_graph is not None:
        self.build_index_from_graph(code_graph)
    elif chunks is not None:
        self.build_index_from_chunks(chunks, project_root=project_root)

build_index_from_graph

build_index_from_graph(code_graph: CodeGraph) -> BM25Retriever

Build a BM25 index from a CodeGraph.

Parameters:

Name Type Description Default
code_graph CodeGraph

CodeGraph instance containing nodes to index

required
Source code in codenib/index/sparse_idx/bm25_index.py
def build_index_from_graph(self, code_graph: CodeGraph) -> BM25Retriever:
    """
    Build a BM25 index from a CodeGraph.

    Args:
        code_graph: CodeGraph instance containing nodes to index
    """
    # Reset the index
    self.documents = []
    self.nodes = []
    self.code_graph = code_graph
    self.project_root = code_graph.project_root

    # Convert graph nodes to documents
    for vertex in code_graph.graph.vs:
        doc = self._convert_vertex_to_document(vertex)
        if doc is not None:
            self.documents.append(doc)
            node_name = doc.metadata.get("node_id") or doc.metadata.get("name")
            if node_name:
                self.nodes.append(node_name)

    # Create BM25Retriever with LangChain format
    self.retriever = BM25Retriever.from_documents(self.documents, k=self.max_k)

    return self.retriever

build_index_from_chunks

build_index_from_chunks(
    chunks: list[CodeChunk], *, project_root: str | None = None
) -> BM25Retriever

Build a BM25 index from a list of CodeChunk objects.

Parameters:

Name Type Description Default
chunks list[CodeChunk]

List of CodeChunk objects (with node_id, chunk_type, name, file, etc.)

required
project_root str | None

Repository root used to resolve relative source paths

None

Returns:

Type Description
BM25Retriever

BM25Retriever instance

Source code in codenib/index/sparse_idx/bm25_index.py
def build_index_from_chunks(
    self,
    chunks: List[CodeChunk],
    *,
    project_root: Optional[str] = None,
) -> BM25Retriever:
    """
    Build a BM25 index from a list of CodeChunk objects.

    Args:
        chunks: List of CodeChunk objects (with node_id, chunk_type, name, file, etc.)
        project_root: Repository root used to resolve relative source paths

    Returns:
        BM25Retriever instance
    """
    # Reset the index
    self.documents = []
    self.nodes = []
    self.code_graph = None
    self.project_root = project_root

    # Keep each bounded source span independently searchable. Overloads and
    # split large definitions can share a node_id, but merging them widens
    # the returned location and defeats the chunk-size contract.
    documents_by_span: dict[tuple[str, int, int], Document] = {}
    for chunk in chunks:
        node_id = getattr(chunk, "node_id", None)
        if not node_id:
            continue
        doc = self._convert_chunk_to_document(chunk)
        if doc is not None:
            key = (
                node_id,
                int(doc.metadata["start_line"]),
                int(doc.metadata["end_line"]),
            )
            documents_by_span.setdefault(key, doc)

    self.documents = list(documents_by_span.values())
    self.nodes = list(dict.fromkeys(key[0] for key in documents_by_span))

    # Create BM25Retriever with LangChain format
    self.retriever = BM25Retriever.from_documents(self.documents, k=self.max_k)

    return self.retriever

search

search(
    query: str,
    top_k: int | None = None,
    return_code_content: bool = False,
    wrap_with_ln: bool = True,
    filter_test: bool = False,
) -> list[NodeInfo]

Search the index for nodes matching the query.

Parameters:

Name Type Description Default
query str

Search query

required
top_k int | None

Number of top results to return (defaults to max_k if not specified)

None
return_code_content bool

Whether to include code content in the results

False
wrap_with_ln bool

Whether to wrap code content with line numbers

True
filter_test bool

Whether to filter out test files from the results

False

Returns:

Type Description
list[NodeInfo]

List of NodeInfo objects containing matched nodes with optional content

Source code in codenib/index/sparse_idx/bm25_index.py
def search(
    self,
    query: str,
    top_k: Optional[int] = None,
    return_code_content: bool = False,
    wrap_with_ln: bool = True,
    filter_test: bool = False,
) -> List[NodeInfo]:
    """
    Search the index for nodes matching the query.

    Args:
        query: Search query
        top_k: Number of top results to return (defaults to max_k if not specified)
        return_code_content: Whether to include code content in the results
        wrap_with_ln: Whether to wrap code content with line numbers
        filter_test: Whether to filter out test files from the results

    Returns:
        List of NodeInfo objects containing matched nodes with optional content
    """
    if self.retriever is None:
        raise ValueError(
            "Index has not been built. Call build_index_from_graph first."
        )

    # Apply custom text processing to query
    query = self._apply_stemming(query)

    if top_k is None:
        top_k = self.max_k

    logger.debug(f"BM25 search with k={top_k}, num_documents={len(self.documents)}")

    # Retrieve results and truncate to requested top_k
    results = self.retriever.invoke(query)
    logger.info(f"BM25 retrieval returned {len(results)} results")

    # Convert results to NodeInfo objects and apply filtering
    processed_results = []
    for doc in results:
        # Extract all metadata directly from the document
        metadata = doc.metadata
        node_name = metadata.get("node_id", "")

        # Filter out test files if requested
        if filter_test and is_test_file(node_name):
            continue

        # Get basic node info
        file_path = metadata.get("file")
        start_line = metadata.get("start_line")
        end_line = metadata.get("end_line")
        node_type = metadata.get("type", "unknown")

        # Handle code content if requested
        content = None
        if return_code_content and file_path:
            # Construct full file path using project_root if available
            full_file_path = file_path
            if self.project_root and not os.path.isabs(file_path):
                full_file_path = os.path.join(self.project_root, file_path)

            try:
                with open(
                    full_file_path, "r", encoding="utf-8", errors="replace"
                ) as f:
                    if node_type == NODE_TYPE_FILE:
                        # For file nodes, return entire file content
                        code_content = f.read()
                        if wrap_with_ln:
                            lines = code_content.split("\n")
                            content = wrap_code_snippet(code_content, 1, len(lines))
                        else:
                            content = code_content
                    else:
                        # For symbol nodes, extract specific lines
                        if start_line is not None and end_line is not None:
                            lines = f.readlines()
                            # start_line and end_line are already 0-based and inclusive
                            start_idx = max(0, start_line)
                            end_idx = min(
                                len(lines), end_line + 1
                            )  # +1 for slice end exclusivity

                            extracted_lines = lines[start_idx:end_idx]

                            # Strip trailing blank lines to avoid extra whitespace.
                            original_end_idx = len(extracted_lines)
                            while (
                                extracted_lines
                                and extracted_lines[-1].strip() == ""
                            ):
                                extracted_lines.pop()

                            code_content = "".join(extracted_lines)

                            if wrap_with_ln:
                                # Calculate the actual end line after removing empty lines
                                lines_removed = original_end_idx - len(
                                    extracted_lines
                                )
                                actual_end_line = end_line - lines_removed
                                # Convert to 1-based for display purposes
                                content = wrap_code_snippet(
                                    code_content,
                                    start_line + 1,
                                    actual_end_line + 1,
                                )
                            else:
                                content = code_content
            except (IOError, UnicodeDecodeError):
                # If file reading fails, content remains None
                pass

        # Create NodeInfo object (LangChain BM25Retriever doesn't provide scores)
        result = NodeInfo(
            score=0.0,  # LangChain BM25Retriever doesn't provide scores
            node_name=node_name,
            node_id=node_name,  # Set node_id to the same value as node_name
            type=node_type,
            file=file_path,
            start_line=start_line,
            end_line=end_line,
            content=content,
        )

        processed_results.append(result)

        # Stop once we have enough results
        if len(processed_results) >= top_k:
            break

    return processed_results

search_identifier_occurrences

search_identifier_occurrences(
    identifier: str,
    *,
    context_query: str | None = None,
    top_k: int = 20,
    wrap_with_ln: bool = True,
    filter_test: bool = False
) -> list[NodeInfo]

Find source chunks that reference an exact code identifier.

BM25 ranks lexical relevance, but it does not guarantee exhaustive exact-symbol coverage. This complementary path scans persisted chunk ranges so a symbol query can return callers/usages without requiring a graph index.

Source code in codenib/index/sparse_idx/bm25_index.py
def search_identifier_occurrences(
    self,
    identifier: str,
    *,
    context_query: Optional[str] = None,
    top_k: int = 20,
    wrap_with_ln: bool = True,
    filter_test: bool = False,
) -> List[NodeInfo]:
    """Find source chunks that reference an exact code identifier.

    BM25 ranks lexical relevance, but it does not guarantee exhaustive
    exact-symbol coverage. This complementary path scans persisted chunk
    ranges so a symbol query can return callers/usages without requiring a
    graph index.
    """
    symbol = str(identifier or "").strip().removesuffix("()")
    symbol = symbol.rsplit(".", 1)[-1]
    if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", symbol):
        raise ValueError("identifier must be a single code identifier")
    if top_k <= 0:
        return []

    occurrence = re.compile(
        rf"(?<![A-Za-z0-9_]){re.escape(symbol)}(?![A-Za-z0-9_])"
    )
    invocation = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(symbol)}\s*\(")
    context_terms = set(self._apply_stemming(context_query or "").split())
    context_terms -= set(self._apply_stemming(symbol).split())
    context_terms -= {
        "call",
        "caller",
        "calls",
        "current",
        "site",
        "sites",
        "usage",
        "use",
        "used",
        "where",
    }
    files: dict[str, List[str]] = {}
    matches: List[tuple[int, int, int, NodeInfo]] = []

    for position, doc in enumerate(self.documents):
        metadata = doc.metadata
        file_path = metadata.get("file")
        start_line = metadata.get("start_line")
        end_line = metadata.get("end_line")
        if (
            not file_path
            or not isinstance(start_line, int)
            or not isinstance(end_line, int)
        ):
            continue
        node_name = str(metadata.get("node_id") or metadata.get("name") or "")
        if filter_test and is_test_file(node_name or file_path):
            continue

        declared_name = str(metadata.get("name") or "").removesuffix("()")
        if declared_name.rsplit(".", 1)[-1] == symbol:
            continue

        full_path = file_path
        if self.project_root and not os.path.isabs(file_path):
            full_path = os.path.join(self.project_root, file_path)
        if full_path not in files:
            try:
                with open(
                    full_path,
                    "r",
                    encoding="utf-8",
                    errors="replace",
                ) as source:
                    files[full_path] = source.readlines()
            except OSError:
                files[full_path] = []
        lines = files[full_path]
        if not lines:
            continue

        start_idx = max(0, start_line)
        end_idx = min(len(lines), end_line + 1)
        selected = lines[start_idx:end_idx]
        code_content = "".join(selected)
        if not occurrence.search(code_content):
            continue

        call_count = len(invocation.findall(code_content))
        searchable = self._apply_stemming(f"{node_name} {file_path} {code_content}")
        context_score = sum(
            searchable.count(term) for term in context_terms if len(term) >= 3
        )
        content = (
            wrap_code_snippet(
                code_content,
                start_idx + 1,
                start_idx + len(selected),
            )
            if wrap_with_ln
            else code_content
        )
        matches.append(
            (
                -context_score,
                -call_count,
                position,
                NodeInfo(
                    score=float(call_count or 1),
                    node_name=node_name,
                    node_id=node_name,
                    type=metadata.get("type", "unknown"),
                    file=file_path,
                    start_line=start_line,
                    end_line=end_line,
                    content=content,
                ),
            )
        )

    matches.sort(key=lambda item: (item[0], item[1], item[2]))
    return [node for _, _, _, node in matches[:top_k]]

save_index

save_index(directory_path: str)

Save the index to a directory.

Parameters:

Name Type Description Default
directory_path str

Path to save the index to

required
Source code in codenib/index/sparse_idx/bm25_index.py
def save_index(self, directory_path: str):
    """
    Save the index to a directory.

    Args:
        directory_path: Path to save the index to
    """
    if self.retriever is None:
        raise ValueError(
            "Index has not been built. Call build_index_from_graph first."
        )

    # Create directory if it doesn't exist
    os.makedirs(directory_path, exist_ok=True)

    # Save documents as JSON since LangChain BM25Retriever doesn't have persist method
    documents_data = []
    for doc in self.documents:
        documents_data.append(
            {"page_content": doc.page_content, "metadata": doc.metadata}
        )

    documents_file = os.path.join(directory_path, "documents.json")
    with open(documents_file, "w", encoding="utf-8") as f:
        json.dump(documents_data, f, indent=2)

    # Save additional metadata including project_root
    metadata = {
        "project_root": (
            str(self.project_root) if self.project_root is not None else None
        ),
        "max_k": self.max_k,
        "language": self.language,
    }
    metadata_file = os.path.join(directory_path, "bm25_metadata.json")
    with open(metadata_file, "w", encoding="utf-8") as f:
        json.dump(metadata, f, indent=2)

load_index

load_index(directory_path: str)

Load the index from a directory.

Parameters:

Name Type Description Default
directory_path str

Path to load the index from

required
Source code in codenib/index/sparse_idx/bm25_index.py
def load_index(self, directory_path: str):
    """
    Load the index from a directory.

    Args:
        directory_path: Path to load the index from
    """
    if not os.path.exists(directory_path):
        raise ValueError(f"Directory {directory_path} does not exist.")

    # Load documents from JSON
    documents_file = os.path.join(directory_path, "documents.json")
    if not os.path.exists(documents_file):
        raise ValueError(f"Documents file not found: {documents_file}")

    with open(documents_file, "r", encoding="utf-8") as f:
        documents_data = json.load(f)

    # Reconstruct Document objects
    self.documents = []
    self.nodes = []
    seen_nodes = set()
    for doc_data in documents_data:
        doc = Document(
            page_content=doc_data["page_content"], metadata=doc_data["metadata"]
        )
        self.documents.append(doc)
        node_name = doc.metadata.get("node_id") or doc.metadata.get("name")
        if node_name and node_name not in seen_nodes:
            self.nodes.append(node_name)
            seen_nodes.add(node_name)

    # Load additional metadata including project_root
    metadata_file = os.path.join(directory_path, "bm25_metadata.json")
    if os.path.exists(metadata_file):
        with open(metadata_file, "r", encoding="utf-8") as f:
            metadata = json.load(f)
            self.project_root = metadata.get("project_root")
            self.max_k = metadata.get("max_k", 10)
            self.language = metadata.get("language", "english")
    else:
        # For backward compatibility with indices saved without metadata
        self.project_root = None

    # Recreate only after restoring ``max_k``. Reversing this order silently
    # capped loaded artifacts at the constructor default (15), even when
    # the persisted compiler contract requested 128 candidates.
    self.retriever = BM25Retriever.from_documents(self.documents, k=self.max_k)

create_code_vector_store

create_code_vector_store(
    embedding_model: str = "text-embedding-ada-002",
    embedding_provider: str = "openai",
    store_path: str | None = None,
    **kwargs
) -> CodeVectorStore

Factory function to create a CodeVectorStore.

Parameters:

Name Type Description Default
embedding_model str

Name of the embedding model

'text-embedding-ada-002'
embedding_provider str

Provider for embeddings

'openai'
store_path str | None

Path to store/load the vector store

None
**kwargs

Additional arguments for CodeVectorStore

{}

Returns:

Type Description
CodeVectorStore

CodeVectorStore instance

Source code in codenib/index/embedding/vector_store.py
def create_code_vector_store(
    embedding_model: str = "text-embedding-ada-002",
    embedding_provider: str = "openai",
    store_path: Optional[str] = None,
    **kwargs,
) -> CodeVectorStore:
    """
    Factory function to create a CodeVectorStore.

    Args:
        embedding_model: Name of the embedding model
        embedding_provider: Provider for embeddings
        store_path: Path to store/load the vector store
        **kwargs: Additional arguments for CodeVectorStore

    Returns:
        CodeVectorStore instance
    """
    return CodeVectorStore(
        embedding_model=embedding_model,
        embedding_provider=embedding_provider,
        store_path=store_path,
        **kwargs,
    )