Getting started

Installation

NexRAG is on PyPI. Install the core plus the extras you need.

# Core + OpenAI + PDF (most common setup)
pip install "nexrag[openai,pdf]"

# Core only — bring your own adapters
pip install nexrag

# Everything
pip install "nexrag[all]"
Available extras
openai
OpenAI embeddings & LLM
anthropic
Claude models via Anthropic API
gemini
Google Gemini LLM & embeddings (google-genai)
ollama
Local Ollama LLM & embeddings
huggingface
HuggingFace Inference API embeddings
chromadb
Default vector store
pinecone
Pinecone serverless vector store
pdf
PDF ingestion via pypdf
tiktoken
Token-aware chunking (token strategy)
code
Code-aware chunking (tree-sitter)
pii
PII guard via Microsoft Presidio
bm25
BM25 keyword retrieval (rank-bm25)
cohere
Cohere Rerank API
cross-encoder
CrossEncoder reranker (sentence-transformers)
all-providers
All embedder & LLM providers + vector DBs
all-chunkers
tiktoken + code chunking backends
all-guards
Guardrail backends (Presidio)
all-loaders
All document loaders (PDF, Word, HTML, Excel)
all-sparse
BM25 sparse retrieval bundle (rank-bm25)
all-rerankers
All reranker backends (Cohere + sentence-transformers)
all-retrieval
all-sparse + all-rerankers
all
Everything — providers + loaders + retrieval + chunkers + guards. Dev/CI only: pulls PyTorch via sentence-transformers.

Requires Python 3.12+. NexRAG uses modern Python features including typed generics and structural pattern matching.

Getting started

Configuration

NexRAG is configured entirely through a YAML file — by default nexrag.yaml in your working directory.

# nexrag.yaml — full config reference (v0.5.0)

mode: sync             # sync (default) | async — enables native async pipeline

ingestion:
  loader:
    type: pdf             # auto | pdf | txt | custom
    include_metadata: true      # extract PDF metadata fields (default: true)
    # metadata_fields: [title, author, creation_date]  # whitelist — omit for all 8

  chunker:
    strategy: recursive   # recursive | fixed | token | sentence | sentence_window | markdown | code | semantic | proposition | custom
    chunk_size: 512
    chunk_overlap: 64
    min_chunk_size: 50
    # separator: "\n\n"  # top-level split character (default: double newline)
    # semantic / proposition take their OWN nested component, resolved
    # independently of the pipeline models (cheap model for chunking):
    # embedder: { provider: ollama, model: nomic-embed-text }   # semantic
    # llm:      { provider: gemini, model: gemini-2.5-flash }    # proposition

  embedder:
    provider: openai      # openai | gemini | huggingface | ollama | custom
    model: text-embedding-3-small
    api_key: ${OPENAI_API_KEY}
    # base_url: https://...  # override API base URL (OpenAI-compatible endpoints)
    # max_retries: 2         # embedding API retry attempts (default: 2)

  vector_db:
    provider: chroma           # chroma | pinecone | custom
    default_collection: documents
    on_conflict: overwrite       # overwrite | skip | append
    upsert_batch_size: 500        # max chunks per ChromaDB upsert call
    max_retries: 3               # connection retry attempts
    retry_delay: 1.0             # initial backoff in seconds (exponential)
    collections:
      documents:
        mode: persistent          # persistent | memory | server
        path: ./.nexrag/chroma
        # host: localhost   # server mode only
        # port: 8000        # server mode only

query:
  embedder: inherit             # reuse ingestion embedder
  retriever:
    provider: dense             # dense | bm25 | hybrid
    top_k: 5
    score_threshold: 0.0
    # hybrid-only options:
    # alpha: 0.7          # 0=pure BM25, 1=pure dense (default 0.7)
    # sparse_top_k: 50    # BM25 candidates before fusion
    # sparse:
    #   provider: bm25    # bm25 | custom
  # reranker:                       # optional — omit to skip reranking
  #   provider: cohere               # cohere | cross_encoder | custom
  #   model: rerank-english-v3.0
  #   api_key: ${COHERE_API_KEY}
  #   top_n: 5                       # chunks passed to LLM after reranking
  # max_query_length: 8000           # reject queries longer than this (chars); 0 = disabled
  llm:
    provider: openai          # openai | anthropic | gemini | ollama | custom
    model: gpt-4o
    api_key: ${OPENAI_API_KEY}
    # base_url: https://...  # override API base URL
    temperature: 0.2
    max_tokens: 1024
    # timeout: 30            # request timeout in seconds (default: 30)
    # max_retries: 2         # LLM API retry attempts (default: 2)
  prompt:
    system: |
      You are a helpful assistant. Answer using only
      the provided context. If unknown, say so.
    # context_format: numbered  # numbered | labeled | plain (how chunks are presented)
    # template: default          # default | qa | summarize | custom
  # cache:                            # optional query-result cache — disabled by default
  #   enabled: true
  #   backend: memory                 # memory | custom
  #   strategy: exact                 # exact | semantic (semantic requires a custom backend)
  #   max_size: 1000                  # max cached entries (memory backend)
  #   ttl_seconds: 300                # entry time-to-live
  # session:                          # optional multi-turn conversation memory — disabled by default
  #   enabled: true
  #   backend: memory                 # memory | custom
  #   session_ttl_seconds: 1800       # idle session lifetime
  #   persist: true                   # false = privacy mode (history never stored)
  #   context_strategy:               # how much history is injected into the prompt
  #     type: window                  # window | token_budget | custom
  #     max_history_turns: 6          # window: keep the last N turns
  #     max_tokens: 2000              # token_budget: fit history within N tokens
  # rate_limit:                       # optional client-side throttle — disabled by default
  #   enabled: true
  #   requests_per_minute: 60
  #   burst: 10                       # allow short bursts up to this many requests

guardrails:                  # optional security layer — every chain is disabled by default
  input:                       # runs on the user query
    enabled: true
    policy: fail_closed        # fail_open (default) | fail_closed
    guards:
      - type: access_control   # per-request retrieval filter from auth_context
        params: { mapping: { tenant: tenant } }
      - type: prompt_injection  # heuristic injection/jailbreak detection
  retrieved:                   # runs on each retrieved chunk (an injection vector)
    enabled: true
    guards:
      - type: pii               # redact PII before it reaches the prompt
  output:                      # runs on the LLM answer (buffers when streaming)
    enabled: true
    guards:
      - type: groundedness      # cheap lexical-overlap check vs context
  # ingestion:                  # runs on document text before chunking
  #   enabled: true
  #   guards: [ { type: pii, params: { mode: redact } } ]

observability:
  enabled: true
  service_name: my-rag-app
  signals:
    metrics: true
    traces:  true
    logs:    true
  exporters:
    prometheus:
      enabled: true      # pull → GET http://host:9464/metrics
      port:    9464
    otlp:
      enabled:   false     # push → OTel Collector / Grafana Alloy
      endpoint:  ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4317}
      protocol:  grpc      # grpc | http
      insecure:  true
    console:
      enabled: false      # debug exporter
  metrics:
    pricing:             # for nexrag.llm.cost_per_query_usd
      gpt-4o:      { input: 0.0025,  output: 0.01   }
      gpt-4o-mini: { input: 0.00015, output: 0.0006 }
  evaluations:           # optional LLM-as-judge tier — off by default
    enabled:      false
    sample_rate:  0.1
    faithfulness:
      enabled: true
      llm:
        provider: openai
        model:    gpt-4o-mini
        api_key:  ${OPENAI_API_KEY}
!

