Chonk
Chonk is the retrieval layer of the modern enterprise AI stack.
The stack works like this: an LLM planner decomposes a user query into atomic sub-queries, each scoped to a specific fact or relationship. Chonk answers those sub-queries — pulling cross-domain evidence from heterogeneous document corpora. Structured output constraints (SRR/DSL) gate generation quality at each step, ensuring the planner acts only on verified evidence.
The hard problem in this stack is vocabulary collision. A planner's sub-query names an entity — a company, a software package, a product — but the same entity appears across document types with incompatible terminology. "Apple Inc." in an SEC 10-K filing is "Apple" in a patent assignment, "AAPL" in a trading notice, and "Apple Computer" in a Federal Register ruling from 2003. Embedding-based retrieval without domain disambiguation returns the wrong document type, or the right type from the wrong era, or nothing.
Chonk solves this through two mechanisms working together. Semantic boundary chunking ensures every retrieved passage is a complete unit of meaning — no partial paragraphs, no half-tables, no context bleed from adjacent sections. Graph-guided retrieval with entity vocabulary assembles the right evidence from the right domain by disambiguating entities before the completeness gate runs.
For planners connecting to Chonk: Chonk ships an MCP server
(mcp_chonk_server.py) that speaks the
Model Context Protocol. Any MCP-compatible planner
— Claude, Cursor, VS Code Copilot — connects to Chonk as its retrieval tool. Run it
locally with CHONK_TRANSPORT=stdio for individual use, or deploy it centrally with
CHONK_TRANSPORT=http so every user in your organisation can point their planner at a
shared index by URL — no local Python environment, no file access needed on the client side.
Two capabilities distinguish it from simpler pipelines:
Semantic boundary chunking. Naive pipelines handle split boundaries with overlap — repeating the tail of each chunk at the head of the next — which reduces missed splits at the cost of redundant embeddings, index bloat, and duplicate retrievals that must be deduplicated downstream. Chonk avoids the bad split in the first place. Chunks flush at heading-level transitions, tables at row boundaries, lists at item boundaries, prose at sentence boundaries. Plain-text documents without headings can have headers promoted automatically from questions and short phrases before chunking begins.
Because every chunk corresponds to a complete unit of meaning — never a partial paragraph, never a half-table — what comes back is exactly the passage that answers the query, with no leading or trailing noise from an adjacent context window.
Graph-guided retrieval with completeness gates. EnhancedSearch supports three
retrieval modes: vector-first (seed → structural → entity → cluster → community
expansion), graph-first (RelationshipIndex traversal with vector reranking), and global
(community summary search). After the top-k cohort is assembled, a completeness gate
checks whether query entities are present. Missing ones trigger further expansion until
they appear or the budget is exhausted. Completeness, relevance, priority, and marginal
coverage are combined into a composite reranking score. The result: answer context that
is both on-topic and non-redundant.
The completeness gate is only as good as the entity vocabulary it searches for. On high-quality vocabularies built from schema identifiers, structured files, or API endpoints, it reliably finds the right chunks. On raw spaCy output against domain text it will chase false positives and degrade both quality and latency. The custom vocab layer exists specifically to make the gate production-viable — see NER / vocabulary layer.
The problem with naive chunking
Most RAG pipelines embed raw chunk content and nothing else. This works when every chunk contains enough distinctive vocabulary to describe itself. That is a narrow special case.
When a planner issues a sub-query — "what is Apple's disclosed cybersecurity risk posture?" — the retrieval system faces vocabulary collision across document types simultaneously. The same company name appears in SEC filings (10-K Item 1C), CVE records (vendor string "Apple Inc."), Federal Register notices (formal legal name), and patent assignments (assignee field). The embedding space treats these as related but distinguishable. Naive retrieval returns whichever representation is most common in the corpus — which is rarely the right one for the sub-query's document-type intent.
Within a single document type, the problem compounds. Almost every document type you want to retrieve from has repeating structure:
- Technical documentation — every function reference has
Parameters,Returns,Raisessections with the same words across every function in every library - Code — every
__init__, test setup, error handler, and config block shares vocabulary across the entire codebase - Contracts — indemnification, limitation of liability, and governing law clauses are assembled from a shared clause library; the boilerplate is identical across every agreement
- Regulatory filings — every 10-K has the same Items in the same order; every company's Controls and Procedures section (Item 9A) is near-verbatim identical
- Clinical protocols — ECOG performance criteria, RECIST endpoints, and organ function thresholds appear word-for-word across hundreds of trials
- Academic papers — Abstract, Introduction, Methods, Results, Discussion; the heading hierarchy is fixed by convention
When sections share vocabulary, the embedding vectors are indistinguishable. Retrieval returns the wrong chunk, from the wrong document, for the wrong reason. When document types share entity names with incompatible terminology, the cross-domain sub-query fails entirely.
There are two places to inject disambiguation context. The document name and section path are known at chunk time, so they can be prepended to the text that gets embedded:
[techcorp_msa_2024 > Limitation of Liability]
IN NO EVENT SHALL EITHER PARTY'S AGGREGATE LIABILITY…
Or they can be injected at answer generation time — included in the prompt context alongside the retrieved chunk rather than baked into the embedding itself.
Which approach is better depends on the embedding model. Models that were trained on
structured prefixes can use them as a disambiguation signal and produce meaningfully
different vectors. Models that weren't may treat the prefix as noise, diluting the
content signal rather than sharpening it. Chonk supports both strategies and lets you
choose: enrich_chunks() handles embedding-time injection, and AnswerGenerator /
PromptBuilder handle generation-time injection.
What Chonk does
- Fetches documents from local disk, HTTP/HTTPS, S3, FTP, SFTP, or any custom
source (SharePoint, Confluence, Google Drive, Notion). Built-in
WebCrawler,DirectoryCrawler, andGitHubCrawlerdiscover documents recursively from a root URI; custom crawlers plug in via theCrawlerprotocol. - Extracts text from PDF, DOCX, XLSX, PPTX, HTML, Markdown, plain text, SEC EDGAR inline XBRL, Python, TypeScript/JavaScript, Java, or any custom format
- Chunks into semantically coherent pieces — never breaking mid-paragraph, keeping tables and lists atomic, tracking the full heading hierarchy
- Enriches each chunk: sets
embedding_contentto"[doc_name > section_path]\n\n<content>"before it reaches your embedding model. This is the disambiguation signal the planner depends on — entity vocabulary from the NER layer tags each chunk's domain, and the breadcrumb carries the document type context that makes cross-domain sub-queries land in the right namespace.
The original content field is never modified. embedding_content is what you embed. Everything downstream — your embedding model, vector store, retrieval logic — is unchanged.
Installation
The distribution is named chonk-rag on PyPI (chonk was already taken); the
import name is still chonk.
Core (no optional dependencies):
pip install chonk-rag
With specific extras:
pip install "chonk-rag[http]" # HTTP/HTTPS transport
pip install "chonk-rag[s3]" # Amazon S3 transport
pip install "chonk-rag[sftp]" # SFTP transport
pip install "chonk-rag[pdf]" # PDF extraction
pip install "chonk-rag[docx]" # DOCX extraction
pip install "chonk-rag[xlsx]" # XLSX extraction
pip install "chonk-rag[pptx]" # PPTX extraction
pip install "chonk-rag[yaml]" # YAML file extraction
pip install "chonk-rag[odf]" # ODF/ODS/ODT extraction
pip install "chonk-rag[storage]" # DuckDB vector store
pip install "chonk-rag[pgvector]" # PostgreSQL + pgvector vector store
pip install "chonk-rag[cluster]" # Entity clustering (scikit-learn)
pip install "chonk-rag[leiden]" # Leiden community detection (igraph + leidenalg)
pip install "chonk-rag[parquet]" # Parquet/Arrow/Feather structured file support
pip install "chonk-rag[code]" # Python/TS/JS/Java code chunking (stdlib only, no extra packages)
pip install "chonk-rag[gmail]" # Gmail transport (google-api-python-client, google-auth-oauthlib)
pip install "chonk-rag[full]" # Everything
Quick start
from chonk import DocumentLoader
loader = DocumentLoader() # enrich_context=True is the default
# Local file, URL, or raw bytes — same interface
chunks = loader.load("/path/to/report.pdf")
chunks = loader.load("https://example.com/docs/api.html")
chunks = loader.load_bytes(pdf_bytes, name="report", doc_type="pdf")
chunks = loader.load_text("Paragraph one.\n\nParagraph two.", name="notes")
for chunk in chunks:
# chunk.content — original text, unchanged (for display, storage)
# chunk.embedding_content — "[doc > section]\n\n..." (embed this)
# chunk.section — ["Item 1A", "Risk Factors"] (list of heading levels)
# chunk.document_name — "aapl_10k_2025" (metadata, not in content)
embed(chunk.embedding_content)
The section path and document name appear in both chunk.section / chunk.document_name
(as metadata, for filtering and display) and in embedding_content (as text, for
disambiguation during vector search). These are separate concerns. The metadata is
always present; embedding_content is what makes retrieval accurate.
Pipeline
URI
│
▼
Transport (Local / HTTP / S3 / FTP / SFTP / custom)
│ fetch(uri) → FetchResult(data: bytes, detected_mime, source_path)
▼
Extractor (PDF / DOCX / XLSX / PPTX / HTML / Markdown / EDGAR / custom)
│ extract(data) → str
▼
chunk_document(name, content, min_chunk_size, max_chunk_size)
│ → list[DocumentChunk] (content, section, document_name, breadcrumb,
│ embedding_content already set when include_breadcrumb=True)
▼
enrich_chunks(chunks) [optional; re-enriches or enriches chunks produced without a loader]
│ → list[DocumentChunk]
▼
Your embedding model / vector store
chunk_document sets embedding_content directly when include_breadcrumb=True
(the default). DocumentLoader calls chunk_document with include_breadcrumb=True
when enrich_context=True, then passes the result through enrich_chunks for the
final enrichment step. Calling enrich_chunks on already-enriched chunks is
idempotent — it replaces embedding_content using the stored breadcrumb field.
API reference
DocumentChunk fields
| Field | Type | Description |
|---|---|---|
document_name |
str |
Source document name |
content |
str |
Chunk text — original, never modified |
section |
list[str] |
Ordered list of enclosing heading labels (["Methods", "Table 1"]) |
chunk_index |
int |
Zero-based position within the document |
source_offset |
int | None |
Character offset of chunk start in source text |
source_length |
int | None |
Character length of chunk content |
embedding_content |
str | None |
Set by chunk_document / enrich_chunks() — embed this, not content |
chunk_type |
str |
"document", "db_table", "db_column", "api_endpoint", "api_graphql_query", "api_graphql_mutation", "api_graphql_type" |
breadcrumb |
str | None |
Pre-formatted breadcrumb string ("[doc > section]") used by enrich_chunk |
paragraph_continuation |
bool |
True when this chunk is a continuation of a split paragraph |
source |
str |
Origin class: "document", "schema", "api", or "community" |
source_detail |
dict | None |
Format-specific navigation metadata — see Source detail |
rendered_source |
str | None |
Per-record Markdown set by domain renderers (CWE, CVE, ATT&CK, etc.) for visualization |
chunk_document
chunk_document(
name: str,
content: str,
min_chunk_size: int,
max_chunk_size: int,
overflow_margin: float = 0.15,
include_breadcrumb: bool = True,
include_doc_name: bool = True,
promote_headings: bool = False,
promote_questions: bool = True,
promote_short_phrases: bool = True,
max_header_words: int = 6,
max_header_chars: int = 80,
structural_levels: list[tuple[str, int]] | None = None,
toc_proximity: int = 300,
max_breadcrumb_chars: int | None = None,
overlap_chars: int = 0,
) -> list[DocumentChunk]
Splits a document into semantically coherent chunks bounded by min_chunk_size
and max_chunk_size. Respects paragraph boundaries, keeps tables and lists atomic,
tracks heading hierarchy in section, and splits large blocks with continuation
markers ([TABLE:start] / [TABLE:cont] / [TABLE:end], etc.).
When include_breadcrumb=True (default), sets embedding_content and breadcrumb
on every returned chunk.
enrich_chunk / enrich_chunks
enrich_chunk(chunk: DocumentChunk) -> DocumentChunk
enrich_chunks(chunks: list[DocumentChunk]) -> list[DocumentChunk]
Returns new chunk(s) with embedding_content set. Never mutates input.
Output format:
[doc_name > Ancestor > Section]
<content>
The breadcrumb is taken from chunk.breadcrumb when present. When absent it is
rebuilt from chunk.document_name and chunk.section. If neither is available,
embedding_content is set to chunk.content unchanged.
DocumentLoader
DocumentLoader(
min_chunk_size: int = 600,
max_chunk_size: int = 1500,
overflow_margin: float = 0.15,
enrich_context: bool = True,
include_doc_name: bool = True,
extra_transports: list | None = None,
extra_extractors: list | None = None,
)
Full pipeline: fetch → extract → chunk → enrich. enrich_context=False disables
enrichment and is only useful as a baseline for benchmarking.
Core load methods
loader.load(uri, name=None)— fetch from any supported URI (local path,http(s)://,s3://,ftp://,sftp://). Delegates toload_structured_file()for.parquet,.arrow,.feather,.csv,.jsonl,.ndjson.loader.load_bytes(data, name, doc_type="auto", source_path=None)— extract from raw bytes;doc_type="auto"detects fromsource_path.loader.load_text(text, name)— chunk and enrich pre-extracted text.
Structured / metadata loaders
-
loader.load_query(connection_url, query, name, params=None)— execute a SQL query via SQLAlchemy, render results as a markdown table, and chunk.connection_urlis any SQLAlchemy URL (e.g."sqlite:///data.db"). -
loader.load_schema(tables)— build N+1DocumentChunkobjects perTableMeta: one"db_table"chunk summarising the table plus one"db_column"chunk per column. -
loader.load_api(endpoints)— build N+1DocumentChunkobjects perEndpointMeta: one"api_endpoint"/"api_graphql_query"/"api_graphql_mutation"/"api_graphql_type"chunk plus one"api_field"chunk per field. -
loader.load_structured_file(path_or_uri, name=None)— infer schema from.csv,.json,.jsonl/.ndjson,.parquet,.arrow, or.featherand delegate toload_schema(). Returns the same N+1 layout. -
loader.load_imap(uri, *, include_attachments=False, limit=None)— fetch messages from an IMAP mailbox. Each message becomes a separate set of chunks; attachments are optionally extracted inline.URI format:
imap[s]://user:pass@host[:port]/MAILBOX[?search=CRITERIA&limit=N]search=passes RFC 3501 criteria directly to the server — filtering happens before any bytes are transferred. Criteria are space-separated (implicit AND). Common values:Criterion Meaning ALLevery message (default) UNSEEN/SEENunread / read FROM addrsender address SUBJECT textsubject contains text BODY textbody contains text SINCE dateon or after date (e.g. 01-Jan-2025)BEFORE datebefore date FLAGGEDstarred / flagged messages LARGER n/SMALLER nsize threshold in bytes limit=N(also a kwarg) caps results to the N most-recent messages by UID after server-side filtering. Use Gmail/O365 app passwords — OAuth2 is not supported.# Unread messages with PDF attachments from the last 90 days chunks = loader.load_imap( "imaps://me@example.com:app-pass@imap.gmail.com/INBOX" "?search=UNSEEN%20SINCE%2001-Feb-2025", include_attachments=True, limit=100, )
-
loader.load_from_db(connection, queries)— execute one or more SQL queries or views against a live DB connection and load the results as document chunks. Each query becomes a separate document.queriesis adict[name, sql]orlist[tuple[name, sql]]. The same connection used for schema introspection and NER data vocab can be passed here — no second authentication needed. -
loader.load_from_cassandra(contact_points, dataset_queries, *, port, keyspace, username, password, local_dc)— execute CQL queries against Cassandra and load results as document chunks. Each chunk carriessource_detailannotations (host, port, keyspace, query). For schema indexing and NER vocab, useCassandraCrawlerdirectly.
Crawl methods
loader.load_site(url, max_pages=50, max_depth=3, same_domain=True, exclude_patterns=None, include_pattern=None, crawler=None)— crawl a website and load all discovered HTML pages.loader.load_directory(path, extensions=None, recursive=True, max_files=1000, crawler=None)— load all documents in a local directory or S3 prefix. Code extensions (.py,.ts,.tsx,.js,.jsx,.java) are included by default.loader.load_crawl(uri, crawler=None, **crawler_kwargs)— generic entry point;load_siteandload_directoryare convenience wrappers.
GitHubCrawler
GitHubCrawler indexes a GitHub repository — or every repo a token can reach — without cloning. It calls the GitHub REST API to get the file tree, then returns raw.githubusercontent.com URLs. HttpTransport fetches each blob on demand; no local storage is needed.
from chonk.transports import GitHubCrawler
from chonk import DocumentLoader
crawler = GitHubCrawler(token="ghp_...") # or set GITHUB_TOKEN env var
loader = DocumentLoader()
Set GITHUB_TOKEN in the environment and omit the token argument to avoid hardcoding credentials.
Full index of a single repo
chunks = loader.load_crawl("https://github.com/org/repo", crawler=crawler)
# Persist the watermark — needed for incremental updates
sha = crawler.current_sha
crawler.current_sha is set after every crawl() call. Store it; it is the input to the next incremental run.
Incremental update
Pass the previously saved SHA as since_sha. Only files added, modified, renamed, or copied since that commit are returned.
chunks = loader.load_crawl(
"https://github.com/org/repo",
crawler=crawler,
since_sha=sha,
)
sha = crawler.current_sha # update the watermark
When since_sha equals the current HEAD, crawl() returns an empty list immediately — nothing changed.
All accessible repos with crawl_all
list_repos() paginates /user/repos with affiliation=owner,collaborator,organization_member, covering personal repos, org repos, and repos shared with the token. crawl_all() calls list_repos() then crawl() on each, returning (urls, current_shas).
import json, pathlib
shas_file = pathlib.Path("github_shas.json")
since_shas = json.loads(shas_file.read_text()) if shas_file.exists() else {}
crawler = GitHubCrawler(
repo_include=r"org/", # only repos whose URL matches this regex
repo_exclude=r"-archived$", # skip repos matching this regex
)
urls, current_shas = crawler.crawl_all(since_shas=since_shas)
# Load all discovered URLs
chunks = []
for url in urls:
chunks.extend(loader.load(url))
# Persist watermarks for the next run
shas_file.write_text(json.dumps(current_shas, indent=2))
Repos that fail (bad token scope, private with insufficient access) are skipped with a warning rather than aborting the entire run.
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
token |
str | None |
None |
GitHub personal access token. Falls back to GITHUB_TOKEN env var. Public repos work without a token but are rate-limited to 60 requests/hour. |
extensions |
list[str] | None |
See below | File extensions to include. Leading . is optional. |
branch |
str | None |
None |
Branch or tag to crawl. Defaults to the repo's default branch. |
max_files |
int |
2000 |
Maximum files returned per repo. |
repo_include |
str | None |
None |
Regex — only repos whose https://github.com/{owner}/{repo} URL matches are crawled. |
repo_exclude |
str | None |
None |
Regex — repos whose URL matches are skipped. Applied after repo_include. |
Default extensions: .md, .txt, .rst, .html, .htm, .pdf, .docx, .xlsx, .pptx, .csv, .json, .xml, .yaml, .yml, .py, .pyw, .ts, .tsx, .js, .jsx, .mjs, .java.
GitHubCrawler is exported from chonk.transports.
DatabaseSchemaCrawler
DatabaseSchemaCrawler indexes stored procedures, functions, views, and triggers from a live database as searchable document chunks. Each object's SQL definition becomes its own chunk, with a dbschema:// URI as the document name.
Unlike load_schema() — which describes table structure (column names, types, relationships) — DatabaseSchemaCrawler captures the actual SQL logic: the CREATE VIEW body, the procedure parameter list and code, trigger firing conditions. Together they cover the full picture of what a database does and how.
The class implements both the Crawler and Transport protocols. Pass the same instance as both crawler= and in extra_transports=:
from chonk.transports import DatabaseSchemaCrawler
from chonk import DocumentLoader
crawler = DatabaseSchemaCrawler("postgresql://user:pass@host/db")
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("postgresql://user:pass@host/db", crawler=crawler)
crawl() connects to the database, fetches all matching schema objects, and caches their definitions keyed by dbschema:// URIs. load_crawl() then calls fetch() for each URI — which reads from that cache, not the database again.
Supported dialects
| Dialect | Views | Procedures / Functions | Triggers |
|---|---|---|---|
| PostgreSQL | Yes | Yes | Yes |
| MySQL / MariaDB | Yes | Yes | Yes |
| SQL Server | Yes | Yes (P, FN, IF, TF) |
Yes |
| SQLite | Yes | No (SQLite has no stored procedures) | Yes |
For dialects not in this list, crawl() logs a warning and indexes views only (via SQLAlchemy inspection, which works across all dialects).
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
connection_url |
str |
required | SQLAlchemy connection URL |
include_procs |
bool |
True |
Include stored procedures and functions |
include_views |
bool |
True |
Include views |
include_triggers |
bool |
True |
Include triggers |
schemas |
list[str] | None |
None |
Restrict to these schema names. None indexes all non-system schemas. |
Basic usage — index everything from a PostgreSQL database
from chonk.transports import DatabaseSchemaCrawler
from chonk import DocumentLoader
crawler = DatabaseSchemaCrawler("postgresql://user:pass@prod-db/warehouse")
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("postgresql://user:pass@prod-db/warehouse", crawler=crawler)
for chunk in chunks:
# chunk.document_name — e.g. "VIEW: reporting.v_customer_360"
# chunk.content — the SQL definition, prefixed with "-- VIEW: ..."
embed(chunk.embedding_content)
Selective indexing — procs and views only, restricted to named schemas
crawler = DatabaseSchemaCrawler(
"mssql+pyodbc://sa:pass@sqlserver/OperationsDB?driver=ODBC+Driver+18+for+SQL+Server",
include_procs=True,
include_views=True,
include_triggers=False,
schemas=["dbo", "reporting"],
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl(
"mssql+pyodbc://sa:pass@sqlserver/OperationsDB?driver=ODBC+Driver+18+for+SQL+Server",
crawler=crawler,
)
Combined pipeline — GitHub source code + database schema
The motivating use case for DatabaseSchemaCrawler is cross-referencing application code against the database objects it calls. A view named v_customer_risk in the DB and a function called fetch_customer_risk in the Python codebase land in the same retrieval index. A query for "how is customer risk calculated?" pulls both.
from chonk.transports import GitHubCrawler, DatabaseSchemaCrawler
from chonk import DocumentLoader
github = GitHubCrawler(token="ghp_...")
db = DatabaseSchemaCrawler("postgresql://user:pass@prod-db/warehouse")
loader = DocumentLoader(extra_transports=[db])
# Index the application source code
code_chunks = loader.load_crawl("https://github.com/org/risk-service", crawler=github)
# Index the database schema objects — views, procs, triggers
db_chunks = loader.load_crawl("postgresql://user:pass@prod-db/warehouse", crawler=db)
all_chunks = code_chunks + db_chunks
# embed and store as usual
DatabaseSchemaCrawler is exported from chonk.transports.
SharePointCrawler
SharePointCrawler indexes a SharePoint site — document libraries, generic lists, calendar/events lists, and site pages — and produces searchable chunks from all of them. Three authentication modes cover the full range of SharePoint deployments: Azure AD for Microsoft 365, legacy Add-in auth for older cloud tenants, and NTLM for on-premises servers.
Like DatabaseSchemaCrawler, the class implements both the Crawler and Transport protocols. Pass the same instance as both crawler= and in extra_transports=:
from chonk.transports import SharePointCrawler
from chonk import DocumentLoader
crawler = SharePointCrawler(
site_url="https://contoso.sharepoint.com/sites/mysite",
auth_mode="azure_ad",
tenant_id="your-tenant-id",
client_id="your-client-id",
client_secret="your-client-secret",
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl(
"https://contoso.sharepoint.com/sites/mysite",
crawler=crawler,
)
crawl() authenticates, enumerates all configured artifact types, and returns a list of spitem:// URIs. Documents are registered as pending — not downloaded. fetch() downloads each document on demand when load_crawl() processes the URI list. Lists, calendars, and pages are serialized to text during crawl() and cached; they are read from that cache during fetch(), not re-fetched.
Authentication modes
"azure_ad" — Microsoft 365 / SharePoint Online with an Azure AD app registration. Uses MSAL to acquire a client-credentials token and calls the Microsoft Graph API. Requires pip install msal.
"legacy" — SharePoint Add-in OAuth for tenants that cannot use Azure AD app registrations. Acquires a token from the Azure ACS endpoint (accounts.accesscontrol.windows.net) and calls the SharePoint REST API (/_api/). Requires only requests.
"ntlm" — On-premises SharePoint Server. Authenticates with Windows NTLM credentials and calls the SharePoint REST API. Requires pip install requests-ntlm.
Artifact types
All four types are enabled by default. Pass artifacts= to restrict:
| Artifact | Default | How it is fetched | Content |
|---|---|---|---|
"documents" |
Yes | Lazily, in fetch() |
Raw file bytes — same extractors as loader.load() |
"lists" |
Yes | During crawl(), cached |
List item fields serialized as plain text |
"calendars" |
Yes | During crawl(), cached |
Event fields serialized as plain text (Title, EventDate, EndDate, Location, Description first) |
"pages" |
Yes | During crawl(), cached |
Site page HTML |
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
site_url |
str |
required | Full URL of the SharePoint site |
auth_mode |
str |
"azure_ad" |
"azure_ad", "legacy", or "ntlm" |
tenant_id |
str | None |
None |
Azure AD tenant ID or domain (azure_ad and legacy modes) |
client_id |
str | None |
None |
App client ID (azure_ad and legacy modes) |
client_secret |
str | None |
None |
App client secret (azure_ad and legacy modes) |
username |
str | None |
None |
Windows username including domain, e.g. DOMAIN\user (ntlm) |
password |
str | None |
None |
Password (ntlm) |
artifacts |
list[str] | None |
None |
Artifact types to crawl. None enables all four. |
max_items |
int |
5000 |
Maximum list items fetched per list |
Azure AD — full site crawl
Use this for any Microsoft 365 / SharePoint Online tenant where you can register an Azure AD application.
from chonk.transports import SharePointCrawler
from chonk import DocumentLoader
crawler = SharePointCrawler(
site_url="https://contoso.sharepoint.com/sites/legal",
auth_mode="azure_ad",
tenant_id="contoso.onmicrosoft.com",
client_id="a1b2c3d4-...",
client_secret="your-secret",
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl(
"https://contoso.sharepoint.com/sites/legal",
crawler=crawler,
)
for chunk in chunks:
embed(chunk.embedding_content)
Legacy Add-in auth
When the tenant does not support Azure AD app registrations, register a SharePoint Add-in via appregnew.aspx and use auth_mode="legacy". The tenant_id field accepts either a GUID or a domain like contoso.onmicrosoft.com — the actual tenant GUID is read from the WWW-Authenticate header automatically, so the domain form works.
crawler = SharePointCrawler(
site_url="https://contoso.sharepoint.com/sites/operations",
auth_mode="legacy",
tenant_id="contoso.onmicrosoft.com",
client_id="your-addin-client-id",
client_secret="your-addin-client-secret",
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl(
"https://contoso.sharepoint.com/sites/operations",
crawler=crawler,
)
NTLM — on-premises SharePoint Server
For SharePoint Server deployments behind corporate firewalls. Supply Windows credentials as DOMAIN\username.
crawler = SharePointCrawler(
site_url="https://sharepoint.corp.example.com/sites/projects",
auth_mode="ntlm",
username=r"CORP\svc_indexer",
password="service-account-password",
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl(
"https://sharepoint.corp.example.com/sites/projects",
crawler=crawler,
)
Selective artifacts — documents and pages only
Pass artifacts= to skip artifact types you do not need. Omitting lists and calendars is common when the site contains mostly documents and wiki pages.
crawler = SharePointCrawler(
site_url="https://contoso.sharepoint.com/sites/wiki",
auth_mode="azure_ad",
tenant_id="contoso.onmicrosoft.com",
client_id="a1b2c3d4-...",
client_secret="your-secret",
artifacts=["documents", "pages"], # skip lists and calendars
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl(
"https://contoso.sharepoint.com/sites/wiki",
crawler=crawler,
)
SharePointCrawler is exported from chonk.transports.
GmailCrawler
GmailCrawler indexes Gmail messages via the Gmail REST API. It authenticates with OAuth2 and pages through a mailbox label, returning one chunk set per message. The message subject becomes the document_name; the body is the plain-text content.
Like SharePointCrawler, the class implements both the Crawler and Transport protocols. Pass the same instance as both crawler= and in extra_transports=:
from chonk.transports import GmailCrawler
from chonk import DocumentLoader
crawler = GmailCrawler(
client_id="your-client-id",
client_secret="your-client-secret",
# token_path defaults to ~/.chonk/gmail_token.json
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("gmail://me/INBOX", crawler=crawler)
crawl() calls the Gmail API to list message IDs and returns gmsg:// URIs — one per message. No message content is downloaded at this stage. fetch() downloads each message lazily when load_crawl() processes the URI list, and caches the result so a second call to fetch() for the same URI hits the cache.
First-run authentication
On the first run the browser opens for an OAuth2 consent screen (read-only Gmail scope). The resulting token is written to token_path (default ~/.chonk/gmail_token.json) and reused on every subsequent run. Expired tokens are refreshed automatically without user interaction.
Run the bundled helper script once to complete the consent flow before using the crawler in a pipeline:
python scripts/gmail_auth.py
Credentials can also be passed via environment variables rather than constructor arguments:
export GOOGLE_EMAIL_CLIENT_ID=your-client-id
export GOOGLE_EMAIL_CLIENT_SECRET=your-client-secret
URI scheme
| URI | Mailbox |
|---|---|
gmail://me/INBOX |
Inbox |
gmail://me/SENT |
Sent mail |
gmail://me/DRAFTS |
Drafts |
gmail://me/SPAM |
Spam |
gmail://me/TRASH |
Trash |
gmail://me/ALL |
All mail |
crawl() returns internal gmsg://<key>/<message_id> URIs. These are opaque — pass them back to fetch() or load_crawl() unchanged.
Filtering with Gmail search queries
The query parameter accepts any Gmail search string. When query is supplied the label in the URI is ignored; the search covers all mail.
# Unread messages since the start of 2025
chunks = loader.load_crawl(
"gmail://me/INBOX",
crawler=crawler,
query="is:unread after:2025/01/01",
limit=50,
)
# Messages from a specific sender
chunks = loader.load_crawl(
"gmail://me/INBOX",
crawler=crawler,
query="from:alice@example.com",
limit=100,
)
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
client_id |
str | None |
None |
Google OAuth2 client ID. Falls back to GOOGLE_EMAIL_CLIENT_ID env var. |
client_secret |
str | None |
None |
Google OAuth2 client secret. Falls back to GOOGLE_EMAIL_CLIENT_SECRET env var. |
token_path |
str | Path | None |
~/.chonk/gmail_token.json |
Path to read/write the OAuth2 token. |
user_id |
str |
"me" |
Gmail user ID. "me" always refers to the authenticated account. |
redirect_port |
int |
8000 |
Local port used for the OAuth2 redirect during the consent flow. |
Full inbox crawl
from chonk.transports import GmailCrawler
from chonk import DocumentLoader
crawler = GmailCrawler(
client_id="your-client-id",
client_secret="your-client-secret",
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("gmail://me/INBOX", crawler=crawler, limit=200)
for chunk in chunks:
# chunk.document_name — message subject (or gmsg:// URI if subject is absent)
# chunk.content — From/To/Subject/Date headers + plain-text body
embed(chunk.embedding_content)
Sent mail
chunks = loader.load_crawl("gmail://me/SENT", crawler=crawler, limit=100)
GmailCrawler is exported from chonk.transports.
MongoDBCrawler
MongoDBCrawler indexes documents from one or more MongoDB collections. Schema is inferred by sampling up to 500 documents per collection via the $jsonSchema validator (falling back to $sample aggregation). Each collection emits one schema chunk and one chunk per document. Field names are available for NER vocabulary via get_field_names().
from chonk.transports import MongoDBCrawler
from chonk.loader import DocumentLoader
crawler = MongoDBCrawler(
uri="mongodb://localhost:27017",
database="prod",
collections=["articles", "reports"],
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("mongodb://prod/articles", crawler=crawler)
# NER vocabulary: all field names + collection names + database
vocab = crawler.get_field_names()
| Parameter | Default | Description |
|---|---|---|
uri |
required | MongoDB connection URI |
database |
required | Database name |
collections |
None |
Collections to crawl; None = all collections |
schema_sample_size |
500 |
Max documents sampled per collection for schema inference |
field_aliases |
None |
Map raw field names to normalized names for NER |
Each chunk's source_detail contains {"type": "mongodb", "database": ..., "collection": ..., "doc_id": ...}.
Requires: pymongo>=4.0 (pip install pymongo).
MongoDBCrawler is exported from chonk.transports.
ElasticsearchCrawler
ElasticsearchCrawler indexes documents from an Elasticsearch or OpenSearch index. Pagination uses the search_after API (no scroll contexts). Schema is retrieved via GET /{index}/_mapping and includes fields, dynamic fields, and copy fields. Works with ES ≥ 7.x and OpenSearch ≥ 1.x.
from chonk.transports import ElasticsearchCrawler
from chonk.loader import DocumentLoader
crawler = ElasticsearchCrawler(
"https://my-cluster.es.io:9243",
index="kb-docs",
api_key="base64encodedkey==",
source_fields=["title", "body", "author"],
query={"term": {"published": True}},
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("https://my-cluster.es.io:9243/kb-docs", crawler=crawler)
vocab = crawler.get_field_names()
| Parameter | Default | Description |
|---|---|---|
base_url |
required | Cluster base URL (e.g. https://localhost:9200) |
index |
required | Index name or pattern (e.g. logs-*) |
api_key |
None |
Base-64 encoded id:key API key |
username / password |
None |
HTTP Basic auth alternative to api_key |
query |
{"match_all": {}} |
Elasticsearch query DSL |
source_fields |
None |
Fields to include in _source; None = all |
page_size |
200 |
Documents per page |
verify_ssl |
True |
Verify TLS certificates |
field_aliases |
None |
Map raw field names to normalized names for NER |
Each chunk's source_detail contains {"type": "elasticsearch", "base_url": ..., "index": ..., "doc_id": ...}.
Requires: requests>=2.28 (pip install requests).
ElasticsearchCrawler is exported from chonk.transports.
SolrCrawler
SolrCrawler indexes documents from an Apache Solr collection using cursor-mark pagination for efficient deep pagination. Schema is retrieved via GET /solr/{collection}/schema and includes fields, dynamic fields, and copy fields.
from chonk.transports import SolrCrawler
from chonk.loader import DocumentLoader
crawler = SolrCrawler(
"http://localhost:8983/solr",
collection="articles",
query="published:true",
fields=["id", "title", "body", "author"],
username="solr",
password="SolrRocks",
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("http://localhost:8983/solr/articles", crawler=crawler)
vocab = crawler.get_field_names()
| Parameter | Default | Description |
|---|---|---|
base_url |
required | Solr base URL including /solr |
collection |
required | Collection or core name |
query |
"*:*" |
Solr query string |
fields |
None |
Fields to retrieve; None = all (fl=*) |
page_size |
200 |
Documents per cursor page |
username / password |
None |
HTTP Basic auth |
verify_ssl |
True |
Verify TLS certificates |
field_aliases |
None |
Map raw field names to normalized names for NER |
Each chunk's source_detail contains {"type": "solr", "base_url": ..., "collection": ..., "doc_id": ...}.
Requires: requests>=2.28 (pip install requests).
SolrCrawler is exported from chonk.transports.
DynamoDBCrawler
DynamoDBCrawler indexes items from an AWS DynamoDB table using paginated full table scans (ExclusiveStartKey). Schema is inferred by sampling up to 500 items. For large tables, use filter_expression to reduce scan cost.
from chonk.transports import DynamoDBCrawler
from chonk.loader import DocumentLoader
crawler = DynamoDBCrawler(
table="kb-docs",
region="us-east-1",
aws_access_key_id="AKIA...",
aws_secret_access_key="secret",
projection_expression="docId, title, body, #ts",
expression_attribute_names={"#ts": "timestamp"},
field_aliases={"pk": "id", "sk": "sort_key"},
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("dynamodb://kb-docs", crawler=crawler)
vocab = crawler.get_field_names()
| Parameter | Default | Description |
|---|---|---|
table |
required | DynamoDB table name |
region |
"us-east-1" |
AWS region |
aws_access_key_id / aws_secret_access_key |
None |
Explicit credentials; falls back to IAM role / env vars |
aws_session_token |
None |
STS session token |
endpoint_url |
None |
Override endpoint (DynamoDB Local) |
filter_expression |
None |
boto3 Attr filter expression |
projection_expression |
None |
Comma-separated attribute names to return |
expression_attribute_names |
None |
Substitution map for reserved words |
page_size |
100 |
Items per scan page |
schema_sample_size |
500 |
Max items sampled for schema inference |
field_aliases |
None |
Map raw attribute names to normalized names for NER |
Each chunk's source_detail contains {"type": "dynamodb", "table": ..., "region": ..., "endpoint": ...}.
Requires: boto3>=1.26 (pip install boto3).
DynamoDBCrawler is exported from chonk.transports.
FirestoreCrawler
FirestoreCrawler indexes documents from one or more Google Cloud Firestore collections. Authentication uses Application Default Credentials (ADC) or an explicit service-account key file. Schema is inferred per collection by sampling up to 500 documents.
from chonk.transports import FirestoreCrawler
from chonk.loader import DocumentLoader
crawler = FirestoreCrawler(
project="my-gcp-project",
collections=["articles", "reports"],
credentials_path="/path/to/service-account.json",
field_aliases={"createdAt": "created_at"},
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("firestore://my-gcp-project/articles", crawler=crawler)
vocab = crawler.get_field_names()
| Parameter | Default | Description |
|---|---|---|
project |
required | GCP project ID |
collections |
required | Top-level collection names to crawl |
credentials_path |
None |
Path to service-account JSON key; None uses ADC |
database |
"(default)" |
Firestore database ID |
schema_sample_size |
500 |
Max docs sampled per collection for schema inference |
field_aliases |
None |
Map raw field names to normalized names for NER |
Each chunk's source_detail contains {"type": "firestore", "project": ..., "database": ..., "collection": ..., "doc_id": ...}.
Requires: google-cloud-firestore>=2.11 (pip install google-cloud-firestore).
FirestoreCrawler is exported from chonk.transports.
CosmosCrawler
CosmosCrawler indexes items from one or more Azure Cosmos DB containers (NoSQL API). Schema is inferred per container by sampling up to 500 items. One schema chunk and one chunk per item are emitted per container.
from chonk.transports import CosmosCrawler
from chonk.loader import DocumentLoader
crawler = CosmosCrawler(
url="https://myaccount.documents.azure.com:443/",
key="base64key==",
database="mydb",
containers=["articles", "reports"],
query="SELECT c.id, c.title, c.body FROM c WHERE c.published = true",
max_item_count=500,
field_aliases={"_ts": "timestamp"},
)
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("cosmos://mydb/articles", crawler=crawler)
vocab = crawler.get_field_names()
| Parameter | Default | Description |
|---|---|---|
url |
required | Cosmos DB account endpoint URL |
key |
required | Account key or resource token |
database |
required | Database name |
containers |
None |
Container names to crawl; None = all containers |
query |
"SELECT * FROM c" |
Cosmos DB SQL query |
max_item_count |
200 |
Max items per request page |
connection_mode |
"Gateway" |
"Gateway" or "Direct" |
schema_sample_size |
500 |
Max items sampled per container for schema inference |
field_aliases |
None |
Map raw field names to normalized names for NER |
Each chunk's source_detail contains {"type": "cosmos", "url": ..., "database": ..., "container": ..., "item_id": ...}.
Requires: azure-cosmos>=4.5 (pip install azure-cosmos).
CosmosCrawler is exported from chonk.transports.
Code indexing
Python, TypeScript/JavaScript, and Java files are first-class document types. The
extractor converts source structure into Markdown headings — classes become #, methods
become ## — then feeds the result through the standard chunker unchanged.
loader = DocumentLoader()
# Single file
chunks = loader.load("src/auth/token.py")
# Entire repository
chunks = loader.load_directory("./src")
for chunk in chunks:
# chunk.section — ["TokenService", "validate"]
# chunk.source_detail — {"line_start": 42, "line_end": 67, "symbol": "TokenService.validate"}
embed(chunk.embedding_content)
Docstrings and JSDoc/Javadoc comments are emitted as plain-text paragraphs before the
code fence, giving the embedding model natural language to anchor on. Import blocks are
collected under a single ## Imports heading so they do not dilute the method-level
chunks.
ImportCrawler
Discovers transitive dependencies starting from a seed file, bounded by depth or repository root. Use it to index a module and everything it imports without having to enumerate files manually.
from chonk.transports import ImportCrawler
crawler = ImportCrawler(root_path="./src", max_depth=3)
uris = crawler.crawl("src/auth/token.py") # seed + all reachable imports within src/
chunks = loader.load_crawl("src/auth/token.py", crawler=crawler)
root_path prevents crawling outside the repository. max_depth=0 returns only the
seed file; max_depth=1 adds its direct imports. Bare module specifiers (react,
java.util.*) are skipped — only local relative imports and resolvable package paths
are followed.
Live DB queries as document chunks
load_from_db() materialises SQL queries or views against an existing DB connection
and feeds the results through the standard CSV extractor pipeline. This closes the loop
on "find everything we know about customer X": structured docs, schema metadata, NER
entity vocab, and now live relational data — all in one retrieval index.
from chonk import DocumentLoader
loader = DocumentLoader()
# Same engine used for load_schema() and NerPipeline.add_from_db()
chunks = loader.load_from_db(
connection=engine, # SQLAlchemy Engine, Connection, URL string, or any .execute()
queries={
"customer_360": "SELECT * FROM v_customer_360",
"open_invoices": "SELECT customer_name, amount, due_date "
"FROM invoices WHERE status = 'open'",
"risk_flags": "SELECT * FROM vw_customer_risk_flags",
},
)
# Each key becomes a separate document_name in the returned chunks
Accepts the same connection types as NerPipeline.add_from_db() and
load_schema() — pass the same object, no second authentication needed.
Queries that return zero rows produce no chunks (empty result sets are silently skipped). Column names become the CSV header row and appear in the chunk text.
Chunk provenance
Every chunk produced by load_from_db() carries a source_detail dict with
enough information to locate the original rows — without any credentials:
chunk.source_detail == {
"db_dialect": "postgresql+psycopg2", # SQLAlchemy drivername
"db_host": "prod-db.internal",
"db_port": 5432,
"db_name": "warehouse",
"query": "SELECT * FROM v_customer_360",
"row_start": 12, # 1-based data row index (header = row 0)
"row_end": 47,
}
row_start / row_end are 1-based indices into the data rows (excluding the
header). Re-run the query with LIMIT/OFFSET or WHERE rownum BETWEEN to
retrieve exactly the rows the chunk came from.
db_host, db_port, and db_name are omitted when not present in the connection
(e.g. SQLite in-memory). Credentials (username, password) are never included.
Plain CSV files loaded via loader.load() also receive row_start / row_end
(but not the DB fields).
SqlQueryTransport
load_from_db() is a convenience wrapper around SqlQueryTransport, which can be
used directly when you need fine-grained control or want to integrate with the
transport registry:
from chonk.transports import SqlQueryTransport
transport = SqlQueryTransport(engine)
result = transport.fetch("sqlquery://customer_360",
sql="SELECT * FROM v_customer_360")
# result.data — UTF-8 CSV bytes
# result.detected_mime — "text/csv"
# result.source_path — "customer_360"
CassandraCrawler
CassandraCrawler brings the same four-capability pattern as the relational DB
stack to Apache Cassandra:
| Capability | Method |
|---|---|
| Schema chunks (keyspace/table/column metadata) | crawl() → schema FetchResults |
| NER vocab from table/column names | get_table_meta() → SchemaVocabBuilder.add_tables() |
| NER vocab from CQL entity queries | get_entity_vocab() → NerPipeline.add_entities() |
| Dataset chunks from CQL queries | dataset_queries= / loader.load_from_cassandra() |
Each chunk carries source_detail with db_dialect, db_host, db_port, db_name, and query.
from chonk.transports import CassandraCrawler
from chonk.loader import DocumentLoader
from chonk.ner import NerPipeline
crawler = CassandraCrawler(
contact_points=["10.0.0.1"],
keyspace="clinical",
dataset_queries={
"patient_notes": "SELECT patient_id, note_text FROM clinical_notes",
"diagnoses": "SELECT patient_id, icd_code, description FROM diagnoses",
},
entity_queries={
"physician": "SELECT full_name FROM physicians",
"drug": "SELECT drug_name FROM formulary",
},
local_dc="us-east",
)
# Index schema + datasets
loader = DocumentLoader(extra_transports=[crawler])
chunks = loader.load_crawl("cassandra://10.0.0.1/clinical", crawler=crawler)
# NER: Cassandra table vocab + entity vocab
pipeline = NerPipeline(db_enrich=True, spacy_entities=True)
pipeline.add_tables(crawler.get_table_meta())
for entity_type, names in crawler.get_entity_vocab().items():
pipeline.add_entities(names, entity_type=entity_type)
Or use the DocumentLoader convenience method for dataset-only loading:
chunks = loader.load_from_cassandra(
contact_points=["10.0.0.1"],
keyspace="clinical",
dataset_queries={
"patient_notes": "SELECT patient_id, note_text FROM clinical_notes",
},
username="service_account",
password="...",
)
Requires cassandra-driver>=3.25 (pip install cassandra-driver).
Unified DB pattern: find everything about entity X
Most enterprise knowledge lives in three places simultaneously: unstructured documents (contracts, reports, emails, filings), relational database schema (what data exists and how it is structured), and relational database content (the actual records). Naive RAG pipelines index one of these. Chonk indexes all three through a single DB connection.
engine = create_engine("postgresql://prod-db/warehouse")
┌─────────────────────────────────────────────────────────────┐
│ 1. Schema as chunks loader.load_schema(tables) │
│ What data exists, column names, types, relationships │
│ chunk_type = "db_table" / "db_column" │
├─────────────────────────────────────────────────────────────┤
│ 2. Entity vocab from DB pipeline.add_from_db(engine) │
│ Known entity names → NER vocab for all document types │
│ "Acme Corp" tagged as customer in contracts, emails, │
│ filings — linked to the same entity ID everywhere │
├─────────────────────────────────────────────────────────────┤
│ 3. Live data as chunks loader.load_from_db(engine) │
│ Query results materialised as searchable document chunks│
│ Provenance: db_host, db_name, query, row_start, row_end │
└─────────────────────────────────────────────────────────────┘
All three use the same connection object. No second authentication, no credential duplication.
from sqlalchemy import create_engine
from chonk import DocumentLoader
from chonk.ner import NerPipeline, SpacyLabel
from chonk.storage import Store
engine = create_engine("postgresql+psycopg2://prod-db/warehouse")
loader = DocumentLoader()
pipeline = NerPipeline(db_enrich=True, spacy_entities=True)
# 1. Schema chunks — index what data exists and how it is structured
schema_chunks = loader.load_schema(tables) # from TableMeta introspection
# 2. Entity vocab — teach NER about your actual customers, employees, counterparties
pipeline.add_from_db(engine, queries={
"customer": "SELECT name FROM customers WHERE active = true",
"employee": "SELECT full_name FROM employees",
"counterparty": "SELECT name FROM counterparties",
})
pipeline.add_tables(tables) # schema identifiers normalised
# 3. Live data chunks — make actual records searchable
data_chunks = loader.load_from_db(engine, queries={
"customer_360": "SELECT * FROM v_customer_360",
"open_invoices": "SELECT customer_name, amount, due_date "
"FROM invoices WHERE status = 'open'",
})
# 4. Unstructured docs — NER now links these to the same entities as the DB data
doc_chunks = loader.load_directory("./documents")
pipeline.run_on_chunks(doc_chunks, entity_index)
# Everything lands in one index (DuckDB or PgVectorBackend — same interface)
all_chunks = schema_chunks + data_chunks + doc_chunks
with Store("index.duckdb", embedding_dim=1024) as store:
store.add_document(all_chunks, embeddings)
# or: PgVectorBackend("postgresql://prod-db/warehouse").add_chunks(all_chunks, embeddings)
A query for "Acme Corp payment terms" now retrieves: the contract clause (unstructured
doc), the payment_terms column definition (schema chunk), and the matching rows from
the invoices view (live data chunk) — all linked through the same ent_acme_corp
entity ID in EntityIndex.
Source detail
Every DocumentChunk already carries section (heading breadcrumb path) and, for
text-based formats, source_offset / source_length (byte offsets into the extracted
text). source_detail adds format-specific navigation on top of those — the kind of
sub-location that breadcrumbs alone cannot express.
How much additional detail is useful varies by format:
| Format | What breadcrumbs give you | What source_detail adds |
|---|---|---|
| Markdown | Heading path | Char offsets (already in source_offset/source_length) — source_detail is None |
| XLSX | Sheet + named range (if any) | sheet, row_start, row_end — useful when a sheet has thousands of rows |
| DOCX | Heading section path | paragraph_start, paragraph_end, section — pin-points exact paragraph range |
| None (no heading extraction) | page or page_start / page_end |
|
| PPTX | None | slide, shape |
| Python | Class / method heading | line_start, line_end, symbol (e.g. "MyClass.run") — IDE jump-to-line |
| TypeScript / JavaScript | Class / function heading | line_start, line_end, symbol |
| Java | Class / method heading | line_start, line_end, symbol |
| MongoDB | — | type, database, collection, doc_id |
| Elasticsearch / OpenSearch | — | type, base_url, index, doc_id |
| Solr | — | type, base_url, collection, doc_id |
| DynamoDB | — | type, table, region, endpoint |
| Firestore | — | type, project, database, collection, doc_id |
| Cosmos DB | — | type, url, database, container, item_id |
source_detail is not embedded — it lives on the chunk as metadata only. Use it to
build source links, IDE jump-to-definition integrations, or citation footnotes.
Custom extractors populate source_detail by implementing annotate() (see
Extending Chonk).
Using source_detail to trace a chunk back to its origin
Files
For page-addressable formats the path is chunk.document_name and the location is in source_detail:
chunk = results[0]
print(chunk.document_name) # "/reports/Q1-2025.pdf"
print(chunk.source_detail) # {"page_start": 4, "page_end": 5}
# PDF: open at page
import subprocess
subprocess.run(["open", "-a", "Preview", chunk.document_name,
"--args", f"-p{chunk.source_detail['page_start']}"])
# XLSX: open sheet at row range
print(chunk.source_detail) # {"sheet": "Revenue", "row_start": 12, "row_end": 47}
# Python / TypeScript / Java: jump to symbol
print(chunk.source_detail) # {"line_start": 42, "line_end": 67, "symbol": "TokenService.validate"}
Web pages
chunk.document_name holds the original URL:
chunk = results[0]
print(chunk.document_name) # "https://docs.example.com/api/auth"
import webbrowser
webbrowser.open(chunk.document_name)
NoSQL databases
chunk.source_detail holds enough to reconnect — without any credentials:
meta = chunk.source_detail
# {"type": "mongodb", "database": "prod", "collection": "articles", "doc_id": "6627f3..."}
if meta["type"] == "mongodb":
from pymongo import MongoClient
client = MongoClient(uri) # supply credentials separately
doc = client[meta["database"]][meta["collection"]].find_one({"_id": meta["doc_id"]})
elif meta["type"] == "elasticsearch":
import requests
resp = requests.get(
f"{meta['base_url']}/{meta['index']}/_doc/{meta['doc_id']}",
headers={"Authorization": "ApiKey ..."}
)
elif meta["type"] == "dynamodb":
import boto3
table = boto3.resource("dynamodb", region_name=meta["region"]).Table(meta["table"])
# use primary key fields from the original item
elif meta["type"] == "firestore":
from google.cloud import firestore
client = firestore.Client(project=meta["project"])
doc = client.collection(meta["collection"]).document(meta["doc_id"]).get()
elif meta["type"] == "cosmos":
from azure.cosmos import CosmosClient
client = CosmosClient(meta["url"], credential="...")
item = (client.get_database_client(meta["database"])
.get_container_client(meta["container"])
.read_item(meta["item_id"], partition_key=meta["item_id"]))
elif meta["type"] == "solr":
import requests
resp = requests.get(f"{meta['base_url']}/{meta['collection']}/get",
params={"id": meta["doc_id"]})
Credentials are never stored in source_detail. Keep them in environment variables, a secrets manager, or the same credential store used at crawl time.
Extending Chonk
Domain renderers
JsonExtractor and XmlExtractor support a Renderer plug-in interface for
domain-specific document formats that have known schemas. Instead of falling back
to the generic key-path walk, a matching renderer takes over rendering and annotation
entirely. This co-locates all fields that belong together in a single chunk, rather
than splitting them across separate key-path sections.
Renderer contract
class Renderer(Protocol):
def can_render(self, source_path: str | None, obj: object) -> bool:
"""Return True if this renderer handles the parsed document object."""
def render(self, obj: object) -> str:
"""Convert the parsed object to Markdown. H1 headings mark record boundaries."""
def annotate(self, chunks: list[DocumentChunk], obj: object) -> list[DocumentChunk]:
"""Stamp chunk.source_detail and chunk.rendered_source after chunking."""
render() returns Markdown with one # Heading per logical record (one CVE, one
ATT&CK technique, one control, one trial). chunk_document() splits at those
headings, so each chunk maps to a complete record or a named subsection of one.
annotate() receives chunks produced from the rendered Markdown and the original
parsed object. It sets two fields on each chunk:
source_detail— record-level metadata (IDs, scores, status) for filtering and citation. Not embedded.rendered_source— the full per-record Markdown for that chunk's parent record. Useful for visualization: render it with any Markdown viewer to see the complete record alongside the retrieved chunk.
Built-in renderers
| Renderer | Format | Source | source_detail keys |
|---|---|---|---|
CveRenderer |
NVD CVE JSON (API v2) | JsonExtractor |
cve_id, cvss_score, severity, published |
AttackRenderer |
MITRE ATT&CK STIX 2.x bundles | JsonExtractor |
attack_id, name, tactics, platforms, is_subtechnique, parent_id |
NistRenderer |
NIST SP 800-53 OSCAL JSON | JsonExtractor |
control_id, title, group |
ClinicalTrialRenderer |
ClinicalTrials.gov API v2 | JsonExtractor |
nct_id, title, status, phases, conditions |
FdaLabelRenderer |
openFDA drug label JSON | JsonExtractor |
application_id, brand_name, generic_name, manufacturer |
FhirRenderer |
FHIR R4 Bundle JSON | JsonExtractor |
resource_type, resource_id, code, subject |
CweRenderer |
MITRE CWE XML catalog | XmlExtractor |
cwe_id, name, platforms |
All renderers are pre-registered. Pass a document as doc_type="json" or
doc_type="xml" and the right renderer is selected automatically.
from chonk import DocumentLoader
loader = DocumentLoader()
# NVD CVE feed
chunks = loader.load("https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=log4j")
annotated = [c for c in chunks if c.source_detail]
print(annotated[0].source_detail) # {"cve_id": "CVE-2021-44228", "cvss_score": 10.0, ...}
print(annotated[0].rendered_source) # full Markdown for CVE-2021-44228
# ATT&CK STIX bundle
chunks = loader.load("https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json")
print(chunks[0].source_detail["attack_id"]) # "T1059.001"
print(chunks[0].rendered_source[:200]) # # T1059.001 PowerShell ...
To add a custom renderer for a new JSON format:
from chonk.extractors import JsonExtractor
class MyRenderer:
def can_render(self, source_path, obj):
return isinstance(obj, dict) and "myKey" in obj
def render(self, obj):
return "\n\n".join(f"# {r['id']}\n\n{r['text']}" for r in obj["myKey"])
def annotate(self, chunks, obj):
for chunk in chunks:
chunk.source_detail = {"id": "..."}
chunk.rendered_source = "# ..."
return chunks
loader = DocumentLoader(extra_extractors=[JsonExtractor(renderers=[MyRenderer()])])
Custom extractor
All extractors implement three methods: can_handle, extract, and annotate.
class MyExtractor:
def can_handle(self, doc_type: str) -> bool:
return doc_type == "myformat"
def extract(self, data: bytes, source_path: str | None = None) -> str:
return data.decode() # return plain text for chunk_document()
def annotate(self, chunks: list, data: bytes, source_path: str | None = None) -> list:
# Optionally stamp chunk.source_detail with navigation metadata.
# Called by the loader after chunking; return chunks unchanged if not needed.
return chunks
loader = DocumentLoader(extra_extractors=[MyExtractor()])
chunks = loader.load_bytes(raw_bytes, name="doc", doc_type="myformat")
annotate() receives the chunks produced by chunk_document() and the original raw
bytes. It runs after chunking and before enrichment. The no-op implementation (just
return chunks) is correct for formats where source navigation is not meaningful.
Custom transport
from chonk.transports._protocol import FetchResult
class SharePointTransport:
def can_handle(self, uri): return uri.startswith("sharepoint://")
def fetch(self, uri, **kwargs):
data = ... # your fetch logic
return FetchResult(data=data, detected_mime="text/html", source_path=uri)
loader = DocumentLoader(extra_transports=[SharePointTransport()])
chunks = loader.load("sharepoint://site/document")
Storage
Chonk ships two vector backends. Both implement the same VectorBackend protocol and
return identical (chunk_id, score, DocumentChunk) results.
DuckDB (default)
Requires pip install "chonk-rag[storage]". Stores everything in a single local file.
Uses DuckDB VSS (HNSW cosine index) for vector search and DuckDB FTS (BM25) for
hybrid reranking.
import numpy as np
from chonk import DocumentLoader
from chonk.storage import Store
loader = DocumentLoader()
chunks = loader.load("report.pdf")
embeddings = your_model.encode([c.embedding_content for c in chunks])
with Store("index.duckdb", embedding_dim=1024) as store:
store.add_document(chunks, np.array(embeddings, dtype=np.float32))
results = store.search(your_model.encode(["primary outcomes"])[0], limit=5)
for chunk_id, score, chunk in results:
print(f"{score:.3f} [{chunk.document_name} > {chunk.section}]")
print(f" {chunk.content[:80]}")
PostgreSQL + pgvector
Requires pip install "chonk-rag[pgvector]". Stores chunks in a PostgreSQL table with
a vector(dim) column. Uses pgvector's HNSW cosine index for ANN search. The right
choice when your team is already running PostgreSQL and wants the vector index in the
same managed database as the rest of your data.
from chonk.storage import PgVectorBackend
import numpy as np
backend = PgVectorBackend(
dsn="postgresql://user:pass@prod-db:5432/warehouse",
embedding_dim=1024,
table="chonk_embeddings", # default; created automatically
)
backend.add_chunks(chunks, np.array(embeddings, dtype=np.float32))
results = backend.search(query_vec, limit=5, namespaces=["project_alpha"])
for chunk_id, score, chunk in results:
print(f"{score:.3f} {chunk.content[:80]}")
backend.close()
PgVectorBackend implements the full VectorBackend protocol: add_chunks,
search, delete_by_document, count, clear. The schema is created on
first instantiation; subsequent connections reuse it. namespace and chunk_type
filters work identically to the DuckDB backend.
Document registry and incremental sync
Every backend maintains a documents table that tracks a content fingerprint
for every indexed document. Use it to avoid re-downloading and re-embedding
content that hasn't changed.
A complete incremental run has three parts — add, update, and delete:
from chonk.storage import prune_documents, sync_document
present = set()
for doc_id, raw in crawl_source():
present.add(doc_id)
result = sync_document(backend, doc_id, raw)
if result.action != "skipped":
chunks = loader.load_bytes(raw, name=doc_id)
backend.add_chunks(chunks, embed(chunks))
backend.register_document(doc_id, result.content_hash, chunk_count=len(chunks))
removed = prune_documents(backend, present) # documents deleted at the source
Skipping the prune_documents() step leaves documents that were deleted at the
source indexed and searchable forever — sync_document() only ever sees
documents that still exist.
sync_document()
from chonk.storage import sync_document
result = sync_document(backend, document_name, raw_bytes)
Returns a SyncResult(action, document_name, content_hash, chunk_count, previous_chunk_count).
action |
Meaning |
|---|---|
"skipped" |
Stored hash matches; index is current. Nothing was changed. |
"added" |
Document not previously indexed. |
"updated" |
Document changed; all old chunks have been deleted. |
"deleted" |
Returned by prune_documents() only — the document is gone from the source and has been removed. |
On "added" or "updated" the caller re-embeds and calls
register_document() to complete the update. result.content_hash carries
the hash so you don't compute it twice.
Three calling patterns
1. Mutable document, no server hint — download first, then check:
raw = requests.get(url).content
result = sync_document(backend, "nvd-feed", raw)
if result.action != "skipped":
chunks = loader.load_bytes(raw, name="nvd-feed", doc_type="json")
embeddings = embed(chunks)
backend.add_chunks(chunks, embeddings)
backend.register_document("nvd-feed", result.content_hash,
source_uri=url, chunk_count=len(chunks))
2. Server provides ETag or Last-Modified — skip the download entirely if the hash already matches:
etag = requests.head(url).headers.get("ETag", "")
result = sync_document(backend, "attack-enterprise", content_hash=etag)
if result.action != "skipped":
raw = requests.get(url).content
chunks = loader.load_bytes(raw, name="attack-enterprise", doc_type="json")
# ... embed, add_chunks, register_document ...
3. Immutable / versioned URL — the URL itself is the fingerprint:
versioned_url = "https://example.com/data/cwe-4.15.xml"
result = sync_document(backend, "cwe", content_hash=versioned_url)
if result.action != "skipped":
raw = requests.get(versioned_url).content
# ...
Removing deleted documents — prune_documents()
from chonk.storage import prune_documents
removed = prune_documents(backend, present, dry_run=False)
present must be every document name the source currently holds — a
partial set deletes live data. Each removed document comes back as a
SyncResult with action="deleted" and previous_chunk_count set, ordered by
name. dry_run=True reports the same list without touching the index.
Passing an empty present while documents are registered raises ValueError:
an empty source enumeration is far more often a failed crawl than an intent to
wipe the index. Call backend.clear() to do that deliberately.
What a delete removes
delete_by_document() and prune_documents() cascade. Both remove the
document's chunks, its documents registry row, and every row keyed to those
chunks — chunk_entities, chunk_clusters, svo_triples — then garbage-collect
entities left with no remaining reference, along with their entity_aliases and
context_graph_edges. Entities still referenced by another document survive.
Deleting without that cascade is what lets stale entities rebind to new content when a document is updated, so the cascade is part of the contract every backend implements, not a DuckDB-only detail.
store.vector.clear() removes everything — chunks, registry, and all derived
rows. Namespace, domain, and source registries survive: they describe where
content comes from, not the content itself. (Store does not wrap clear() on
the facade; call it on store.vector directly.)
Index schema version
Indexes are stamped with chonk.storage.SCHEMA_VERSION. Opening an index
written by an incompatible version raises SchemaVersionError — chunk IDs and
cache keys are derived values with no in-place migration, so a stale index must
be rebuilt from source. Verification runs before any DDL, so a rejected index
is left untouched.
Store.add_document() parameters
store.add_document(
chunks: list[DocumentChunk],
embeddings, # np.ndarray shape (n, dim)
namespace: str | None = None, # optional partition key
)
namespace partitions the index so different corpora can share a single DuckDB file and be searched independently. Pass the same value to store.search(namespaces=[...]) to restrict retrieval to that partition. None means no namespace — all chunks are visible to namespace-unrestricted searches.
Store.search() parameters
store.search(
query_embedding, # np.ndarray shape (dim,)
limit: int = 5,
query_text: str | None = None, # enables hybrid FTS + vector (DuckDB only)
namespaces: list[str] | None = None, # restrict to named partitions
chunk_types: list[str] | None = None # restrict to specific chunk types
) -> list[tuple[str, float, DocumentChunk]]
Returns a list of (chunk_id, score, DocumentChunk) tuples. Each returned
DocumentChunk includes source_detail (page, slide, paragraph, line numbers,
etc.) when the original document was annotated — source_detail is persisted
and round-trips through search on both backends.
query_text hybrid BM25 reranking is supported by DuckDBVectorBackend only;
PgVectorBackend accepts the parameter for API compatibility but performs
pure vector search.
Store constructor
Store(
db_path: str | Path = ":memory:",
embedding_dim: int = 1024,
read_only: bool = False,
)
read_only=True opens the DuckDB file without acquiring a write lock, allowing multiple concurrent readers against the same index. Relational entity features are unavailable in read-only mode.
chonk.storage exports
| Name | Description |
|---|---|
Store |
High-level facade: vector search + relational entity storage over a single DuckDB file |
DuckDBVectorBackend |
Low-level DuckDB VSS (HNSW) + FTS (BM25) backend implementing VectorBackend |
PgVectorBackend |
PostgreSQL + pgvector HNSW cosine backend implementing VectorBackend |
RelationalStore |
SQLAlchemy-based relational store for entity and chunk-entity link tables |
VectorBackend |
typing.Protocol — implement to plug in an alternative vector store |
Additional top-level exports
VersionedRef[T]
Thread-safe versioned holder for any object. Supports immediate swap (ref.update(value)) and stage-then-promote (ref.stage(value) / ref.promote()) patterns. The version counter increments on every update or promote call.
from chonk import VersionedRef
ref: VersionedRef[list] = VersionedRef(initial=[])
ref.update(new_list) # atomic swap, version++
EnhancedSearch
4-dimensional cohort assembler for retrieval. Assembles candidates across vector similarity (seed), structural adjacency (next/prev/parent chunks), entity adjacency (EntityIndex), and cluster adjacency (ClusterMap). Each dimension can be independently enabled or disabled.
from chonk import EnhancedSearch
search = EnhancedSearch(store, entity_index=ei, cluster_map=cm)
results: list[ScoredChunk] = search.search(query_embedding, k=5)
Progressive adoption. Vector-only is a valid production configuration — pass store with no additional arguments and every other dimension is disabled. Each layer is an upgrade you earn by building the prerequisite index:
| Layer | Prerequisite | What you gain |
|---|---|---|
| Vector seed | Store with embeddings |
Baseline semantic retrieval |
| Structural adjacency | Chunking with section/parent metadata | Adjacent context pulled automatically |
| Entity + cluster | EntityIndex + ClusterMap (custom vocab recommended) |
Completeness gate; entity-linked expansion |
| Graph communities | CommunityIndex (Leiden, zero LLM) |
Cluster expansion guided by co-occurrence graph |
| Global summaries | CommunitySummarizer (opt-in LLM) |
global mode: query answered from community summaries |
Each layer degrades gracefully to the one below it when its index is absent. You do not need to build all layers before going to production.
Generation layer
| Name | Description |
|---|---|
AnswerContext |
Retrieval output (ranked ScoredChunk list, query, optional community context) packaged for prompt assembly |
PromptBuilder |
Assembles a prompt string from an AnswerContext |
Answer |
Structured answer returned by AnswerGenerator |
AnswerGenerator |
Sends the assembled prompt to an LLM and returns an Answer |
Graph layer
| Name | Description |
|---|---|
SVOTriple |
Subject-verb-object triple extracted from text |
SVOExtractor |
Extracts SVOTriple objects from chunk content |
RelationshipIndex |
In-memory index of SVO triples keyed by entity |
RelationshipIndexBuilder |
Builds a RelationshipIndex from chunks, optionally using an LLM |
LLMClient |
Thin protocol / adapter for LLM calls used by the graph and generation layers |
VERB_SET |
Default set of relation verbs used by SVOExtractor |
build-svo streaming progress
build-svo supports fine-grained per-chunk progress via --progress-out:
python demo/graphrag_bench.py build-svo \
--out-dir work \
--gen-provider together \
--gen-model "meta-llama/Llama-3.3-70B-Instruct-Turbo" \
--progress-out /tmp/svo_progress.jsonl
One JSON line is written per completed chunk — immediately as each extraction finishes, not in batches:
{
"done": 42,
"total": 1927,
"chunk_id": "novel_chapter_3:12",
"triples": [
{"subject_id": "Atticus_Finch", "verb": "has_role", "object_id": "lawyer",
"confidence": 0.95, "description": "Atticus Finch serves as a defense attorney."}
],
"descriptions": {"Atticus_Finch": "A principled lawyer in Maycomb, Alabama."},
"aliases": {"Atticus_Finch": ["Atticus", "Mr. Finch"]},
"rel_descriptions": {"Atticus_Finch|has_role|lawyer": "Atticus Finch serves as a defense attorney in the trial."}
}
Use - to write to stdout. A web service can tail the file (or read stdout) and forward each line as a Server-Sent Event:
# FastAPI / Starlette SSE example (web service wraps the CLI output)
from sse_starlette.sse import EventSourceResponse
import subprocess, json
async def svo_progress_stream():
proc = subprocess.Popen(
["python", "demo/graphrag_bench.py", "build-svo", "--progress-out", "-", ...],
stdout=subprocess.PIPE, text=True,
)
for line in proc.stdout:
event = json.loads(line)
yield {"event": "svo_chunk", "data": json.dumps(event)}
@app.get("/svo-progress")
async def svo_progress():
return EventSourceResponse(svo_progress_stream())
Community layer
| Name | Description |
|---|---|
CommunityIndex |
Index of Leiden communities over the entity co-occurrence graph |
CommunityIndexBuilder |
Builds a CommunityIndex from an EntityIndex and ClusterMap |
CommunitySummarizer |
Generates natural-language summaries of each community via an LLM |
Community detection (CommunityIndexBuilder + Leiden algorithm) runs at index time and makes zero LLM calls. The Leiden graph partition is pure graph math. In vector_first mode, cluster expansion uses that partition to pull co-occurring chunks — graph-guided retrieval at zero per-query LLM cost.
CommunitySummarizer is a separate, opt-in step that calls an LLM once per community to produce a natural-language summary. It is only needed for global retrieval mode, which answers queries from community summaries rather than raw chunks. Traditional GraphRAG pipelines make this summarization pass mandatory; here it is optional and gated behind an explicit call. If you never use global mode, CommunitySummarizer never runs.
NER / vocabulary layer
The problem with naive entity extraction
Generic NER models — spaCy, BERT-NER, cloud APIs — are trained on public corpora. They reliably find people and places in news articles. They are poor at the entities that actually matter in enterprise retrieval:
- Schema identifiers:
customerRiskScore,cpty_id,EFFECTIVE_DT— your internal column names appear in documents ("the customer risk score is reviewed quarterly") but no generic model was trained on your data dictionary - Known entities from your databases: "Acme Corp" is in your CRM; spaCy may or
may not tag it as
ORG; it will never tag it ascustomerwith the right canonical ID to join back to your database - Ambiguous short names: "Mercury" is a planet, a car brand, a record label, and possibly your internal code name for a project — spaCy cannot distinguish them without your context
- Domain vocabulary: drug names, ticker symbols, legal clause labels, internal project codes — statistical models generalise poorly to narrow domains
The result: NER links the wrong chunks to the wrong entities, or misses the link entirely, producing entity graphs that look plausible but silently fail on real queries.
How Chonk solves it: three-layer NER
NerPipeline runs three matcher layers in order and merges the results. Both
vocabulary layers suppress overlapping spaCy hits — your known entities take
precedence over statistical guesses.
| Layer | What it matches | Source |
|---|---|---|
| Schema vocab | Table names, column names, API field identifiers | Your DDL, TableMeta, load_schema() chunks |
| Data vocab | Actual entity values: customer names, employee names, counterparties, tickers | Live DB queries or plain lists |
| spaCy NER | Generic statistical NER for entities not covered above | Pre-trained spaCy model |
Schema identifiers are normalised before matching: customerRiskScore,
customer_risk_score, and CUSTOMER_RISK_SCORE all match the prose form
"customer risk score". This surfaces structural connections — a document that says
"the customer risk score" and a table column called CUSTOMER_RISK_SCORE are linked
through the same entity, without any manual synonym list.
Data values are matched verbatim (case-insensitive). "Acme Corp" matches "Acme Corp"
in text, and the match carries the entity type (customer) and a stable canonical ID
that joins back to your database.
Usage
from chonk.ner import NerPipeline, SpacyLabel
pipeline = NerPipeline(
db_enrich=True, # match schema/column/API identifier terms
spacy_entities=True, # run spaCy NER
spacy_entity_types=[SpacyLabel.ORG, SpacyLabel.PERSON, SpacyLabel.GPE],
)
# --- Schema vocab: identifier names (normalised) ---
pipeline.add_tables(table_meta_list) # TableMeta objects
pipeline.add_sql(open("schema.sql").read()) # raw DDL
pipeline.add_chunks(loader.load_schema(tables)) # chunks from load_schema()
# --- Data vocab: real values from your DB (reuse existing connection) ---
pipeline.add_from_db(
engine, # SQLAlchemy Engine, Connection, or URL string
queries={
"customer": "SELECT name FROM customers WHERE active = true",
"employee": "SELECT full_name FROM employees",
"counterparty": "SELECT name FROM counterparties",
},
row_limit=50_000, # max rows per query (default 10 000)
)
# --- Data vocab: plain list (CRM export, config file, spreadsheet, etc.) ---
pipeline.add_entities(["Acme Corp", "Globex"], entity_type="customer")
# Run against document chunks
matches = pipeline.match(chunk.content)
# Or index a whole batch at once
pipeline.run_on_chunks(chunks, entity_index)
add_from_db rules:
- Each SQL query must return exactly one column —
ValueErroris raised otherwise. - Nulls are dropped and values are deduplicated before being added.
- Data values are matched verbatim (case-insensitive) — no camelCase splitting. "Acme Corp" matches "Acme Corp", not "acme corp".
Schema identifiers are normalised: firstName, first_name, and FIRST_NAME
all match the prose form "first name", surfacing connections between your
relational data model and the documents that reference it.
Primitives (for custom scenarios)
| Name | Description |
|---|---|
NerPipeline |
Three-layer NER pipeline: schema vocab + data vocab + spaCy |
SchemaVocabBuilder |
Builds matchers from TableMeta, SQL DDL, load_schema() chunks, DB queries, or plain lists |
VocabularyMatcher |
Rule-based entity matcher using a user-supplied vocabulary |
EntityIndex |
Index mapping entity IDs to the chunks they appear in |
SpacyMatcher |
spaCy-backed NER matcher — entity_types restricts to a label subset |
SchemaMatcher |
Matches schema-derived terms (table/column names) against chunk text |
normalize_schema_term |
"firstName" / "first_name" / "FIRST_NAME" → "first name" |
merge_matches |
Merge vocab and spaCy hits; vocab wins on span overlap |
CooccurrenceMatrix |
Tracks entity co-occurrence counts across chunks |
ClusterMap |
Maps entity IDs to cluster IDs after cluster_entities() |
SpacyLabel |
Enum of the 18 standard spaCy English entity labels |
ALL_SPACY_LABELS |
Default label list used when entity_types is None |
Context graph
The context graph encodes weighted relationships between entities based on three additive signals computed at index time:
| Signal | Max contribution | Description |
|---|---|---|
| SVO | 1.0 | Entity pair appears together as subject + object in an SVO triple |
| Co-occurrence | 0.8 | Entities appear in the same chunk (capped at 0.8 regardless of count) |
| Cluster | 0.4 | Jaccard similarity of the Leiden cluster sets each entity belongs to |
Weights are normalised to [0, 1] and edges below min_weight are dropped. Both directions are stored (A→B and B→A) with identical weights.
The graph is built at index time and consumed lazily at query time: if the graph hasn't been built for a namespace, get_context_graph() returns [] and logs a DEBUG message — retrieval degrades gracefully rather than raising an error.
Building the context graph
Via build_ner (recommended when you're not using SVO triples):
from chonk.ner import build_ner
n = build_ner(store, build_context_graph=True)
# SVO signal will be 0 if svo_triples is empty; co-occurrence and cluster signals still apply
Via EntityGraphPipeline (when you are using SVO triples):
from chonk.graph import EntityGraphPipeline, SVOExtractor
extractor = SVOExtractor(my_llm)
pipeline = EntityGraphPipeline(extractor, embed_model=st_model)
with Store("index.duckdb") as store:
stats = pipeline.build(store, build_context_graph=True)
Via Store.build_context_graph() (standalone, after NER/SVO are already built):
with Store("index.duckdb") as store:
# Single namespace
stats = store.build_context_graph(namespace="global", min_weight=0.1)
print(f"{stats.entity_count} entities, {stats.edge_count} edges")
# All namespaces at once
all_stats = store.build_context_graph(namespace=None)
for ns, s in all_stats.items():
print(f"[{ns}] {s.entity_count} entities, {s.edge_count} edges")
Parameters:
| Parameter | Default | Description |
|---|---|---|
namespace |
"global" |
Namespace to build for, or None for all namespaces |
min_weight |
0.1 |
Drop edges below this normalised weight |
force |
False |
Rebuild even if the cache fingerprint is current |
algorithm |
"agglomerative" |
Clustering algorithm: "agglomerative", "dbscan", or "leiden" |
min_chunks |
10 |
Minimum chunks required before cluster computation runs |
The build is idempotent and cached: the fingerprint is derived from the sorted chunk IDs and SVO triple count, so repeated calls with the same data are no-ops unless force=True.
Consuming the context graph
with Store("index.duckdb") as store:
edges = store.get_context_graph("customer_id", namespace="global", min_weight=0.2)
for edge in edges:
print(f"{edge.target_entity_id} weight={edge.weight:.3f} "
f"(svo={edge.svo_signal:.2f}, cooccur={edge.cooccur_signal:.2f}, cluster={edge.cluster_signal:.2f})")
get_context_graph returns a list of ContextEdge objects sorted by descending weight.
Integration with EnhancedSearch
Pass context_graph_expansion=True to EnhancedSearch to use context graph edges during entity ref-expansion. When an entity from the query cannot be found in the retrieved chunk pool, the search walks the context graph to find related entities and pulls their chunks:
from chonk.search import EnhancedSearch
search = EnhancedSearch(
store,
entity_ref_expansion=True,
context_graph_expansion=True, # walk context graph for missing entities
context_graph_min_weight=0.2, # minimum edge weight to follow
context_graph_top_k=5, # max edges to follow per missing entity
)
results = search.search(query_embedding, query_text="...", top_k=10)
Context graph expansion runs at priority 0.6 (between entity-adjacent at 0.7 and cluster-adjacent at 0.5). Chunks added via graph expansion carry provenance="context_graph_expansion" in the retrieval trace.
Namespace behaviour
The context graph is per-namespace. Entities and co-occurrences are scoped to the namespace they were ingested under. When you query across multiple namespaces, each namespace's graph is used independently for its own entities — there is no cross-namespace edge storage. This is intentional: entities are the natural cross-domain connective tissue; the graph weights intra-namespace co-occurrence density.
Primitives
| Name | Description |
|---|---|
ContextEdge |
Single directed edge with source_entity_id, target_entity_id, weight, svo_signal, cooccur_signal, cluster_signal |
ContextGraphStats |
Build summary: entity_count, edge_count, chunk_count |
build_context_graph_edges |
Build edges for a single namespace |
build_context_graph_all_namespaces |
Build edges for every namespace present in chunk_entities |
build_chunk_clusters |
Build co-occurrence chunk clusters (used internally by build_context_graph_edges) |
get_context_graph_edges |
Retrieve edges for a single entity (returns [] if graph not built) |
MCP server
mcp_chonk_server.py is the integration point for MCP-compatible planners. Any
planner that speaks the Model Context Protocol —
Claude, Cursor, VS Code Copilot — connects to Chonk as its retrieval tool through this
server. The planner issues sub-queries; the server handles embedding, search, and
evidence assembly; the planner receives ranked DocumentChunk results annotated with
their source domain.
pip install "chonk-rag[storage]" mcp
Transports
Two transports are supported via CHONK_TRANSPORT:
| Value | When to use |
|---|---|
stdio (default) |
Local / developer use. The MCP host manages the subprocess. Each user runs their own server process against a locally accessible DuckDB file. |
http |
Enterprise / centralised deployment. One server process shared by all users. Users connect by URL — no Python install, no file access required on the user side. |
stdio — local use
export CHONK_DB_PATH=/data/index.duckdb
export CHONK_EMBEDDING_DIM=1024 # default if omitted
python mcp_chonk_server.py
Claude Desktop claude_desktop_config.json:
{
"mcpServers": {
"chonk": {
"command": "python",
"args": ["/path/to/mcp_chonk_server.py"],
"env": {
"CHONK_DB_PATH": "/data/index.duckdb",
"CHONK_EMBEDDING_DIM": "1024"
}
}
}
}
http — centralised enterprise deployment
The enterprise team builds and maintains the index. End users connect to the server by URL with a shared API key — no local Python environment needed.
export CHONK_TRANSPORT=http
export CHONK_DB_PATH=/data/index.duckdb
export CHONK_API_KEY=your-secret-key # omit to disable auth (not recommended)
export CHONK_HOST=0.0.0.0 # default
export CHONK_PORT=8000 # default
python mcp_chonk_server.py
# → Uvicorn running on http://0.0.0.0:8000
Claude Desktop config for end users (no local server process):
{
"mcpServers": {
"chonk": {
"url": "http://chonk.internal:8000/mcp",
"headers": {"Authorization": "Bearer your-secret-key"}
}
}
}
All requests must carry Authorization: Bearer <key> when CHONK_API_KEY is set.
Requests with a missing or wrong key are rejected with HTTP 401 before any MCP
session is established.
Multiple named DBs
CHONK_DB_CONFIG takes precedence over CHONK_DB_PATH and works with both
transports. Each named DB is independently searchable; search_chunks merges
results across all stores when no db parameter is supplied.
export CHONK_DB_CONFIG='{
"main": {"path": "/data/main.duckdb"},
"archive": {"path": "/data/archive.duckdb", "embedding_dim": 768}
}'
Tools
| Tool | Parameters | Description |
|---|---|---|
search_chunks |
query_embedding (required), query_text, limit, db, namespaces, chunk_types |
Hybrid vector + BM25 search. Omit db to search all stores and merge by score. |
get_chunk |
chunk_id (required), db, include_neighbors, neighbor_radius |
Fetch a chunk by ID, optionally with adjacent chunks from the same document. |
expand_chunk_graph |
chunk_id (required), db |
Stub — wire to your EntityIndex / RelationshipIndex to expand a chunk into entity and relation overlays. |
Configuration
Chonk supports three configuration levels:
- Library API — pass constructor arguments directly to
Store,EnhancedSearch,DocumentLoader, etc. No files, no global state. The right choice for application code. - TOML config file — used by
demo/graphrag_bench.py. Load with--config path/to/file.toml. Configs may chain withextends = "parent.toml". CLI flags override TOML values; TOML values override hardcoded defaults. - CLI flags — override any TOML value when passed explicitly.
Minimal library example — index then query:
import numpy as np
from chonk import Store, EnhancedSearch
from chonk.ner import EntityIndex, SpacyMatcher
spacy = SpacyMatcher(model="en_core_web_sm", strip_numeric=True)
query_ner_fn = lambda text: [m.display_name for m in spacy.match(text)]
with Store("my.duckdb", embedding_dim=1024, read_only=True) as store:
search = EnhancedSearch(
store,
entity_index=entity_index, # built by build-ner or manually
query_ner_fn=query_ner_fn,
lane_entity_min_sim=0.45, # drop weak entity-linked candidates
entity_ref_expansion=True, # gap-fill if query entities are missing
)
results = search.search(query_vec, k=10, query_text="...", mode="vector_first")
For the full reference — index feature flags (build-ner, build-svo, build-community), all EnhancedSearch constructor parameters, retrieval modes, generation variants (--sr, --srr), and TOML schema — see docs/configuration.md.
Benchmark runner
demo/graphrag_bench.py runs the full GraphRAG retrieval benchmark against a stratified question corpus.
Additional requirements:
# S3-compatible storage (Cloudflare R2 used in this project)
pip install "chonk-rag[s3]" # boto3
# rclone — sync large result files (checkpoints, run DBs, embeddings) to/from R2
brew install rclone
# Configure R2 remote named "chonk":
# rclone config create chonk s3 provider Cloudflare \
# access_key_id <key> secret_access_key <secret> \
# endpoint https://<account>.r2.cloudflarestorage.com
Large files (work/results/*.jsonl, work/data/runs/*.duckdb, work/data/*.npy/npz) are not tracked in git. Sync them with:
rclone sync work/results r2:chonk/results
rclone sync work/data r2:chonk/data
HARE-Bench
work/fang2026/ contains HARE-Bench — an enterprise benchmark designed around
planner sub-queries against a heterogeneous corpus. The corpus spans four public
document types that share entity names but use incompatible terminology: SEC filings,
CVE records, Federal Register notices, and patents. Queries are decomposed into
atomic sub-queries matching the vocabulary of a specific document type, then evaluated
across all four simultaneously to measure cross-domain retrieval accuracy.
See work/fang2026/benchmark-design.md for the
full benchmark design, corpus construction methodology, and evaluation protocol.
Replication notes
The benchmark spec constrains only the generator model (gpt-4o-mini) and judge model (gpt-4o-mini). All other pipeline choices are implementation-defined and undisclosed by published entrants, making exact score replication impossible. Known gaps for MS-GraphRAG specifically:
| Implementation detail | MS-GraphRAG disclosure | This project |
|---|---|---|
| Knowledge graph extraction LLM | Not disclosed | Configurable; Qwen2.5-72B or gpt-4o-mini used in practice |
| Chunking strategy | Not disclosed | Structural chunking (section-aware, 1100–2200 chars) |
| Entity resolution / NER | Not disclosed | spaCy en_core_web_sm + domain vocabulary |
| Community detection parameters | Not disclosed | Leiden algorithm, default resolution |
| Community summary LLM | Not disclosed | Configurable; same as extraction LLM |
Scores should be interpreted as measuring the same benchmark task under comparable but not identical conditions.
Demos
# Synthetic multi-section docs (ops reports, product catalogs, incident logs)
python demo/contextual_vs_naive.py
# Real SEC EDGAR 10-K filings (AAPL, MSFT, AMZN, CRM) — requires internet
python demo/edgar_demo.py
# Real ClinicalTrials.gov Phase 2/3 oncology protocols — requires internet
python demo/clinicaltrials_demo.py
# Python standard library documentation — requires internet
python demo/python_docs_demo.py
Ethics & sourcing
- Sustainably harvested tokens — no embeddings computed, stored, or billed without your consent
- Free-range paragraphs — chunks never split mid-sentence against their will
- Cage-free section breadcrumbs — every chunk knows where it came from
- Conflict-free text extraction — no third-party cloud APIs consulted without consent
- Non-GMO transport layer — no monkey-patching of built-ins
- Fair trade — MIT licensed, attribution appreciated
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file chonk_rag-0.2.4.tar.gz.
File metadata
- Download URL: chonk_rag-0.2.4.tar.gz
- Upload date:
- Size: 5.3 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b8a5b2490ed7aec4079bb8bceeb2a7fc636d5c43c9f19d5a73e78deaa2fcdc4f
|
|
| MD5 |
467afb6f2440109c9ac399416060845c
|
|
| BLAKE2b-256 |
8322997f4150f406657bda27f10cd7f1c2692a6a100935abfaaf11ef4417ceac
|
File details
Details for the file chonk_rag-0.2.4-py3-none-any.whl.
File metadata
- Download URL: chonk_rag-0.2.4-py3-none-any.whl
- Upload date:
- Size: 367.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6f2c9099a760175cc7eaed349481ae7aa1647e07650f88f284de778ba8191e97
|
|
| MD5 |
88598762cee018b0b8d3130618f62d28
|
|
| BLAKE2b-256 |
f11bd4ddadfcd4475bf1babf4f663833eecc42c15c18247a129747c5b99b41b6
|