Never hardcode API keys. Use ${ENV_VAR} substitution. YAML files are often committed to version control.

Getting started

Quickstart

Ingest a document and query it in under ten lines of Python.

import os
from nexrag import NexRAG

os.environ["OPENAI_API_KEY"] = "sk-..."  # or set in your shell

pipeline = NexRAG.from_config("nexrag.yaml")

from pathlib import Path

# Ingest — loaders expect bytes or str, not a file path
result = pipeline.ingest(Path("contracts/agreement.pdf").read_bytes())
print(f"Ingested {result.documents_loaded} doc")
print(f"Wrote {result.chunks_written} chunks in {result.latency_ms:.1f}ms")

# Query
result = pipeline.query("What are the termination clauses?")
print(result.answer)

for source in result.sources:
    print(f"  [{source.rank}] score={source.score:.3f}  {source.source}")
Guides

Ingestion

pipeline.ingest() accepts raw bytes (for binary formats like PDF) or a plain string (for text content). Loaders are converter-only since v0.3.0 — they never open files themselves. Read the file first, then pass the content. pipeline.ingest_documents() accepts pre-built Document objects.

Reading from disk

Read the file to bytes or text first, then pass the content to ingest(). Use the metadata argument to attach a source name so the result is traceable.

from pathlib import Path

# PDF — pass bytes
result = pipeline.ingest(
    Path("contracts/agreement.pdf").read_bytes(),
    metadata={"source": "agreement.pdf"},
)

# Text file — pass string
result = pipeline.ingest(
    Path("notes/onboarding.txt").read_text(),
    metadata={"source": "onboarding.txt"},
)
From remote bytes (e.g. S3)
import boto3

s3 = boto3.client("s3")
pdf_bytes = s3.get_object(Bucket="bucket", Key="report.pdf")["Body"].read()

result = pipeline.ingest(pdf_bytes, metadata={"source": "report.pdf"})
From a plain string
text = "Alice joined Acme Corp in 2019..."
# pass a (text, source_name) tuple — second element becomes metadata["source"]
result = pipeline.ingest((text, "alice_bio"))

# or use the metadata argument explicitly
result = pipeline.ingest(text, metadata={"source": "alice_bio"})
From pre-built Document objects

Use ingest_documents() when your data comes from a database or API — anywhere a file path doesn't make sense.

from nexrag.core.models.document import Document

docs = [
    Document(
        content="NexRAG is a framework-agnostic RAG pipeline SDK.",
        metadata={"source": "internal-wiki", "team": "platform"},
    ),
]
result = pipeline.ingest_documents(docs)
Batch ingestion

Use ingest_batch() to ingest multiple sources in sequence. One IngestionResult is returned per source, in the same order. Each element in the list is passed to the loader — read bytes/text before passing.

from pathlib import Path

files = ["contracts/q1.pdf", "contracts/q2.pdf", "contracts/q3.pdf"]
results = pipeline.ingest_batch([Path(f).read_bytes() for f in files])
for r in results:
    print(f"{r.chunks_written} chunks from pipeline_id={r.pipeline_id}")

ingest_batch() applies a single metadata dict to all sources. To attach different metadata per file (e.g. different source names), call pipeline.ingest() in a loop with per-call metadata instead.

Conflict strategies

Ingestion is idempotent by default. Each chunk is fingerprinted with SHA-256. The on_conflict setting controls what happens when a chunk already exists.

StrategyBehavior
overwriteDelete existing chunk and write the new one (default)
skipSkip chunks that already exist — safe for re-ingestion
appendAlways write, even if the chunk exists
Multi-collection routing

Route ingestions and queries to named collections declared in YAML. Each collection is an independent namespace in ChromaDB.

# nexrag.yaml
vector_db:
  default_collection: documents
  collections:
    documents:
      mode: persistent
      path: ./.nexrag/documents
    resumes:
      mode: persistent
      path: ./.nexrag/resumes
from pathlib import Path

# ingest into a named collection
results = pipeline.ingest_batch([
    Path("resumes/alice.pdf").read_bytes(),
    Path("resumes/bob.pdf").read_bytes(),
], collection="resumes")

# query a named collection
result = pipeline.query("Candidate with Python experience", collection="resumes")
print(result.collection_used)   # → "resumes"

Each collection with a distinct mode, path, host, or port gets its own ChromaDB client instance. Collections sharing the same connection settings reuse the same client. This allows mixing persistent and in-memory collections in the same pipeline.

Guides

Querying

All query parameters can be overridden per-call without changing the YAML.

result = pipeline.query("What is the refund policy?")

# Override retrieval params
result = pipeline.query("Summarize the contract", top_k=10)
result = pipeline.query("Exact clause text", score_threshold=0.75)

# Query a specific collection
result = pipeline.query("Refund policy", collection="terms_of_service")

# Filter by metadata
result = pipeline.query(
    "What changed in 2024?",
    metadata_filter={"year": 2024},
)
Hybrid retrieval (BM25 + dense)

Use provider: hybrid to fuse keyword-based (BM25) and vector-based (dense) search. alpha controls the balance — 1.0 is pure dense, 0.0 is pure BM25. Requires pip install "nexrag[bm25]".

# nexrag.yaml
query:
  retriever:
    provider: hybrid
    top_k: 10
    alpha: 0.7          # 0=pure BM25, 1=pure dense (default 0.7)
    sparse_top_k: 50   # BM25 candidate pool before fusion
    sparse:
      provider: bm25

metadata_filter is supported for bm25 and hybrid providers — filtering is applied post-scoring. The BM25 index is cached per-collection in memory and automatically invalidated after each ingest() call.

Reranking

Add an optional reranker stage between the retriever and the LLM. The typical pattern is to retrieve a large candidate set with top_k and rerank down to a small top_n for the LLM — combining recall from retrieval with precision from reranking.

# nexrag.yaml
query:
  retriever:
    provider: dense
    top_k: 50            # retrieve many candidates
  reranker:
    provider: cohere      # cohere | cross_encoder | custom
    model: rerank-english-v3.0
    api_key: ${COHERE_API_KEY}
    top_n: 5              # only 5 chunks reach the LLM

For a local, offline reranker use provider: cross_encoder with a sentence-transformers model. Requires pip install "nexrag[cross-encoder]".

reranker:
  provider: cross_encoder
  model: cross-encoder/ms-marco-MiniLM-L-6-v2
  top_n: 5
  # device: cuda   # optional — CPU by default

stream_query() and astream_query() yield RunMetrics as the final stream item. Use isinstance(item, RunMetrics) to separate tokens from the metrics object — stage latencies and chunk count are available without a separate non-streaming call.

Guides

Async & streaming

NexRAG ships two pipeline modes. Sync (default) — straightforward, thread-safe, works everywhere. Async — native async/await with parallel batch embedding and live token streaming.

Enabling async mode

Add one line to your YAML. No application code changes required.

# nexrag.yaml
mode: async   # async | sync (default)

ingestion:
  embedder:
    provider: openai
    model: text-embedding-3-small
    api_key: ${OPENAI_API_KEY}
    batch_size: 50   # chunks per concurrent API call batch

In async mode, embedding batches run as concurrent API calls. For large documents this is the biggest performance win — N batches in parallel instead of N sequential requests.

Sync streaming

stream_query() works with any sync caller — no event loop required. Tokens are yielded as they arrive from the LLM. The final item in the stream is always a RunMetrics object — use isinstance to separate tokens from metrics.

from nexrag import NexRAG, RunMetrics

pipeline = NexRAG.from_config("nexrag.yaml")

metrics = None
for item in pipeline.stream_query("Summarize the key obligations"):
    if isinstance(item, RunMetrics):
        metrics = item
    else:
        print(item, end="", flush=True)
print()
print(f"{metrics.total_latency_ms:.0f}ms — {metrics.chunks_retrieved} chunks")

# Runtime overrides work identically to query()
for item in pipeline.stream_query(
    "What are the termination clauses?",
    top_k=10,
    score_threshold=0.6,
):
    if not isinstance(item, RunMetrics):
        print(item, end="", flush=True)
Async streaming

astream_query() is the async variant. With mode: async configured, pre-LLM stages use native async clients and tokens stream live. With mode: sync, the sync stream runs in a thread pool — non-blocking for the event loop, but tokens buffer until the stream completes in the thread. Like the sync variant, RunMetrics is the final item yielded.

import asyncio
from nexrag import NexRAG, RunMetrics

pipeline = NexRAG.from_config("nexrag.yaml")

async def main():
    metrics = None
    async for item in pipeline.astream_query(
        "What changed in 2024?",
        metadata_filter={"year": 2024},
    ):
        if isinstance(item, RunMetrics):
            metrics = item
        else:
            print(item, end="", flush=True)
    print(f"\n{metrics.total_latency_ms:.0f}ms")

asyncio.run(main())
FastAPI integration

Use async_query() for non-blocking queries, or astream_query() for SSE / streaming responses. Configure mode: async for true async parallelism at every stage.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from nexrag import NexRAG

app = FastAPI()
pipeline = NexRAG.from_config("nexrag.yaml")  # mode: async in nexrag.yaml

@app.get("/ask")
async def ask(q: str):
    result = await pipeline.async_query(q)
    return {"answer": result.answer, "sources": [s.source for s in result.sources]}

@app.get("/stream")
async def stream(q: str):
    async def gen():
        async for token in pipeline.astream_query(q):
            yield token
    return StreamingResponse(gen(), media_type="text/plain")
!

Do not call pipeline.query() or pipeline.ingest() from inside a running event loop. Use async_query() and async_ingest() inside async contexts. NexRAG raises a clear NexRAGError if you mix sync and async entry points.

Streaming calls yield RunMetrics as the last item. Both stream_query() and astream_query() yield string tokens followed by a single RunMetrics object. Use isinstance(item, RunMetrics) to detect it. token_usage is None in streaming (streaming LLM APIs do not expose counts mid-stream).

All async methods
MethodDescription
async_query(text, **kwargs)Non-blocking query — awaitable, returns PipelineResult
astream_query(text, **kwargs)Async generator — yields tokens then a final RunMetrics; use isinstance(item, RunMetrics) to separate
async_ingest(data, loader?)Non-blocking ingest — awaitable, returns IngestionResult
async_ingest_batch(sources, **kwargs)Non-blocking batch ingest — embeds all chunks across the batch in one call; returns list[IngestionResult]
async_query_session(text, session_id, **kwargs)Non-blocking multi-turn query — awaitable, returns PipelineResult (requires query.session.enabled)
stream_query(text, **kwargs)Sync generator — yields tokens then a final RunMetrics; no event loop needed
Guides

Secret management

NexRAG supports ${ENV_VAR} substitution in YAML before any parsing occurs.

# Required — fails at startup if not set
api_key: ${OPENAI_API_KEY}

# Optional with empty-string fallback
base_url: ${OPENAI_BASE_URL:-}

# Default value
model: ${LLM_MODEL:-gpt-4o}
# Load from .env before from_config
from dotenv import load_dotenv
load_dotenv()
pipeline = NexRAG.from_config("nexrag.yaml")
Guides

Custom components

Any pipeline stage can be replaced. Implement the interface from nexrag.core.interfaces, then declare it in YAML with provider: custom and a dotted class: path.

Example: custom embedder
# myapp/embedders.py
from nexrag.core.interfaces.embedder import BaseEmbedder

class SentenceTransformerEmbedder(BaseEmbedder):
    def __init__(self, model: str = "all-MiniLM-L6-v2") -> None:
        from sentence_transformers import SentenceTransformer
        self._model = SentenceTransformer(model)
        self._model_name = model

    @property
    def model_name(self) -> str: return self._model_name

    @property
    def dimensions(self) -> int:
        return self._model.get_sentence_embedding_dimension()

    def embed(self, texts: list[str]) -> list[list[float]]:
        return self._model.encode(texts).tolist()

    def embed_query(self, text: str) -> list[float]:
        return self._model.encode([text])[0].tolist()
# nexrag.yaml
ingestion:
  embedder:
    provider: custom
    class:    myapp.embedders.SentenceTransformerEmbedder
    params:
      model:  all-MiniLM-L6-v2

The same pattern works for every stage: loader, chunker, sanitizer, vector_db, retriever, reranker, prompt_builder, llm, observer, and evaluator. For a custom observer use the class_path key under observability.

Available interfaces
InterfaceImport path
BaseLoadernexrag.core.interfaces.loader
BaseSanitizernexrag.core.interfaces.sanitizer
BaseChunkernexrag.core.interfaces.chunker
BaseEmbeddernexrag.core.interfaces.embedder
BaseVectorDBnexrag.core.interfaces.vector_db
BaseRetrievernexrag.core.interfaces.retriever
BaseSparseRetrievernexrag.core.interfaces.sparse_retriever
BaseRerankernexrag.core.interfaces.reranker
BasePromptBuildernexrag.core.interfaces.prompt_builder
BaseLLMnexrag.core.interfaces.llm
BaseObservernexrag.core.interfaces.observer
BaseEvaluatornexrag.core.interfaces.evaluator
Guides

Testing

Use in-memory ChromaDB and mock embedders/LLMs to test without any API calls.

Memory mode for ChromaDB
# nexrag.test.yaml
vector_db:
  provider: chroma
  default_collection: test
  collections:
    test:
      mode: memory   # ephemeral, no disk I/O
Mock embedder and LLM
from nexrag.core.interfaces.embedder import BaseEmbedder
from nexrag.core.interfaces.llm import BaseLLM

class FixedEmbedder(BaseEmbedder):
    DIM = 8

    @property
    def model_name(self) -> str: return "fixed-test"

    @property
    def dimensions(self) -> int: return self.DIM

    def embed(self, texts):
        return [[float(i % self.DIM) for i in range(self.DIM)] for _ in texts]

    def embed_query(self, text):
        return [float(i % self.DIM) for i in range(self.DIM)]


class EchoLLM(BaseLLM):
    # generate() returns (answer, token_usage) — token_usage is None when unknown
    def generate(self, prompt: str): return ("test answer", None)
    def stream(self, prompt): yield "test answer"
Reference

API reference

Return types for the two main pipeline methods.

IngestionResult

Returned by pipeline.ingest(), pipeline.ingest_documents(), and each element of pipeline.ingest_batch().

FieldTypeDescription
pipeline_idstrUUID for this ingestion run
documents_loadedintNumber of documents loaded
chunks_producedintTotal chunks produced by the chunker
chunks_writtenintChunks actually written (skips duplicates on skip)
latency_msfloatWall-clock time for the full ingestion pipeline
collection_usedstrWhich vector DB collection was used (empty string if default)
metricsRunMetrics | NonePer-stage latency breakdown and observability data
PipelineResult

Returned by pipeline.query() and pipeline.async_query(). Not returned by streaming calls.

FieldTypeDescription
pipeline_idstrUUID for this query run
answerstrLLM-generated answer
querystrThe original query string
sourceslist[Source]Retrieved sources ordered by similarity score
scoreslist[float]Convenience list of scores mirroring sources order
collection_usedstrWhich vector DB collection was queried
latency_msfloatWall-clock time for the full query pipeline
token_usageTokenUsage | NoneToken counts from the LLM (None if not exposed)
metricsRunMetrics | NonePer-stage latency breakdown and token usage
metadatadict[str, Any]Arbitrary metadata attached during the run (empty dict by default)
RunMetrics

Attached to PipelineResult.metrics and IngestionResult.metrics. Also yielded as the final item from stream_query() and astream_query() — use isinstance(item, RunMetrics) to detect it in a stream.

FieldTypeDescription
pipeline_idstrSame UUID as the parent result object
total_latency_msfloatWall-clock time for the full pipeline run
stage_latenciesdict[str, float]Per-stage breakdown, e.g. {"embedder": 120.3, "llm": 890.1}
token_usageTokenUsage | NoneToken counts (None if the LLM does not expose them)
modelstr | NoneLLM model name from the completed event
chunks_retrievedint | NoneChunks returned by the retriever (query pipeline only)
chunks_writtenint | NoneChunks written to vector DB (ingestion pipeline only)
result = pipeline.query("What are the termination clauses?")
m = result.metrics
print(f"Total: {m.total_latency_ms:.0f}ms")
print(f"Stages: {m.stage_latencies}")
print(f"Tokens: {m.token_usage.total_tokens if m.token_usage else 'n/a'}")
Source

Each element in result.sources.

FieldTypeDescription
contentstrThe chunk's text content
sourcestrOrigin identifier from chunk.metadata["source"]
metadatadictFull metadata dict from the chunk
scorefloatCosine similarity score (0–1, higher = more similar)
rankint1-based rank in the result list
chunk_indexint | None0-based position of this chunk within its parent document
total_chunksint | NoneTotal number of chunks the parent document was split into
parent_doc_idstr | NoneDocument ID of the source document this chunk came from
Conversation sessions

Available when query.session.enabled: true. Each method takes a caller-supplied session_id (any opaque string — chat thread, socket id, etc.). History is injected into the prompt only; retrieval always uses the current query alone. Calling these with sessions disabled raises RuntimeError.

MethodDescription
query_session(text, session_id, **kwargs)Run a query as the next turn in a conversation — returns PipelineResult and appends the turn to history
async_query_session(text, session_id, **kwargs)Awaitable variant of query_session()
clear_session(session_id)Delete all stored history for a session (idempotent) — for "start over" and data-deletion requests
delete_turns(session_id, before=timestamp)Delete turns older than a Unix timestamp; returns the number of turns removed
r1 = pipeline.query_session("What is NexRAG?", session_id="chat-42")
r2 = pipeline.query_session("And who maintains it?", session_id="chat-42")  # sees turn 1
pipeline.clear_session("chat-42")  # forget the conversation
Components

Loaders

A loader converts raw data into a list of Document objects. Since v0.3.0, loaders are converter-only — they accept bytes or str only. File I/O is the caller's responsibility: read the file to bytes or text first, then pass it to pipeline.ingest().

Auto-detect

The default loader. Inspects the MIME type or file extension of the source and dispatches to the appropriate loader automatically. Use when you want to ingest mixed file types without changing YAML.

# nexrag.yaml
ingestion:
  loader:
    type: auto   # default — detects pdf, txt, and more
PDF

Extracts text content and up to 8 document metadata fields from PDF files using pypdf. Requires pip install "nexrag[pdf]".

Extracted metadata fields: title, author, subject, keywords, creator, producer, creation_date, modification_date.

# nexrag.yaml
ingestion:
  loader:
    type: pdf
    include_metadata: true          # attach metadata to each chunk (default: true)
    # metadata_fields: [title, author]  # whitelist specific fields; omit for all 8
Plain text

Wraps a plain string into a single Document. Use for ingesting strings from a database, API, or in-memory content.

# nexrag.yaml
ingestion:
  loader:
    type: txt
# runtime: pass a (text, source_name) tuple
result = pipeline.ingest(("NexRAG is a RAG pipeline SDK.", "intro"))
Custom loader

Implement BaseLoader to load any format not covered by the built-in loaders (DOCX, HTML, Excel, database rows, etc.).

from nexrag.core.interfaces.loader import BaseLoader
from nexrag.core.models.document import Document

class MarkdownLoader(BaseLoader):
    def load(self, source: str) -> list[Document]:
        # source is a markdown string
        return [Document(content=source, metadata={"source": "markdown"})]
# nexrag.yaml
ingestion:
  loader:
    type: custom
    class: myapp.loaders.MarkdownLoader
Components

Chunkers

A chunker splits Document objects into smaller Chunk objects suitable for embedding. Chunk boundaries significantly affect retrieval quality — smaller chunks give precise matches, larger chunks provide more context.

Recursive

The default chunker. Splits text recursively on a priority list of separators (\n\n, \n, . , ) until each chunk is under chunk_size. Preserves natural paragraph and sentence boundaries where possible.

# nexrag.yaml
ingestion:
  chunker:
    strategy: recursive   # the default; see all strategies below
    chunk_size: 512       # max characters per chunk
    chunk_overlap: 64    # overlap between consecutive chunks
    min_chunk_size: 50   # discard chunks shorter than this

A chunk overlap of 10–15% of chunk_size typically improves retrieval — context at chunk boundaries is duplicated so a query landing near an edge still retrieves the relevant passage.

Fixed

Splits text into fixed-length character windows with a configurable overlap (sliding window). No separator logic — each chunk is exactly chunk_size characters, except the last which may be shorter. Useful when you need strict, predictable chunk boundaries.

# nexrag.yaml
ingestion:
  chunker:
    strategy: fixed
    chunk_size: 512        # characters per chunk
    chunk_overlap: 64     # overlap between consecutive chunks
    min_chunk_size: 50    # discard chunks shorter than this
Production strategies

v0.4.0 ships a full suite of structure- and meaning-aware chunkers. All accept the common chunk_size / chunk_overlap / min_chunk_size knobs; strategy-specific options go under params.

strategyWhat it doesExtra
tokenTokenizer-aware fixed windows (tiktoken) — counts in tokens, not characters.nexrag[tiktoken]
sentencePacks whole sentences up to chunk_size, never splitting mid-sentence.
sentence_windowOne chunk per sentence, expanded with params.window_size neighbours for context.
markdownSplits on heading hierarchy; records the header path in metadata["header_path"].
codeLanguage-aware splitting on function/class boundaries (tree-sitter, regex fallback).nexrag[code]
semanticSplits where adjacent-sentence embedding similarity drops. Needs an embedder.nested embedder
propositionAn LLM rewrites text into atomic, self-contained propositions. Highest cost. Needs an LLM.nested llm

Nested component configs. The semantic and proposition chunkers resolve their own embedder/LLM — a full component block, resolved independently of the pipeline's main models. Run a cheap model for chunking and a strong one for generation:

# nexrag.yaml — semantic chunking with an independent (cheap) embedder
ingestion:
  chunker:
    strategy: semantic
    embedder:                        # resolved independently of ingestion.embedder
      provider: ollama
      model: nomic-embed-text
    params:
      threshold: 0.5               # or omit for percentile-based breakpoints
      buffer_size: 1

# proposition chunking uses a nested `llm:` block instead:
#   strategy: proposition
#   llm: { provider: gemini, model: gemini-2.5-flash, api_key: ${GOOGLE_API_KEY} }
Custom chunker

Implement BaseChunker for semantic chunking, fixed-size chunking, or any domain-specific splitting strategy (e.g. split legal contracts by clause, code files by function).

from nexrag.core.interfaces.chunker import BaseChunker
from nexrag.core.models.document import Document
from nexrag.core.models.chunk import Chunk

class ParagraphChunker(BaseChunker):
    def chunk(self, document: Document) -> list[Chunk]:
        paragraphs = [p.strip() for p in document.content.split("\n\n") if p.strip()]
        return [
            Chunk(
                text=p,
                chunk_index=i,
                total_chunks=len(paragraphs),
                parent_doc_id=document.doc_id,
                metadata={**document.metadata, "paragraph_index": i},
            )
            for i, p in enumerate(paragraphs)
        ]
# nexrag.yaml
ingestion:
  chunker:
    strategy: custom
    class: myapp.chunkers.ParagraphChunker
Components

Embedders

An embedder converts text into dense vector representations. NexRAG uses the same embedder instance for both ingestion and query by default — ensuring the query vector lives in the same space as the indexed chunks.

OpenAI

Uses OpenAI's embedding API. Requires pip install "nexrag[openai]".

# nexrag.yaml
ingestion:
  embedder:
    provider: openai
    model: text-embedding-3-small   # or text-embedding-3-large, text-embedding-ada-002
    api_key: ${OPENAI_API_KEY}
    batch_size: 100                # texts per API call (default 100)
Gemini

Google Gemini embeddings via the unified google-genai SDK. Dimensionality is detected lazily. Requires pip install "nexrag[gemini]". Reads GOOGLE_API_KEY / GEMINI_API_KEY if api_key is omitted.

# nexrag.yaml
ingestion:
  embedder:
    provider: gemini
    model: gemini-embedding-001     # or text-embedding-004
    api_key: ${GOOGLE_API_KEY}
HuggingFace

Calls the HuggingFace Inference API. Works with both the free Inference API and dedicated endpoints. Requires pip install "nexrag[huggingface]".

# nexrag.yaml — HuggingFace Inference API
ingestion:
  embedder:
    provider: huggingface
    model: sentence-transformers/all-MiniLM-L6-v2
    api_key: ${HF_API_KEY}
    # base_url: https://your-dedicated-endpoint.hf.space  # dedicated endpoint override
    # max_retries: 3   # retries on HTTP 429/5xx with exponential backoff (default: 3)
Ollama

Calls a locally-running Ollama server. No API key required. Requires pip install "nexrag[ollama]" and Ollama running locally.

# nexrag.yaml
ingestion:
  embedder:
    provider: ollama
    model: nomic-embed-text   # or mxbai-embed-large, all-minilm, etc.
    # base_url: http://localhost:11434  # default Ollama address
    # max_concurrent_requests: 10  # async concurrency limit (default: 10)
Custom embedder

Implement BaseEmbedder for any model not covered above — local sentence-transformers, Cohere embed, Voyage AI, etc.

from nexrag.core.interfaces.embedder import BaseEmbedder

class CohereEmbedder(BaseEmbedder):
    def __init__(self, api_key: str, model: str = "embed-english-v3.0") -> None:
        import cohere
        self._client = cohere.Client(api_key)
        self._model = model

    @property
    def model_name(self) -> str: return self._model

    @property
    def dimensions(self) -> int: return 1024

    def embed(self, texts: list[str]) -> list[list[float]]:
        resp = self._client.embed(texts=texts, model=self._model, input_type="search_document")
        return resp.embeddings

    def embed_query(self, text: str) -> list[float]:
        resp = self._client.embed(texts=[text], model=self._model, input_type="search_query")
        return resp.embeddings[0]
# nexrag.yaml
ingestion:
  embedder:
    provider: custom
    class: myapp.embedders.CohereEmbedder
    params:
      api_key: ${COHERE_API_KEY}
      model:   embed-english-v3.0
Components

Vector DB

The vector DB stores embedded chunks during ingestion and retrieves similar chunks at query time. NexRAG abstracts it behind BaseVectorDB — the ingestion and query pipelines never call a database directly.

ChromaDB

The default and only built-in vector DB. Supports three modes: persistent (data saved to disk), memory (ephemeral, no disk I/O), and server (remote ChromaDB instance). Requires pip install "nexrag[chromadb]".

# nexrag.yaml — persistent (recommended for production)
vector_db:
  provider: chroma
  default_collection: documents
  on_conflict: overwrite
  upsert_batch_size: 500
  max_retries: 3
  retry_delay: 1.0
  collections:
    documents:
      mode: persistent
      path: ./.nexrag/chroma
# nexrag.yaml — memory (testing / CI)
vector_db:
  provider: chroma
  default_collection: test
  collections:
    test:
      mode: memory
# nexrag.yaml — server (remote ChromaDB)
vector_db:
  provider: chroma
  default_collection: documents
  collections:
    documents:
      mode: server
      # host: localhost   # ChromaDB server host
      # port: 8000        # ChromaDB server port

Each collection with distinct mode, path, host, or port settings gets its own ChromaDB client. This enables mixing persistent and in-memory collections in the same pipeline, or pointing different collections at separate disk paths.

Pinecone

Managed serverless vector store. Each NexRAG collection maps to a Pinecone namespace inside a single index; the index is created lazily on first upsert from the embedder's dimension. Pinecone settings go under params. Requires pip install "nexrag[pinecone]".

# nexrag.yaml
ingestion:
  vector_db:
    provider: pinecone
    default_collection: documents
    collections: { documents: {} }   # collection names = Pinecone namespaces
    params:
      index_name: nexrag
      api_key: ${PINECONE_API_KEY}
      cloud: aws                  # cloud / region / metric optional
      region: us-east-1

Pinecone caps metadata at ~40 KB per vector and chunk text is stored in metadata — prefer smaller chunks with Pinecone.

Custom vector DB

Implement BaseVectorDB to connect to any vector store — Pinecone, Weaviate, Qdrant, pgvector, etc. The interface has four methods: upsert(), query(), delete(), list_collections().

from nexrag.core.interfaces.vector_db import BaseVectorDB

class PineconeDB(BaseVectorDB):
    def __init__(self, api_key: str, index_name: str) -> None: ...
    def upsert(self, chunks, collection): ...
    def query(self, vector, top_k, collection, metadata_filter): ...
    def delete(self, ids, collection): ...
    def list_collections(self): ...
# nexrag.yaml
vector_db:
  provider: custom
  class: myapp.db.PineconeDB
  params:
    api_key:    ${PINECONE_API_KEY}
    index_name: nexrag-prod
Components

Retrievers

A retriever selects the most relevant chunks for a given query. NexRAG ships three retrieval strategies: dense (vector similarity), sparse (keyword overlap), and hybrid (fusion of both). The sparse retriever interface is open — BM25 is the built-in implementation, but any sparse algorithm can be plugged in.

Dense retrieval

Uses cosine similarity between the query vector and stored chunk vectors. Fast and accurate for semantic queries — finds conceptually similar text even if the exact words differ. No additional dependencies required.

# nexrag.yaml
query:
  retriever:
    provider: dense         # default
    top_k: 5               # chunks returned to the LLM
    score_threshold: 0.0   # drop chunks below this similarity score
OptionDefaultDescription
top_k5Number of chunks to return
score_threshold0.0Minimum cosine similarity (0–1) to include a chunk
Sparse retrieval

Sparse retrieval ranks chunks by keyword overlap rather than semantic similarity. It excels at exact-term queries, product codes, proper nouns, and cases where dense retrieval misses literal matches.

Sparse is a category, not a single algorithm. NexRAG ships BM25 as the built-in sparse implementation, but the BaseRetriever interface supports any sparse method — TF-IDF, BM25+, SPLADE, or any custom scoring function. See Custom retriever to plug in a different sparse algorithm.

BM25 (built-in)

Okapi BM25 keyword scoring. Tokenizes the corpus at query time and ranks by term frequency adjusted for document length. Requires pip install "nexrag[bm25]".

# nexrag.yaml
query:
  retriever:
    provider: bm25
    top_k: 5

The BM25 index is built once per collection and cached in memory. It is automatically invalidated after ingest() so new documents are always reflected on the next query. metadata_filter is fully supported — filtering is applied post-scoring.

Hybrid retrieval

Fuses dense and sparse retrieval scores using a weighted sum. The alpha parameter controls the balance: alpha=1.0 is pure dense, alpha=0.0 is pure sparse. The default alpha=0.7 gives 70% weight to dense and 30% to sparse.

Hybrid is not tied to BM25 — the sparse.provider key accepts bm25 or custom, so you can plug in any sparse implementation via class:.

# nexrag.yaml — hybrid with BM25 sparse
query:
  retriever:
    provider: hybrid
    top_k: 10
    alpha: 0.7          # 0=pure sparse, 1=pure dense
    sparse_top_k: 50   # sparse candidate pool (use a larger set than top_k)
    sparse:
      provider: bm25    # bm25 | custom
# nexrag.yaml — hybrid with a custom sparse retriever
query:
  retriever:
    provider: hybrid
    top_k: 10
    alpha: 0.6
    sparse:
      provider: custom
      class: myapp.retrievers.TFIDFRetriever
OptionDefaultDescription
alpha0.7Dense weight (1 − alpha is the sparse weight)
sparse_top_kNone (= top_k)Candidate pool fetched from sparse retriever before fusion
sparse.providerbm25bm25 or custom
Custom retriever

Implement BaseRetriever to replace the entire retrieval strategy — or to supply a custom sparse retriever used by the hybrid fusion.

from nexrag.core.interfaces.retriever import BaseRetriever
from nexrag.core.models.chunk import ScoredChunk

class TFIDFRetriever(BaseRetriever):
    def retrieve(self, query: str, top_k: int, collection: str,
                  metadata_filter: dict | None = None) -> list[ScoredChunk]:
        # your TF-IDF implementation here
        ...
# nexrag.yaml — custom retriever as the primary strategy
query:
  retriever:
    provider: custom
    class: myapp.retrievers.TFIDFRetriever
Components

Rerankers

An optional post-retrieval stage that re-scores a larger candidate set and returns the top-N most relevant chunks. The pattern: retrieve a wide set (top_k=50) for high recall, then rerank to a narrow set (top_n=5) for high precision — only the top-N reach the LLM.

Streaming calls include reranker latency in RunMetrics. When a reranker is configured, its latency appears as stage_latencies["reranker"] in the RunMetrics yielded at the end of stream_query() and astream_query().

Cohere

Uses the Cohere Rerank API. State-of-the-art re-ranking quality with minimal latency overhead. Requires pip install "nexrag[cohere]" and a Cohere API key.

# nexrag.yaml
query:
  retriever:
    provider: dense
    top_k: 50
  reranker:
    provider: cohere
    model: rerank-english-v3.0   # or rerank-multilingual-v3.0
    api_key: ${COHERE_API_KEY}
    top_n: 5
CrossEncoder

Local, offline reranker using a cross-encoder model from sentence-transformers. No API key or network call — runs on CPU or GPU. Slower than Cohere for large candidate sets but fully private. Requires pip install "nexrag[cross-encoder]".

# nexrag.yaml
query:
  retriever:
    provider: dense
    top_k: 20
  reranker:
    provider: cross_encoder
    model: cross-encoder/ms-marco-MiniLM-L-6-v2
    top_n: 5
    # device: cuda   # cpu (default) | cuda | mps
Custom reranker

Implement BaseReranker to use any reranking model — Voyage Rerank, Jina Reranker, a fine-tuned cross-encoder, etc.

from nexrag.core.interfaces.reranker import BaseReranker
from nexrag.core.models.chunk import ScoredChunk

class VoyageReranker(BaseReranker):
    def __init__(self, api_key: str) -> None:
        import voyageai
        self._client = voyageai.Client(api_key=api_key)

    def rerank(self, query: str, chunks: list[ScoredChunk], top_n: int) -> list[ScoredChunk]:
        texts = [c.content for c in chunks]
        result = self._client.rerank(query, texts, model="rerank-2", top_k=top_n)
        return [chunks[r.index] for r in result.results]
# nexrag.yaml
query:
  reranker:
    provider: custom
    class: myapp.rerankers.VoyageReranker
    params:
      api_key: ${VOYAGE_API_KEY}
Components

LLMs

The LLM receives the formatted prompt (query + retrieved context) and generates the final answer. NexRAG calls the LLM as the last stage of the query pipeline and wraps the response in PipelineResult.

OpenAI

Uses the OpenAI Chat Completions API. Supports streaming natively. Requires pip install "nexrag[openai]".

# nexrag.yaml
query:
  llm:
    provider: openai
    model: gpt-4o          # gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-3.5-turbo, ...
    api_key: ${OPENAI_API_KEY}
    temperature: 0.2
    max_tokens: 1024
    timeout: 30           # seconds before request is aborted
    max_retries: 2        # automatic retries on transient errors
Anthropic

Uses the Anthropic Messages API (Claude models). Supports streaming natively. Requires pip install "nexrag[anthropic]".

# nexrag.yaml
query:
  llm:
    provider: anthropic
    model: claude-opus-4-8   # claude-sonnet-4-6, claude-haiku-4-5-20251001, ...
    api_key: ${ANTHROPIC_API_KEY}
    temperature: 0.2
    max_tokens: 1024
    timeout: 30
    max_retries: 2
Ollama

Calls a locally-running Ollama server. No API key required — data stays on your machine. Requires pip install "nexrag[ollama]" and Ollama running locally.

# nexrag.yaml
query:
  llm:
    provider: ollama
    model: llama3.1        # any model pulled via `ollama pull`
    # base_url: http://localhost:11434  # default Ollama address
    temperature: 0.2
    max_tokens: 1024
    # max_retries: 2    # async retry attempts on transient failures (default: 2)
Gemini

Google Gemini via the unified google-genai SDK — supports generate, stream, and native async. Requires pip install "nexrag[gemini]". Reads GOOGLE_API_KEY / GEMINI_API_KEY if api_key is omitted.

# nexrag.yaml
query:
  llm:
    provider: gemini
    model: gemini-2.5-flash     # or gemini-2.5-pro
    api_key: ${GOOGLE_API_KEY}
    temperature: 0.2
    max_tokens: 1024
Custom LLM

Implement BaseLLM to call any model API not covered above — Mistral, Groq, a local vLLM server, etc. generate() returns (text, TokenUsage | None); implement stream() for stream_query() support.

Gemini is now built-in — use provider: gemini (LLM and embeddings) instead of a custom adapter. Install nexrag[gemini].

from nexrag.core.interfaces.llm import BaseLLM
from nexrag.core.models.metrics import TokenUsage
from collections.abc import Iterator

class GroqLLM(BaseLLM):
    def __init__(self, api_key: str, model: str = "llama-3.3-70b-versatile") -> None:
        from groq import Groq
        self._client = Groq(api_key=api_key)
        self._model = model   # BaseLLM.model_name reads self._model

    def generate(self, prompt: str) -> tuple[str, TokenUsage | None]:
        r = self._client.chat.completions.create(
            model=self._model, messages=[{"role": "user", "content": prompt}])
        return r.choices[0].message.content, None

    def stream(self, prompt: str) -> Iterator[str]:
        stream = self._client.chat.completions.create(
            model=self._model, messages=[{"role": "user", "content": prompt}], stream=True)
        for chunk in stream:
            if chunk.choices[0].delta.content:
                yield chunk.choices[0].delta.content
# nexrag.yaml
query:
  llm:
    provider: custom
    class: myapp.llms.GroqLLM
    params:
      api_key: ${GROQ_API_KEY}
      model:   llama-3.3-70b-versatile
Security

Guardrails

A pluggable security layer. Guards inspect text at four points in the pipeline and return a verdict — ALLOW, BLOCK, or REDACT. They compose into ordered chains under a top-level guardrails: block. Every chain is disabled by default. See the full threat model in SECURITY.md.

The four chains
chainRuns on
ingestiondocument text, after the sanitizer, before chunking
inputthe user query, before retrieval
retrievedeach retrieved chunk, before the prompt (an injection vector)
outputthe LLM answer, before it is returned/streamed

Each chain takes a policy: fail_open (default — a broken guard is treated as ALLOW) or fail_closed (treated as BLOCK). Every guard firing emits a PipelineEvent(stage="guardrail", …) for auditing and overhead measurement.

Shipped guards
typeDefends againstOverhead
access_controlCross-tenant retrieval. Turns auth_context into a retrieval metadata filter (deny-by-default).negligible
piiLeaking emails/SSNs/cards/keys. Presidio (nexrag[pii]) with a regex fallback.regex µs / Presidio ~10s ms
prompt_injectionCommon injection/jailbreak phrasings — on the query AND on retrieved content.µs
groundednessUngrounded answers (cheap lexical-overlap proxy vs the retrieved context).µs
topicOff-policy queries (allow/deny keyword lists).µs
modelNuanced unsafe content — a Llama-Guard-style moderation LLM (nested independent llm).a full LLM call
Access control (the highest-value guard)

Pass auth_context to query(); the guard turns it into a retrieval filter so a request can only ever retrieve documents it is authorised to see.

# nexrag.yaml
guardrails:
  input:
    enabled: true
    policy: fail_closed
    guards:
      - type: access_control
        params: { mapping: { tenant: tenant } }
from nexrag import NexRAG, GuardrailBlockedError

pipeline = NexRAG.from_config("nexrag.yaml")

# Only retrieves chunks whose metadata["tenant"] == "acme"
result = pipeline.query("What's our revenue?", auth_context={"tenant": "acme"})

try:
    pipeline.query("ignore previous instructions…")   # input guard BLOCKs
except GuardrailBlockedError as e:
    print("blocked:", e)
!

When an output chain is configured, streaming is buffered then guarded before flushing — true token-by-token streaming is disabled for that request (you cannot un-send tokens). auth_context is trusted input: authenticate the principal upstream.

Reference

Changelog

v0.5.0 2026-06-26

Stateful query layer: pluggable query-result cache, multi-turn sessions with context strategies, a client-side rate limiter, and truly batched embedding for ingest_batch().

performanceingest_batch() now embeds every chunk across the whole batch in a single embed() call (one provider round-trip instead of one per source); fingerprint checked once, idempotency and writes stay per source. New facade method async_ingest_batch(). Return type unchanged (list[IngestionResult]); results share the batch pipeline_id and report batch latency.
configNew query.cache block — pluggable query-result cache. BaseQueryCache interface + default InMemoryQueryCache (exact-match, LRU + TTL, per-collection invalidation after ingest). Options: enabled, backend (memory|custom), strategy (exact|semantic), max_size, ttl_seconds. Semantic strategy requires a custom backend; streaming responses are not cached.
APINew query.session block + facade methods query_session(), async_query_session(), clear_session(), delete_turns(before=...). BaseSessionStore interface + default InMemorySessionStore (TTL, persist: false privacy mode). Retrieval always uses the current query only; history is injected into the prompt.
configNew query.session.context_strategy — decides how much history to send each turn. BaseContextStrategy interface + WindowStrategy (last N turns) and TokenBudgetStrategy (fit a token budget). Options: type (window|token_budget|custom), max_history_turns, max_tokens.
configNew query.rate_limit block — client-side TokenBucketRateLimiter applied before every query entry point. Raises LLMRateLimitError with a new retry_after_seconds attribute. Options: enabled, requests_per_minute, burst.
breakingBasePromptBuilder.build() gained an optional history parameter (build(query, chunks, history=None)); DefaultPromptBuilder renders a "Conversation so far" block when history is present. Custom prompt builders must accept history=None.
observabilityWhen an output guard chain is enabled, from_config() logs a startup warning that stream_query() / astream_query() buffer the full response (output guards cannot edit an already-sent stream). Behaviour unchanged — the warning makes the trade-off explicit.
v0.4.0 2026-06-15

Major release: Gemini (LLM + embeddings) and Pinecone providers, a production chunking suite with nested component configs, and a new pluggable guardrails security layer.

adapterGeminiLLM (llm.provider: gemini) and GeminiEmbedder (embedder.provider: gemini) via the unified google-genai SDK. Install nexrag[gemini]. Default models gemini-2.5-flash / gemini-embedding-001.
adapterPineconeVectorDB (vector_db.provider: pinecone) — each NexRAG collection is a Pinecone namespace in one serverless index, created lazily on first upsert. Install nexrag[pinecone].
chunkersNew strategies: token (tiktoken), sentence, sentence_window, markdown (header path), code (tree-sitter), semantic, proposition. fixed is now wired. New extras nexrag[tiktoken], nexrag[code].
configChunkerConfig gains nested embedder / llm sub-configs, resolved independently of the pipeline's main models. semantic requires chunker.embedder; proposition requires chunker.llm.
securityNew guardrails pillar: a BaseGuard (verdict ALLOW · BLOCK · REDACT) composed into ingestion / input / retrieved / output chains under a top-level guardrails: block, each with a policy: fail_open|fail_closed. Shipped guards: pii (nexrag[pii]), access_control, prompt_injection, groundedness, topic, model. See SECURITY.md.
APIFacade methods query / async_query / stream_query / astream_query gain an optional auth_context param (per-request principal for the access-control guard). New GuardrailError / GuardrailBlockedError, exported from the top-level package.
observabilityEvery guard firing emits a PipelineEvent(stage="guardrail", metadata={guard, verdict, latency_ms}). When an output chain is configured, streaming buffers then guards before flushing.
observabilityFull OpenTelemetry observability — metrics, traces, and logs. Prometheus pull (/metrics scrape endpoint) and OTLP push (gRPC/HTTP to any OTel Collector or Grafana Alloy). Always-on metrics cover every pipeline stage: latency, token counts, retrieval scores, cost, embedding, and ingestion stats. Install nexrag[observability]. Configure under observability.exporters.
observabilityLLM-as-judge evaluators — faithfulness, answer relevance, completeness, coherence, and context diversity. Runs off the response path (sampled, fire-and-forget). Each evaluator accepts an independent llm / embedder sub-config. Results emitted as nexrag.eval.metric_value OTel histogram. Opt-in under observability.evaluations.
APINexRAG facade gains evaluation_runner attribute — access the EvaluationRunner / NoOpEvaluationRunner directly for custom dispatch or testing. New public module nexrag.evaluators exports all five concrete evaluator classes.
v0.3.3 2026-06-13

Correctness and safety hardening across ingestion, retrieval, and observability.

pipelineComposite chunk row IDs — vector-DB rows are keyed by Chunk.row_id = sha256(parent_doc_id:content_hash), so two documents with identical text keep separate rows. delete() callers must use chunk.row_id.
pipelineon_conflict (skip/overwrite) is now applied independently per metadata["source"]; one source's lookup failure no longer wipes another's dedup state.
fixedHybridRetriever now applies metadata_filter to BOTH the dense and sparse paths — closes a cross-tenant leak where a sparse-only match could bypass the filter.
observabilityEvery stage now emits exactly one PipelineEvent(status="failed", …) before raising, plus a pipeline-level failed event (sync and async).
adapterOpenAILLM.async_stream() fixed to use create(..., stream=True); live token streaming via astream_query() with mode: async + OpenAI now works.
configloader.type restricted to wired types (auto | pdf | txt | text | custom); collection-name validation aligned to ChromaDB ≥1.5 rules (3–512 chars, dots allowed).
v0.3.2 2026-06-12

Robustness and depth: retry logic for HuggingFace embedder, native async for Ollama via httpx, concurrent Ollama embeddings, query input validation, chunk provenance fields in Source, FixedChunker, and config-time collection name validation.

chunkersFixedChunker — sliding-window fixed-size chunking with configurable chunk_size, chunk_overlap, and min_chunk_size. Use strategy: fixed in YAML.
breakingAutoLoader now raises LoaderError (was NotImplementedError) for unsupported formats. Callers catching NotImplementedError must be updated.
interfaceBaseVectorDB gains get_ids_by_metadata(filters, collection_name) and async_get_ids_by_metadata(). Custom adapter implementations must add get_ids_by_metadata().
pipelineIdempotency check (on_conflict: skip) now uses metadata fetch via get_ids_by_metadata() instead of a top-k similarity query — exact deduplication, lower cost.
observabilitystream_query() and astream_query() now always yield RunMetrics as the final item, even when the LLM raises an exception mid-stream.
APISource gains three optional fields: chunk_index, total_chunks, parent_doc_id. Populated from the underlying Chunk; defaults to None. Enables callers to reconstruct surrounding context.
configQueryConfig gains max_query_length: int = 8000. Queries exceeding this character count raise PipelineError(stage="validation") before any API call. Set to 0 to disable.
configVectorDBConfig validates all collection names (ChromaDB rules: 1–63 chars, alphanumeric/hyphens/underscores) at from_config() time. Invalid names raise ConfigError immediately.
adapterHuggingFaceEmbedder gains max_retries: int = 3 with exponential backoff on HTTP 429/500/502/503/504. Auth and model-not-found errors still fail immediately.
adapterOllamaEmbedder.async_embed() fires all per-text requests concurrently, bounded by max_concurrent_requests: int = 10. Expected speedup: ~10× for large batches vs sequential.
adapterOllamaLLM.async_generate() and async_stream() now use httpx.AsyncClient for true non-blocking I/O. New max_retries: int = 2 applies to the async path. httpx>=0.27 added to nexrag[ollama].
v0.3.1 2026-06-10

Bug fixes and performance improvements: BM25 metadata filtering, per-collection BM25 index caching, multi-collection ChromaDB path isolation, extras bundle restructuring, and streaming metrics via RunMetrics.

fixedBM25Retriever now applies metadata_filter post-scoring — previously silently ignored. Semantics match DenseRetriever: scalar equality on metadata keys. Fixes #22.
performanceBM25Retriever caches the BM25 index per collection. get_all() is called once instead of on every query. Cache is invalidated automatically after ingest() / ingest_documents() / async_ingest(). Optional cache_ttl constructor arg for time-based expiry. Fixes #23.
fixedMulti-collection ChromaDB: per-collection mode, path, host, and port are now applied. Each unique configuration gets its own ChromaDB client. Fixes #24.
configNew install extras: nexrag[all-sparse] (rank-bm25), nexrag[all-rerankers] (Cohere + sentence-transformers), nexrag[all-retrieval] (both). nexrag[all] now includes all retrieval deps. Fixes #21.
observabilitystream_query() and astream_query() now yield RunMetrics as the final stream item. All pre-LLM stages are timed identically to query(). Return types updated to Iterator[str | RunMetrics] / AsyncIterator[str | RunMetrics]. RunMetrics exported from top-level package. Fixes #25.
v0.3.0 2026-05-31

Retrieval expansion: hybrid BM25+dense retrieval, optional reranking stage, multi-collection routing, RunMetrics observability, and PDF metadata extraction.

retrievalBM25Retriever — corpus-level keyword retrieval. Requires nexrag[bm25]. Metadata filtering and index caching shipped in v0.3.1.
retrievalHybridRetriever — fused dense+BM25 with configurable alpha. Typical pattern: retrieve top_k=50, rerank to top_n=5.
rerankingOptional reranker stage between retriever and prompt builder. CohereReranker (nexrag[cohere]) and CrossEncoderReranker (nexrag[cross-encoder]).
observabilityRunMetrics — per-stage latency, token usage, chunks written/retrieved. Attached to PipelineResult.metrics and IngestionResult.metrics. Not on streaming calls (#25).
routingMulti-collection routing — pass collection="name" to any ingest*() or query() call. IngestionResult.collection_used tracks which was used. Per-collection path/mode isolation shipped in v0.3.1.
loadersPDFLoader extracts up to 8 metadata fields (title, author, subject, keywords, creator, producer, creation_date, modification_date). Configurable via loader.include_metadata and loader.metadata_fields.
configVectorDBConfig gains upsert_batch_size, query_batch_size, max_retries, retry_delay for ChromaDB connection and batch tuning.
configChunker strategy reduced to recursive | custom. Values fixed, sentence, paragraph removed — they were never implemented.
breakingLoaders are now converter-only: PDFLoader.load() accepts bytes; RawTextLoader.load() accepts str. Read the file yourself first (e.g. Path("doc.pdf").read_bytes()) and pass the content to pipeline.ingest() — file paths are no longer accepted.
fixedSix ChromaDBAdapter bugs resolved: None metadata crash, missing struct fields on round-trip, filter operator always $eq, missing batch upsert, no connection retry, unimplemented list_collections().
v0.2.0 2026-05-28

First public release. Full naive RAG pipeline — ingest a document, query it, get a structured result. Includes async pipeline mode and token-by-token streaming.

pipelineIngestion: Loader → Sanitizer → Chunker → Embedder → VectorDB with hash-based idempotent deduplication
pipelineQuery: Embed → Retrieve → Prompt → LLM with structured PipelineResult
asyncmode: asyncAsyncIngestionPipeline + AsyncQueryPipeline with native async/await and parallel batch embedding
streamingstream_query() (sync) and astream_query() (async) for token-by-token LLM responses; async_query() / async_ingest() for non-blocking calls in FastAPI and other async frameworks
configYAML config with ${ENV_VAR} substitution — secrets never hardcoded. Root-level mode: key selects pipeline mode.
interfaces9 abstract base classes — all stages swappable via class: in YAML
embeddersOpenAI, Ollama, HuggingFace (Inference API + Dedicated Endpoints)
llmsOpenAI, Ollama, Anthropic — all with sync and async streaming, exponential backoff
vector dbChromaDB — memory, persistent, and server modes
loadersPDF (pypdf), plain text, auto-detect by extension
chunkersRecursive (separator-aware) with configurable size, overlap, and min-size
ingestioningest_batch() for sequential multi-source ingestion
observabilityStructured JSON / text console observer with configurable log level
v0.1.0 internal pre-release

Internal pre-release. Not publicly available on PyPI. Foundation work — core interfaces, config schema, and initial pipeline wiring.

// no public changelog for this version