<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>CocoIndex Blog</title><description>Notes from the CocoIndex team — incremental data infrastructure, Rust internals, and production stories.</description><link>https://cocoindex.io/</link><language>en-us</language><item><title>CocoIndex Changelog 1.0.8 - 1.0.16</title><link>https://cocoindex.io/blogs/changelog-108-1016/</link><guid isPermaLink="true">https://cocoindex.io/blogs/changelog-108-1016/</guid><description>Changelog for CocoIndex 1.0.8-1.0.16: persistent per-component state, LiveMap, rate limiting, batched target writes, BigQuery and Snowflake connectors.</description><pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is the second changelog since the v1.0 launch, covering nine releases (1.0.8–1.0.16). The focus shifts from initial hardening into &lt;strong&gt;new engine primitives for stateful, production-grade pipelines&lt;/strong&gt;, a major expansion of &lt;strong&gt;cloud and enterprise connectors&lt;/strong&gt;, and continued depth on &lt;strong&gt;agent ergonomics&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;CocoIndex builds fresh, structured context for AI from sources such as PDFs, codebases, emails, screenshots, and meeting notes, and keeps it up to date with an &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;incremental engine&lt;/a&gt;. The source is on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Why this release cycle matters&lt;/h2&gt;
&lt;p&gt;For developers building retrieval, extraction, and knowledge-graph pipelines, the most significant changes are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Persistent per-component state.&lt;/strong&gt; &lt;code&gt;coco.use_state()&lt;/code&gt; lets pipeline components carry state that survives across runs, opening the door to accumulators, counters, and stateful deduplication without an external store.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-memory intermediate collections.&lt;/strong&gt; &lt;code&gt;LiveMap&lt;/code&gt; is a live, in-memory keyed collection that bridges a producing and a consuming stage of the same run: one side declares entries, the other consumes them through &lt;code&gt;mount_each&lt;/code&gt;, which grows and shrinks one live component per entry as the data changes — a producer/consumer hand-off that pure declarative pipelines can&apos;t express.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Batched target writes.&lt;/strong&gt; Row-level writes to the same target from different components can now be coalesced into one apply, collapsing N write transactions into one — a direct win for connectors like LanceDB and cloud warehouses where per-transaction cost dominates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Token-bucket rate limiting.&lt;/strong&gt; &lt;code&gt;RateLimiter&lt;/code&gt; is now a first-class engine primitive: a consistent, cooperative throttle shared across pipeline components, replacing hand-rolled sleep pacing at each call site with one fair, budgeted rate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enterprise cloud connectors.&lt;/strong&gt; BigQuery, Snowflake, and Azure Blob Storage land alongside sparse-vector Qdrant support and a new Valkey vector store connector, completing coverage of the major cloud data platforms.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Engine&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;coco.use_state()&lt;/code&gt;: persistent per-component state&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;coco.use_state()&lt;/code&gt; gives any pipeline component a persistent, typed state slot that survives across update cycles. The state is stored in the LMDB state store alongside other engine metadata, keyed by a &lt;code&gt;StableKey&lt;/code&gt; (not just a plain string, as of &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2085&quot;&gt;&lt;strong&gt;#2085&lt;/strong&gt;&lt;/a&gt;). A &lt;code&gt;type_hint&lt;/code&gt; parameter added in v1.0.15 enables typed deserialization so components can round-trip structured state objects cleanly.&lt;/p&gt;
&lt;p&gt;Why it matters: the incremental engine already knows &lt;em&gt;what&lt;/em&gt; changed between runs. &lt;code&gt;use_state&lt;/code&gt; lets components know &lt;em&gt;what they have already done&lt;/em&gt;. This enables accumulators (running totals, seen-entity sets), watermark trackers, and stateful deduplication, patterns that previously required an external Redis or Postgres sidecar. The net effect is that a wider class of stateful logic can now live inside the pipeline declaration, subject to the same incremental reconciliation semantics as the rest of CocoIndex.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;LiveMap&lt;/code&gt;: in-memory intermediate collections&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;LiveMap&lt;/code&gt; is a new in-memory keyed collection that bridges a producing and a consuming half of the same live run. Producing components &lt;em&gt;declare&lt;/em&gt; entries into it as target states (&lt;code&gt;LiveMap.declare_entry&lt;/code&gt;), and the consuming side reads that same in-memory &lt;code&gt;dict&lt;/code&gt; as a live source through &lt;code&gt;mount_each&lt;/code&gt;. Where &lt;code&gt;coco.stats_group&lt;/code&gt; provides observability over a data slice, &lt;code&gt;LiveMap&lt;/code&gt; provides &lt;em&gt;computation&lt;/em&gt; over one: a mutable intermediate collection the pipeline both writes and reads, decoupled through the shared map.&lt;/p&gt;
&lt;p&gt;Because &lt;code&gt;LiveComponent&lt;/code&gt; classes can now be used inside &lt;code&gt;mount_each()&lt;/code&gt;, the consuming side is a dynamic live component tree — one live component per entry, mounted and dropped as producers add and stop declaring keys.&lt;/p&gt;
&lt;h3&gt;Token-bucket &lt;code&gt;RateLimiter&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;RateLimiter&lt;/code&gt; is now a first-class engine primitive implementing a token-bucket algorithm. It provides a cooperative throttle shared across pipeline components: any component that acquires a token from the same &lt;code&gt;RateLimiter&lt;/code&gt; instance participates in the same budget, and concurrent callers are served FIFO so none starve. This replaces hand-rolled sleep pacing at each call site with one budgeted, coordinated rate.&lt;/p&gt;
&lt;p&gt;Combined with the existing retry-on-429 (HTTP rate limit) behavior added in earlier cycles, &lt;code&gt;RateLimiter&lt;/code&gt; gives pipelines deterministic, budget-aware flow control rather than reactive backpressure alone.&lt;/p&gt;
&lt;h3&gt;Preview mode for update actions&lt;/h3&gt;
&lt;p&gt;Update actions can now be previewed before they are applied. A pipeline run can describe the adds, reprocesses, and deletes it &lt;em&gt;would&lt;/em&gt; perform, without writing to any target, enabling dry-run validation and change auditing before committing to a production store.&lt;/p&gt;
&lt;h3&gt;Batched target writes across components&lt;/h3&gt;
&lt;p&gt;Row-level writes to the same target from different components can now be coalesced into a single batched apply. The common shape is &lt;code&gt;mount_each(..., table_target)&lt;/code&gt;, where many child components each declare one row into the same target — but the coalescing is keyed by the target &lt;em&gt;sink&lt;/em&gt;, not by any sibling relationship, so any components whose writes land on the same sink concurrently are batched together. Previously each component submitted its leaf actions directly to the sink after precommit, so the connector saw a stream of single-row calls even though its API already accepts a batch. In practice a 50-row ingest into LanceDB produced roughly 50 &lt;code&gt;merge_insert&lt;/code&gt; commits — and 50 table versions — instead of one (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/issues/2219&quot;&gt;&lt;strong&gt;#2219&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;An app-scoped batcher, keyed by the target sink, merges concurrent commits that hit the same sink before the connector&apos;s apply call (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2221&quot;&gt;&lt;strong&gt;#2221&lt;/strong&gt;&lt;/a&gt;). It reuses CocoIndex&apos;s existing &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/rust/utils/src/batching.rs&quot;&gt;&lt;code&gt;Batcher&lt;/code&gt;&lt;/a&gt; primitive rather than a bespoke queue, so leaf-target batching shares the same path as the engine&apos;s other batched work and adds no debounce latency to a lone write. Batching preserves order, each output mapping back to its input; container targets, which return per-action child handlers, stay on the direct apply path, while the batched path handles leaf writes. Sink lifetime drives cleanup: a keeper owns the batcher one-to-one and drops its registry entry the moment the sink goes away, so a pipeline with an unbounded number of sink functions cannot leak batcher entries.&lt;/p&gt;
&lt;p&gt;Why it matters: for connectors with real per-operation cost, such as LanceDB and cloud warehouses, write cost is dominated by the number of write transactions — round-trips and commits — not by row count. Coalescing N single-row applies into one batched apply collapses N write transactions into one, cutting that overhead directly. For a store like LanceDB it also collapses N table versions into one, which keeps version history and compaction manageable under heavy fan-out.&lt;/p&gt;
&lt;h3&gt;Engine performance improvements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Prefetch fn-memos and user-states in one read transaction&lt;/strong&gt;: the engine now batches all LMDB reads for function memos and user states into a single read transaction per cycle, replacing N individual reads (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2076&quot;&gt;&lt;strong&gt;#2076&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimize serialization for UserStateCache&lt;/strong&gt;: reduces round-trip cost for state reads during hot-path execution (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2127&quot;&gt;&lt;strong&gt;#2127&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LMDB map auto-resize&lt;/strong&gt;: the state store now automatically resizes the LMDB map when it fills, eliminating the manual &lt;code&gt;LMDB_MAP_SIZE&lt;/code&gt; tuning that previously required operator intervention (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2231&quot;&gt;&lt;strong&gt;#2231&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LMDB map size rounded to page size&lt;/strong&gt;: map sizes are now aligned to the OS page boundary, preventing subtle allocation failures on some Linux kernels (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2109&quot;&gt;&lt;strong&gt;#2109&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Other engine fixes&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;coco.fn&lt;/code&gt; callable outside a component context&lt;/strong&gt;: &lt;code&gt;coco.fn&lt;/code&gt;-decorated functions can now be called directly in scripts and tests without being inside a mounted pipeline component (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2101&quot;&gt;&lt;strong&gt;#2101&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;!&lt;/code&gt;-prefixed negation in &lt;code&gt;excluded_patterns&lt;/code&gt;&lt;/strong&gt;: source file filters now support &lt;code&gt;!pattern&lt;/code&gt; to explicitly un-exclude a path that matched an earlier pattern, following the &lt;code&gt;.gitignore&lt;/code&gt;-style rule ordering (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1901&quot;&gt;&lt;strong&gt;#1901&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retry transient LiteLLM embedding errors&lt;/strong&gt;: transient failures (network blips, upstream 5xx) during embedding calls are retried automatically (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2090&quot;&gt;&lt;strong&gt;#2090&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reject rows with missing or null primary keys&lt;/strong&gt;: the engine now fails fast rather than silently writing corrupt rows (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2239&quot;&gt;&lt;strong&gt;#2239&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Connectors&lt;/h2&gt;
&lt;h3&gt;Target: Snowflake (v1.0.15)&lt;/h3&gt;
&lt;p&gt;A native Snowflake target connector lands, bringing CocoIndex&apos;s declare-target-state model to one of the most widely deployed cloud data warehouses. Snowflake targets participate in the same incremental reconciliation as all other CocoIndex targets: rows for deleted sources are removed automatically, and only changed rows are upserted.&lt;/p&gt;
&lt;h3&gt;Target: BigQuery (v1.0.16)&lt;/h3&gt;
&lt;p&gt;A Google BigQuery target connector was added alongside the Snowflake connector in the same cycle. For organizations already running Google Cloud infrastructure, this completes cloud data warehouse coverage across the major platforms.&lt;/p&gt;
&lt;h3&gt;Source: Azure Blob Storage (v1.0.16)&lt;/h3&gt;
&lt;p&gt;An Azure Blob Storage source connector mirrors the OCI and S3 source APIs. For teams on Azure infrastructure, this enables the same live-mode, incremental bucket-watching behavior available to AWS and OCI users.&lt;/p&gt;
&lt;h3&gt;Target: Valkey vector store (v1.0.12)&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://valkey.io&quot;&gt;Valkey&lt;/a&gt;, the open-source Redis fork under Linux Foundation governance, gains a target connector with vector store support. For organizations that have standardized on Valkey over Redis, this provides a fully supported vector index target without requiring a separate vector database.&lt;/p&gt;
&lt;h3&gt;Qdrant: native sparse vector support (v1.0.16)&lt;/h3&gt;
&lt;p&gt;The Qdrant connector was upgraded with native sparse vector support and eager point-ID validation. Sparse vectors are essential for hybrid search pipelines that combine dense (semantic) and sparse (BM25/TF-IDF) representations, making CocoIndex&apos;s Qdrant integration first-class for hybrid retrieval.&lt;/p&gt;
&lt;h3&gt;LanceDB: vector and FTS index support (v1.0.11)&lt;/h3&gt;
&lt;p&gt;LanceDB gained explicit vector and full-text search (FTS) index management. The &lt;code&gt;drop_index&lt;/code&gt; operation was added alongside a dependency bump to LanceDB 0.33.0 and PyArrow 23.0.0. Nullable schema-only row rewrites (a correctness bug that appeared on partial schema changes) were fixed in both the Python and Rust layers. In v1.0.15, LanceDB table maintenance was tied to LanceDB&apos;s own stats API, making compaction scheduling driven by actual table metrics rather than a fixed interval.&lt;/p&gt;
&lt;h3&gt;zvec: FTS field support (v1.0.14)&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;zvec&lt;/code&gt; target connector gained full-text search field support, allowing pipelines to write both vector and FTS fields to zvec in a single target export.&lt;/p&gt;
&lt;h3&gt;OCI: skip startup scan on unchanged logic (v1.0.11)&lt;/h3&gt;
&lt;p&gt;The OCI Object Storage source now detects when pipeline logic is unchanged since the last run and skips the expensive full-bucket startup scan, going directly to live-stream event processing. For large buckets, this can reduce cold-start time from minutes to seconds.&lt;/p&gt;
&lt;h3&gt;Local file system: periodic rescan in live mode (v1.0.15)&lt;/h3&gt;
&lt;p&gt;The local file system source in live mode now periodically rescans and recreates the watcher if it becomes stale. OS file-system watchers (&lt;code&gt;inotify&lt;/code&gt;, &lt;code&gt;kqueue&lt;/code&gt;) have known limitations under high churn, network mounts, and certain editors; periodic rescan provides a safety net that catches any events the watcher missed.&lt;/p&gt;
&lt;h3&gt;New connector summary&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Connector&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Snowflake&lt;/td&gt;
&lt;td&gt;Data warehouse target&lt;/td&gt;
&lt;td&gt;v1.0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigQuery&lt;/td&gt;
&lt;td&gt;Data warehouse target&lt;/td&gt;
&lt;td&gt;v1.0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Blob Storage&lt;/td&gt;
&lt;td&gt;Object store source&lt;/td&gt;
&lt;td&gt;v1.0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valkey&lt;/td&gt;
&lt;td&gt;Vector store target&lt;/td&gt;
&lt;td&gt;v1.0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qdrant sparse vectors&lt;/td&gt;
&lt;td&gt;Enhanced vector target&lt;/td&gt;
&lt;td&gt;v1.0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LanceDB vector + FTS indexes&lt;/td&gt;
&lt;td&gt;Enhanced vector target&lt;/td&gt;
&lt;td&gt;v1.0.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zvec FTS fields&lt;/td&gt;
&lt;td&gt;Enhanced target&lt;/td&gt;
&lt;td&gt;v1.0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Agent &amp;amp; developer experience&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;cocoindex show&lt;/code&gt;: full-fledged inspection tool (v1.0.12)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;cocoindex show&lt;/code&gt; was upgraded from a basic state dump into a full inspection tool for pipeline debugging. It provides structured, human-readable output of pipeline state, component configuration, and target schema: the production debugging workflow that previously required querying the LMDB or Postgres state store directly.&lt;/p&gt;
&lt;h3&gt;Python error messages&lt;/h3&gt;
&lt;p&gt;Runtime errors crossing the Rust-to-Python boundary now use &lt;code&gt;Display&lt;/code&gt; (human-readable) rather than &lt;code&gt;Debug&lt;/code&gt; (Rust internals) formatting, making error messages that surface in Python stack traces significantly more actionable.&lt;/p&gt;
&lt;h3&gt;Multiple GPU and fractional GPU allocation (v1.0.15)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;GPUPool&lt;/code&gt; was added to support multiple GPUs and fractional GPU allocations within a pipeline. For embedding-heavy pipelines running on multi-GPU infrastructure, this allows work to be distributed across available GPU resources without hand-partitioning the pipeline.&lt;/p&gt;
&lt;h2&gt;Example walkthroughs&lt;/h2&gt;
&lt;p&gt;v1.0.13 shipped a major documentation expansion: 17 new &lt;a href=&quot;https://cocoindex.io/docs/examples/&quot;&gt;example walkthroughs&lt;/a&gt; (10 full + 7 variants) covering the breadth of CocoIndex use cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/blogs/meeting-notes-graph/&quot;&gt;Meeting Notes → Knowledge Graph&lt;/a&gt;&lt;/strong&gt;, with &lt;a href=&quot;https://cocoindex.io/docs/examples/meeting-notes-to-knowledge-graph/&quot;&gt;Neo4j and FalkorDB variants&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/blogs/csv-to-kafka-live/&quot;&gt;CSV → Kafka&lt;/a&gt;&lt;/strong&gt; &lt;a href=&quot;https://cocoindex.io/docs/examples/csv-to-kafka/&quot;&gt;streaming pipeline&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/blogs/pdf-elements/&quot;&gt;Semantic Search over PDFs&lt;/a&gt;&lt;/strong&gt; with &lt;a href=&quot;https://cocoindex.io/docs/examples/pdf-embedding/&quot;&gt;mixed embedding models&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/blogs/live-image-search/&quot;&gt;Search Images by Text&lt;/a&gt;&lt;/strong&gt;, a &lt;a href=&quot;https://cocoindex.io/docs/examples/image-search/&quot;&gt;multimodal CLIP walkthrough&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/blogs/face-detection/&quot;&gt;Face Recognition&lt;/a&gt;&lt;/strong&gt;, &lt;a href=&quot;https://cocoindex.io/docs/examples/face-recognition/&quot;&gt;ported from v0 to v1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/blogs/knowledge-graph-for-docs/&quot;&gt;Docs to Knowledge&lt;/a&gt;&lt;/strong&gt; &lt;a href=&quot;https://cocoindex.io/docs/examples/docs-to-knowledge-graph/&quot;&gt;graph construction&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each walkthrough was structured for agent followability, with consistent heading patterns, copyable commands, and explicit prerequisites, so AI coding agents can execute them as step-by-step procedures.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Bug fixes and correctness&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 ETag encoded to bytes for content fingerprint&lt;/td&gt;
&lt;td&gt;Prevents false cache invalidation on binary ETags&lt;/td&gt;
&lt;td&gt;v1.0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StableKey::Bytes&lt;/code&gt; serde round-trip via msgpack&lt;/td&gt;
&lt;td&gt;Correct key serialization for binary primary keys&lt;/td&gt;
&lt;td&gt;v1.0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres: preserve data and NOT NULL on column type evolution&lt;/td&gt;
&lt;td&gt;Schema evolution no longer drops non-null constraints&lt;/td&gt;
&lt;td&gt;v1.0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres: schema-qualify DROP INDEX for vector indexes&lt;/td&gt;
&lt;td&gt;Avoids dropping wrong-schema indexes in multi-schema deployments&lt;/td&gt;
&lt;td&gt;v1.0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logic deps propagated across mount boundary&lt;/td&gt;
&lt;td&gt;Dependency tracking correct for nested mounted components&lt;/td&gt;
&lt;td&gt;v1.0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orphaned child-existence rows when Directory→Component&lt;/td&gt;
&lt;td&gt;Prevents stale rows when a directory node becomes a component node&lt;/td&gt;
&lt;td&gt;v1.0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LanceDB: escape single quotes in delete filter predicates&lt;/td&gt;
&lt;td&gt;Prevents filter injection on document paths containing apostrophes&lt;/td&gt;
&lt;td&gt;v1.0.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LanceDB: nullable schema-only row rewrites&lt;/td&gt;
&lt;td&gt;Correct behavior on partial schema updates&lt;/td&gt;
&lt;td&gt;v1.0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle batchers cleared to avoid stale accumulation&lt;/td&gt;
&lt;td&gt;Prevents write amplification on quiet pipelines&lt;/td&gt;
&lt;td&gt;v1.0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PEP 695 type aliases in numpy 2.5 NDArray&lt;/td&gt;
&lt;td&gt;Fixes &lt;code&gt;analyze_type_info&lt;/code&gt; crash on numpy 2.5+&lt;/td&gt;
&lt;td&gt;v1.0.13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;This cycle&apos;s theme is &lt;strong&gt;depth and breadth&lt;/strong&gt;: depth in the engine (stateful components, in-memory intermediate collections, GPU pools, performance batching) and breadth in connectors (completing cloud warehouse coverage with Snowflake, BigQuery, and Azure Blob, plus Valkey and enhanced Qdrant). The through-line is the incremental engine growing new primitives — persistent state, live intermediate collections, cooperative rate limiting, batched writes — that bring a wider class of stateful, production-grade pipelines inside a single declarative CocoIndex app.&lt;/p&gt;
&lt;p&gt;For the complete list of changes, see the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/releases&quot;&gt;GitHub releases&lt;/a&gt;. If CocoIndex is useful to you, consider starring the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;repository&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Thanks to the community&lt;/h2&gt;
&lt;p&gt;Thanks to everyone who shipped this cycle, core maintainers and community alike, including twelve first-time contributors.&lt;/p&gt;
&lt;h3&gt;@georgeh0&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/georgeh0&quot;&gt;@georgeh0&lt;/a&gt; for the bulk of this cycle&apos;s engine work: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2088&quot;&gt;&lt;code&gt;LiveMap&lt;/code&gt;&lt;/a&gt;, the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2057&quot;&gt;token-bucket &lt;code&gt;RateLimiter&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2091&quot;&gt;&lt;code&gt;LiveComponent&lt;/code&gt; support inside &lt;code&gt;mount_each&lt;/code&gt;&lt;/a&gt;, and the shared-parse &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2253&quot;&gt;code infrastructure&lt;/a&gt; behind cocoindex-code.&lt;/p&gt;
&lt;h3&gt;@badmonster0&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/badmonster0&quot;&gt;@badmonster0&lt;/a&gt; for the documentation updates.&lt;/p&gt;
&lt;h3&gt;@hakuuww&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/hakuuww&quot;&gt;@hakuuww&lt;/a&gt; for building &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2034&quot;&gt;&lt;code&gt;coco.use_state()&lt;/code&gt;&lt;/a&gt;, persistent per-component state, then following it with engine-side performance work: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2076&quot;&gt;prefetching fn-memos and user states in one read transaction&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2127&quot;&gt;minimizing UserStateCache serialization&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@sdhilip200&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/sdhilip200&quot;&gt;@sdhilip200&lt;/a&gt; for a prolific connector cycle: the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2046&quot;&gt;Snowflake&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2250&quot;&gt;BigQuery&lt;/a&gt; targets, the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2252&quot;&gt;Azure Blob source&lt;/a&gt;, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2045&quot;&gt;Mermaid diagram rendering in the docs&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@Sujit-1509&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Sujit-1509&quot;&gt;@Sujit-1509&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2115&quot;&gt;LanceDB vector and FTS indexing&lt;/a&gt;, plus correctness fixes: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2228&quot;&gt;preserving data and NOT NULL constraints during Postgres column-type evolution&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2212&quot;&gt;reconciling orphaned child-existence rows&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@prrao87&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prrao87&quot;&gt;@prrao87&lt;/a&gt; for deep LanceDB work: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2245&quot;&gt;tying table maintenance to LanceDB&apos;s own stats&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2132&quot;&gt;implementing &lt;code&gt;drop_index&lt;/code&gt;&lt;/a&gt;, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2235&quot;&gt;fixing nullable schema-only row rewrites&lt;/a&gt;, plus &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2221&quot;&gt;batching leaf target actions&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@lichuang&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/lichuang&quot;&gt;@lichuang&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2224&quot;&gt;&lt;code&gt;GPUPool&lt;/code&gt;&lt;/a&gt;, supporting multiple and fractional GPU allocations, and for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2210&quot;&gt;adding &lt;code&gt;type_hint&lt;/code&gt; to &lt;code&gt;use_state&lt;/code&gt;&lt;/a&gt; for typed state deserialization.&lt;/p&gt;
&lt;h3&gt;@Haleshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Haleshot&quot;&gt;@Haleshot&lt;/a&gt; for the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2092&quot;&gt;initial &lt;code&gt;zvec&lt;/code&gt; target integration&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2215&quot;&gt;adding FTS field support to it&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@nuthalapativarun&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/nuthalapativarun&quot;&gt;@nuthalapativarun&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2052&quot;&gt;switching runtime errors crossing into Python to &lt;code&gt;Display&lt;/code&gt; formatting&lt;/a&gt;, adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1952&quot;&gt;Qdrant&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2083&quot;&gt;local file system&lt;/a&gt; connector unit tests, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2081&quot;&gt;fixing broken documentation links&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@daric93&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/daric93&quot;&gt;@daric93&lt;/a&gt; for the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2027&quot;&gt;Valkey vector store target connector&lt;/a&gt;, a fully supported vector index target for teams standardized on Valkey.&lt;/p&gt;
&lt;h3&gt;@prithvi-moonshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prithvi-moonshot&quot;&gt;@prithvi-moonshot&lt;/a&gt; for upgrading &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1920&quot;&gt;&lt;code&gt;cocoindex show&lt;/code&gt; into a full-fledged inspection tool for debugging&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@MrAnayDongre&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/MrAnayDongre&quot;&gt;@MrAnayDongre&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1945&quot;&gt;preview mode for update actions&lt;/a&gt;, letting a run describe the adds, reprocesses, and deletes it would perform before touching a target.&lt;/p&gt;
&lt;h3&gt;@pyjuan91&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/pyjuan91&quot;&gt;@pyjuan91&lt;/a&gt; for making the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2116&quot;&gt;OCI source skip its full startup scan when pipeline logic is unchanged&lt;/a&gt;, cutting cold-start time on large buckets.&lt;/p&gt;
&lt;h3&gt;@tomz-alt&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/tomz-alt&quot;&gt;@tomz-alt&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2242&quot;&gt;native sparse vector support and eager point-ID validation in the Qdrant connector&lt;/a&gt;, making it first-class for hybrid retrieval.&lt;/p&gt;
&lt;h3&gt;@jordigilh&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/jordigilh&quot;&gt;@jordigilh&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2232&quot;&gt;adding periodic rescan and watcher recreation to the local file system source in live mode&lt;/a&gt;, a safety net for events an OS watcher misses.&lt;/p&gt;
&lt;h3&gt;@MaxRong&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/MaxRong&quot;&gt;@MaxRong&lt;/a&gt; for making the engine &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2239&quot;&gt;reject rows with missing or null primary keys&lt;/a&gt; instead of silently writing corrupt rows.&lt;/p&gt;
&lt;h3&gt;@slliland&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/slliland&quot;&gt;@slliland&lt;/a&gt; for making the state store &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2231&quot;&gt;automatically resize the LMDB map when it fills&lt;/a&gt;, removing the manual &lt;code&gt;LMDB_MAP_SIZE&lt;/code&gt; tuning operators used to need.&lt;/p&gt;
&lt;h3&gt;@SuperMarioYL&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/SuperMarioYL&quot;&gt;@SuperMarioYL&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1901&quot;&gt;&lt;code&gt;!&lt;/code&gt;-prefixed negation in &lt;code&gt;excluded_patterns&lt;/code&gt;&lt;/a&gt;, so a path matched by an earlier rule can be explicitly un-excluded.&lt;/p&gt;
&lt;h3&gt;@davidmyriel&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/davidmyriel&quot;&gt;@davidmyriel&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2063&quot;&gt;documenting Tigris alongside MinIO as a supported S3-compatible service&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@dashitongzhi&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/dashitongzhi&quot;&gt;@dashitongzhi&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2009&quot;&gt;improving the docs CI with proper triggers and build verification&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>connectors</category><category>performance</category><author>George He</author></item><item><title>Index Your Codebase for AI Agents with CocoIndex V1</title><link>https://cocoindex.io/blogs/index-codebase-v1/</link><guid isPermaLink="true">https://cocoindex.io/blogs/index-codebase-v1/</guid><description>Index a codebase for RAG and AI coding agents with CocoIndex V1 and Tree-sitter: language-aware chunking, embedding, and a live vector index in async Python.</description><pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In this blog, we&apos;ll build a live semantic index over a codebase with &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex V1&lt;/a&gt;. Point it at a repo, and you get a vector index you can search in natural language (&quot;where do we embed chunks?&quot;) that updates itself as you edit. It&apos;s the kind of fresh, low-latency code context a &lt;a href=&quot;https://cocoindex.io/docs/getting_started/ai_coding_agents/&quot;&gt;coding agent&lt;/a&gt; needs, and it&apos;s about 100 lines of plain async Python.&lt;/p&gt;
&lt;p&gt;CocoIndex has built-in support for codebase chunking, with native Tree-sitter parsing and live updates.&lt;/p&gt;
&lt;p&gt;The heavy lifting (&lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental processing&lt;/a&gt;, change tracking, managed targets) runs in a Rust engine underneath. With a source that watches for changes, like the local filesystem in &lt;code&gt;live&lt;/code&gt; mode, the index tracks your edits as they happen: save a file, and only the chunks that changed get re-embedded and re-upserted.&lt;/p&gt;
&lt;h2&gt;Use case&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Code context for agents: semantic context for Claude, Codex, OpenCode, Factory.&lt;/li&gt;
&lt;li&gt;Code search: natural-language and semantic search over your repo.&lt;/li&gt;
&lt;li&gt;Review &amp;amp; refactor agents: context for code review, security analysis, and large-scale refactoring.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why CocoIndex for codebase indexing&lt;/h2&gt;
&lt;p&gt;A codebase is hard to keep indexed well, and it exercises most of what CocoIndex was built for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chunks follow real code structure (functions, classes, blocks) instead of arbitrary line windows. Tree-sitter parsing is built in for every major language.&lt;/li&gt;
&lt;li&gt;Code changes constantly: every commit, save, and rebase mutates a handful of files. CocoIndex re-embeds only the chunks that changed, and with &lt;code&gt;live=True&lt;/code&gt; plus &lt;code&gt;cocoindex update -L&lt;/code&gt; it keeps watching and applies each edit as it happens.&lt;/li&gt;
&lt;li&gt;The engine is Rust. The run below indexes 622 files in 43 seconds cold; after that, a save costs one file.&lt;/li&gt;
&lt;li&gt;The pipeline is ordinary &lt;code&gt;async&lt;/code&gt; Python and your own types. Pick your embedding model, chunking strategy, and vector database; if your agent can write Python, it can extend this flow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;From a high level, these are the steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read code files from a local directory.&lt;/li&gt;
&lt;li&gt;Split each file into syntax-aware chunks with Tree-sitter, then embed every chunk.&lt;/li&gt;
&lt;li&gt;Store the chunks and their embeddings in Postgres (as &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/target_state/&quot;&gt;&lt;strong&gt;target states&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;With CocoIndex, you &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;declare the transformation logic&lt;/a&gt; with native Python, without worrying about handling updates.&lt;/p&gt;
&lt;p&gt;Think: &lt;strong&gt;target_state = transformation(source_state)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;When your codebase changes, or your processing logic changes (a different embedding model, a new chunk size), only the difference is reprocessed. The &lt;a href=&quot;#incremental-updates&quot;&gt;Incremental updates&lt;/a&gt; section walks through exactly what gets recomputed when.&lt;/p&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;In this example we store the indexed rows in Postgres with the &lt;a href=&quot;https://github.com/pgvector/pgvector&quot;&gt;pgvector&lt;/a&gt; extension. Your metadata and the vector search index both live there. CocoIndex supports &lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;many targets&lt;/a&gt;, so you can pick the one that works best for you.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;If you don&apos;t have Postgres running, use the Docker Compose file in the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;cocoindex repo&lt;/a&gt; to start one.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose -f dev/postgres.yaml up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Point CocoIndex at it&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export POSTGRES_URL=&quot;postgres://cocoindex:cocoindex@localhost/cocoindex&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Install CocoIndex and the dependencies&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U &quot;cocoindex[postgres,sentence_transformers]&quot; asyncpg pgvector numpy python-dotenv
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Define the App&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/app/&quot;&gt;Apps&lt;/a&gt; are the top-level runnable unit in CocoIndex. An app names your pipeline and binds a main function with its parameters.&lt;/p&gt;
&lt;h3&gt;Define the data and shared resources&lt;/h3&gt;
&lt;p&gt;This block sets up two things the rest of the code builds on. &lt;code&gt;CodeEmbedding&lt;/code&gt; defines one row of the output table: each chunk of code becomes one row, with its text, location, and embedding vector. &lt;code&gt;coco_lifespan&lt;/code&gt; provides the &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/context/&quot;&gt;shared resources&lt;/a&gt; every step needs, the Postgres connection pool and the embedding model, built once at startup instead of per file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import pathlib
from dataclasses import dataclass
from typing import AsyncIterator, Annotated

import asyncpg
from numpy.typing import NDArray

import cocoindex as coco
from cocoindex.connectors import localfs, postgres
from cocoindex.ops.text import RecursiveSplitter, detect_code_language
from cocoindex.ops.sentence_transformers import SentenceTransformerEmbedder
from cocoindex.resources.chunk import Chunk
from cocoindex.resources.file import FileLike, PatternFilePathMatcher
from cocoindex.resources.id import IdGenerator

DATABASE_URL = os.getenv(&quot;POSTGRES_URL&quot;, &quot;postgres://cocoindex:cocoindex@localhost/cocoindex&quot;)
TABLE_NAME = &quot;code_embeddings&quot;
EMBED_MODEL = &quot;sentence-transformers/all-MiniLM-L6-v2&quot;

PG_DB = coco.ContextKey[asyncpg.Pool](&quot;code_embedding_db&quot;)
EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder](&quot;embedder&quot;, detect_change=True)

_splitter = RecursiveSplitter()

@dataclass
class CodeEmbedding:
    id: int
    filename: str
    code: str
    embedding: Annotated[NDArray, EMBEDDER]
    start_line: int
    end_line: int

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -&amp;gt; AsyncIterator[None]:
    async with asyncpg.create_pool(DATABASE_URL) as pool:
        builder.provide(PG_DB, pool)
        builder.provide(EMBEDDER, SentenceTransformerEmbedder(EMBED_MODEL))
        yield
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Define file processing&lt;/h3&gt;
&lt;p&gt;With the row schema and shared resources in place, we can write the actual work: turning a file into rows.&lt;/p&gt;
&lt;h4&gt;Process file&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_file(
    file: FileLike,
    table: postgres.TableTarget[CodeEmbedding],
) -&amp;gt; None:
    text = await file.read_text()
    language = detect_code_language(filename=str(file.file_path.path.name))
    chunks = _splitter.split(
        text,
        chunk_size=1000,
        min_chunk_size=300,
        chunk_overlap=300,
        language=language,
    )
    id_gen = IdGenerator()
    await coco.map(process_chunk, chunks, file.file_path.path, id_gen, table)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;process_file&lt;/code&gt; runs once per file. It reads the file, detects the language so Tree-sitter can parse it, &lt;a href=&quot;https://cocoindex.io/docs/ops/text/&quot;&gt;splits the code&lt;/a&gt; along the syntax tree, and maps each chunk to &lt;code&gt;process_chunk&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Each chunk is a coherent syntactic unit, so retrieval returns whole functions or blocks, never a fragment cut mid-statement. All major languages are supported (Python, Rust, JavaScript, TypeScript, Java, C++, and more); unknown types fall back to plain text.&lt;/p&gt;
&lt;p&gt;Note that &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;&lt;code&gt;@coco.fn&lt;/code&gt;&lt;/a&gt; with &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/memoization_keys/&quot;&gt;&lt;code&gt;memo=True&lt;/code&gt;&lt;/a&gt; is what makes this incremental: if a file&apos;s content and this function&apos;s code are both unchanged, the whole file is skipped on the next run. &lt;code&gt;coco.map&lt;/code&gt; fans out to one &lt;code&gt;process_chunk&lt;/code&gt; call per chunk.&lt;/p&gt;
&lt;p&gt;Here is what chunking produces: each file is split into syntactic chunks, each with its own location and text.&lt;/p&gt;
&lt;h4&gt;Process chunk&lt;/h4&gt;
&lt;p&gt;Next, we embed the chunk with the shared embedder and declare the target row.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def process_chunk(
    chunk: Chunk,
    filename: pathlib.PurePath,
    id_gen: IdGenerator,
    table: postgres.TableTarget[CodeEmbedding],
) -&amp;gt; None:
    embedding = await coco.use_context(EMBEDDER).embed(chunk.text)
    table.declare_row(
        row=CodeEmbedding(
            id=await id_gen.next_id(chunk.text),
            filename=str(filename),
            code=chunk.text,
            embedding=embedding,
            start_line=chunk.start.line,
            end_line=chunk.end.line,
        ),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We use &lt;a href=&quot;https://cocoindex.io/docs/ops/sentence_transformers/&quot;&gt;&lt;code&gt;SentenceTransformerEmbedder&lt;/code&gt;&lt;/a&gt; with &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt;; there are 12k+ sentence-transformer models on &lt;a href=&quot;https://huggingface.co/models?other=sentence-transformers&quot;&gt;Hugging Face&lt;/a&gt;, so swap in whichever you prefer. Note &lt;code&gt;chunk.start.line&lt;/code&gt; and &lt;code&gt;chunk.end.line&lt;/code&gt;: V1 carries line numbers through, so search results point straight at the lines that matched.&lt;/p&gt;
&lt;h3&gt;Define the main function&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;app_main&lt;/code&gt; wires the source to the target. It mounts the Postgres table (with a &lt;a href=&quot;https://cocoindex.io/docs/common_resources/vector_schema/&quot;&gt;vector index&lt;/a&gt;), walks the codebase, and mounts one &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;processing component&lt;/a&gt; per file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def app_main(sourcedir: pathlib.Path) -&amp;gt; None:
    target_table = await postgres.mount_table_target(
        PG_DB,
        table_name=TABLE_NAME,
        table_schema=await postgres.TableSchema.from_class(
            CodeEmbedding, primary_key=[&quot;id&quot;],
        ),
    )
    target_table.declare_vector_index(column=&quot;embedding&quot;)

    files = localfs.walk_dir(
        sourcedir,
        recursive=True,
        path_matcher=PatternFilePathMatcher(
            included_patterns=[&quot;**/*.py&quot;, &quot;**/*.rs&quot;, &quot;**/*.toml&quot;, &quot;**/*.md&quot;, &quot;**/*.mdx&quot;],
            excluded_patterns=[&quot;**/.*&quot;, &quot;**/target&quot;, &quot;**/node_modules&quot;],
        ),
        live=True,  # watch for changes; pass -L to `cocoindex update` to run live
    )
    await coco.mount_each(process_file, files.items(), target_table)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We walk the codebase from &lt;code&gt;sourcedir&lt;/code&gt; (point this at the repository you want to index). We index files with the extensions &lt;code&gt;.py&lt;/code&gt;, &lt;code&gt;.rs&lt;/code&gt;, &lt;code&gt;.toml&lt;/code&gt;, &lt;code&gt;.md&lt;/code&gt;, &lt;code&gt;.mdx&lt;/code&gt;, and skip dot-directories, &lt;code&gt;target&lt;/code&gt;, and &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mount_table_target&lt;/code&gt; creates and manages the Postgres table for you: schema, the pgvector index, idempotent upserts, and orphan cleanup when a file disappears. &lt;code&gt;live=True&lt;/code&gt; makes the &lt;a href=&quot;https://cocoindex.io/docs/connectors/localfs/&quot;&gt;filesystem source&lt;/a&gt; &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/live_mode/&quot;&gt;watch for changes&lt;/a&gt;, and &lt;code&gt;mount_each&lt;/code&gt; runs one component per file so the engine can track and update them independently.&lt;/p&gt;
&lt;h3&gt;Create the App&lt;/h3&gt;
&lt;p&gt;Bind &lt;code&gt;app_main&lt;/code&gt; into a &lt;code&gt;coco.App&lt;/code&gt; and point it at the codebase root.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app = coco.App(
    coco.AppConfig(name=&quot;CodeEmbeddingV1&quot;),
    app_main,
    sourcedir=pathlib.Path(__file__).parent / &quot;..&quot; / &quot;..&quot;,  # index from repo root
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the entire indexing path.&lt;/p&gt;
&lt;h2&gt;Run the pipeline&lt;/h2&gt;
&lt;p&gt;Run the &lt;a href=&quot;https://cocoindex.io/docs/cli/&quot;&gt;&lt;code&gt;cocoindex&lt;/code&gt; CLI&lt;/a&gt; to set up and update the index. Choose catch-up (scan, sync, exit) or live (catch up, then keep watching):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Catch-up run
cocoindex update main

# Live run: keep watching for file changes
cocoindex update -L main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll see the index update state in the terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✅ app_main: 1 total | 1 added
✅ declare_target_state_with_child: 1 total | 1 added
✅ _MountEachLiveComponent.process: 1 total | 1 added
✅ process_file: 622 total | 622 added
⏳ Elapsed: 43.4s
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Query the index&lt;/h2&gt;
&lt;p&gt;We match user text against the index with a plain SQL query, reusing the &lt;em&gt;same&lt;/em&gt; embedder from the indexing flow so indexing and querying stay consistent.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def query_once(pool, embedder, query: str, *, top_k: int = 5) -&amp;gt; None:
    query_vec = await embedder.embed(query)
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            f&quot;&quot;&quot;
            SELECT filename, code, embedding &amp;lt;=&amp;gt; $1 AS distance, start_line, end_line
            FROM &quot;{TABLE_NAME}&quot;
            ORDER BY distance ASC
            LIMIT $2
            &quot;&quot;&quot;,
            query_vec, top_k,
        )
    for r in rows:
        score = 1.0 - float(r[&quot;distance&quot;])
        print(f&quot;[{score:.3f}] {r[&apos;filename&apos;]} (L{r[&apos;start_line&apos;]}-L{r[&apos;end_line&apos;]})&quot;)
        print(f&quot;    {r[&apos;code&apos;]}&quot;)
        print(&quot;---&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator is pgvector&apos;s cosine distance. We turn it into a similarity score and print the filename, the matched line range, and the code snippet. Run it against your index:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python main.py &quot;embedding&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The search results print in the terminal:&lt;/p&gt;
&lt;h2&gt;Incremental updates&lt;/h2&gt;
&lt;p&gt;You never compute a diff or write update logic: you change something, and CocoIndex works out what to embed, upsert, and delete. &lt;code&gt;@coco.fn(memo=True)&lt;/code&gt; decides what to &lt;em&gt;recompute&lt;/em&gt;: a file is skipped when its content and the function&apos;s code are both unchanged, and an embedding is reused when its chunk text is unchanged. &lt;code&gt;mount_table_target&lt;/code&gt; decides what to &lt;em&gt;write&lt;/em&gt;: each row&apos;s &lt;code&gt;id&lt;/code&gt; is derived from its chunk&apos;s content, so only rows that actually changed are upserted, and rows whose source is gone are deleted.&lt;/p&gt;
&lt;p&gt;The same machinery covers two kinds of change: changes to your &lt;strong&gt;data&lt;/strong&gt; (the code being indexed) and changes to your &lt;strong&gt;logic&lt;/strong&gt; (the pipeline itself).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Data changes.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A file is added&lt;/strong&gt;: only that file is chunked and embedded, and its rows are inserted. The rest of the repo is untouched.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A file is deleted&lt;/strong&gt;: its rows are removed from the target.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A file is edited&lt;/strong&gt;: the file is re-chunked, and the new chunks usually overlap the old ones. Chunks whose text is unchanged keep their &lt;code&gt;id&lt;/code&gt; and embedding, so they are left as-is; genuinely new chunks are embedded and inserted; chunks that no longer exist are deleted. Edit one function and only that function&apos;s chunks are re-embedded, even though the whole file was re-read.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Logic changes.&lt;/strong&gt; A pipeline change is reconciled the same way: CocoIndex compares the new output against what is already in Postgres and applies only the difference.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Change the file patterns&lt;/strong&gt; (&lt;code&gt;included_patterns&lt;/code&gt; / &lt;code&gt;excluded_patterns&lt;/code&gt;): files that now match are added automatically; files that no longer match have their rows deleted automatically.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tune the chunking&lt;/strong&gt; (chunk size, overlap): files are re-chunked, producing the same partial-overlap diff shown above: unchanged chunks are no-ops, new chunks are embedded and inserted, dropped chunks are deleted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swap the embedding model&lt;/strong&gt;: the vectors genuinely change, so all embeddings are recomputed, but row identity is stable: it is an in-place update of the &lt;code&gt;embedding&lt;/code&gt; column, with no rows added or removed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A catch-up run (&lt;code&gt;cocoindex update main&lt;/code&gt;) does this once and exits; live mode (&lt;code&gt;cocoindex update -L main&lt;/code&gt;) keeps watching and applies each change as you code.&lt;/p&gt;
&lt;h2&gt;CocoIndex Code&lt;/h2&gt;
&lt;p&gt;If you&apos;d rather not wire the pipeline yourself, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex-code&quot;&gt;CocoIndex Code&lt;/a&gt; is an end-to-end implementation of exactly this indexing, packaged as a CLI and an MCP server. It does the same thing the example above does: AST-aware chunking, incremental re-index on file changes, local embeddings by default.&lt;/p&gt;
&lt;p&gt;You can plug it straight into your coding agent or code-review agent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Claude Code skill:&lt;/strong&gt; &lt;code&gt;npx skills add cocoindex-io/cocoindex-code&lt;/code&gt;, then invoke &lt;code&gt;/ccc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP server:&lt;/strong&gt; &lt;code&gt;claude mcp add cocoindex-code -- ccc mcp&lt;/code&gt; (Codex, OpenCode, Cursor, and any MCP client work the same way).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CLI:&lt;/strong&gt; &lt;code&gt;ccc index&lt;/code&gt; to build the index, &lt;code&gt;ccc search &quot;where we embed chunks&quot;&lt;/code&gt; to query it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The agent gets the whole repository as semantic context instead of reading one file at a time.&lt;/p&gt;
&lt;h2&gt;Next steps&lt;/h2&gt;
&lt;p&gt;The full, runnable example is in the CocoIndex repo: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/code_embedding&quot;&gt;examples/code_embedding&lt;/a&gt;, with a walkthrough in the &lt;a href=&quot;https://cocoindex.io/docs/examples/index-codebase/&quot;&gt;index-codebase example&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Point &lt;code&gt;sourcedir&lt;/code&gt; at your own repository.&lt;/li&gt;
&lt;li&gt;Swap the embedding model or the target store; the pipeline is plain Python.&lt;/li&gt;
&lt;li&gt;Read the &lt;a href=&quot;https://cocoindex.io/docs/&quot;&gt;CocoIndex V1 docs&lt;/a&gt; for more on incremental processing, components, and targets.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Issues and contributions are welcome on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>examples</category><category>rag</category><category>embeddings</category><category>ai-agents</category><category>incremental-processing</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 1.0.1 - 1.0.7</title><link>https://cocoindex.io/blogs/changelog-101-107/</link><guid isPermaLink="true">https://cocoindex.io/blogs/changelog-101-107/</guid><description>CocoIndex&apos;s first post-v1 releases: stable memoization keys, scheduled live refresh, scoped stats, safer SQL connectors, and more integrations.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;CocoIndex reached &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;v1.0&lt;/a&gt; at the start of this cycle.&lt;/p&gt;
&lt;p&gt;This is the first changelog since the v1 launch, covering seven releases (1.0.1–1.0.7). The focus is on making the engine easier to operate in production pipelines, then connecting it to more of the systems where teams already store data.&lt;/p&gt;
&lt;p&gt;CocoIndex builds fresh, structured context for AI from sources such as PDFs, codebases, emails, screenshots, and meeting notes, and keeps it up to date with an &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;incremental engine&lt;/a&gt;. The source is on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The short version&lt;/h2&gt;
&lt;p&gt;The engine work is the headline this cycle. If you run long-lived, expensive, or shared pipelines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Per-argument &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/memoization_keys/&quot;&gt;memoization&lt;/a&gt; keys let you exclude clients, loggers, and debug flags from the cache key, so recomputation depends only on the inputs that matter.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coco.auto_refresh&lt;/code&gt; turns &quot;poll this source every few minutes&quot; into a live component with consistent error handling and target-state reconciliation.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coco.stats_group(...)&lt;/code&gt; breaks the &lt;code&gt;adds&lt;/code&gt; / &lt;code&gt;reprocesses&lt;/code&gt; / &lt;code&gt;deletes&lt;/code&gt; counts down by data slice (per tenant, project, or folder) instead of one aggregate per processor.&lt;/li&gt;
&lt;li&gt;New connectors: OCI Object Storage and Apache Iggy as sources; Turbopuffer, Neo4j, and FalkorDB as targets; LanceDB gained background compaction and in-place schema evolution.&lt;/li&gt;
&lt;li&gt;Security and correctness fixes, including SQL identifier injection hardening and a concurrency race that only appeared under real database latency.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Engine&lt;/h2&gt;
&lt;h3&gt;Per-argument memoization control&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@coco.fn&lt;/code&gt; now accepts a per-argument &lt;strong&gt;&lt;code&gt;memo_key&lt;/code&gt;&lt;/strong&gt;, giving you fine-grained control over which arguments participate in the memoization cache key.&lt;/p&gt;
&lt;p&gt;Production functions take more than data: clients, handles, config objects, loggers, tracing context, debug flags. Those values change across runs even when the meaningful input is identical, and each change could invalidate the cache and rerun expensive transforms, embeddings, or LLM calls for the wrong reason. &lt;code&gt;memo_key&lt;/code&gt; keeps the cache keyed to the semantic input.&lt;/p&gt;
&lt;p&gt;The API maps parameter names to either a callable (transform the value before fingerprinting) or &lt;code&gt;None&lt;/code&gt; (exclude the parameter entirely):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True, memo_key={&quot;entry&quot;: lambda e: (e.name, e.version), &quot;extra&quot;: None})
def transform(entry: SourceDataEntry, extra: str) -&amp;gt; str:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Callable&lt;/strong&gt; → applied to the argument; its return value is fingerprinted in place of the original.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;None&lt;/code&gt;&lt;/strong&gt; → the parameter is excluded from the memo key; changing it never invalidates the cache.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not listed&lt;/strong&gt; → fingerprinted normally.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Read more in &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/memoization_keys/&quot;&gt;Memoization keys &amp;amp; states → Override at the call site&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1888&quot;&gt;&lt;strong&gt;#1888&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2000&quot;&gt;&lt;strong&gt;#2000&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;coco.auto_refresh&lt;/code&gt; for live components&lt;/h3&gt;
&lt;p&gt;A common live-mode pattern is &quot;run this processor on a fixed schedule.&quot; &lt;code&gt;coco.auto_refresh&lt;/code&gt; now makes that pattern first-class.&lt;/p&gt;
&lt;p&gt;Without an engine-level primitive, every pipeline hand-rolls the same loop and re-decides how errors propagate and how missing rows get reconciled. &lt;code&gt;coco.auto_refresh&lt;/code&gt; wraps any processor function as a &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/live_mode/&quot;&gt;live component&lt;/a&gt; that re-runs on an interval and routes cycle failures through one error-handling channel.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import datetime
import cocoindex as coco

@coco.fn
async def app_main(db, target) -&amp;gt; None:
    await coco.mount(
        coco.auto_refresh(sync_users, interval=datetime.timedelta(minutes=5)),
        db, target,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each cycle reconciles target states against the previous cycle. If &lt;code&gt;sync_users&lt;/code&gt; stops declaring a row, CocoIndex deletes the corresponding target automatically. See &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/live_component/&quot;&gt;Live components → periodic refresh&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1995&quot;&gt;&lt;strong&gt;#1995&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Scoped stats reports&lt;/h3&gt;
&lt;p&gt;By default, CocoIndex already breaks stats down &lt;strong&gt;by processor&lt;/strong&gt;: each entry function (&lt;code&gt;process_doc&lt;/code&gt;, &lt;code&gt;process_code&lt;/code&gt;, …) reports its own counts: &lt;code&gt;adds&lt;/code&gt;, &lt;code&gt;reprocesses&lt;/code&gt;, &lt;code&gt;deletes&lt;/code&gt;. What it can&apos;t do by default is break those counts down across &lt;strong&gt;data slices&lt;/strong&gt;. &lt;code&gt;coco.stats_group(title)&lt;/code&gt; opens a scope where everything mounted inside aggregates into a &lt;strong&gt;separate&lt;/strong&gt; report under &lt;code&gt;title&lt;/code&gt;, split out of the parent (no double counting).&lt;/p&gt;
&lt;p&gt;Seeing that one tenant did 600 reprocesses while another did 1,150 fresh adds tells you who is churning, who is growing, and where an unexpected reprocess storm came from. One aggregate per processor hides all of that. The natural slice is the data: per tenant, per project, or per source folder.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def app_main(tenants, target):
    for tenant in tenants:
        # same processor, but stats scoped per tenant
        with coco.stats_group(f&quot;tenant:{tenant.id}&quot;, report_to_stdout=True):
            files = localfs.walk_dir(tenant.docs_dir, ...)
            await coco.mount_each(process_doc, files.items(), target)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The block also yields a handle with the same &lt;code&gt;stats()&lt;/code&gt; and &lt;code&gt;watch()&lt;/code&gt; methods as &lt;code&gt;UpdateHandle&lt;/code&gt;, so you can stream a single slice&apos;s counts to a dashboard. See &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/progress_monitoring/&quot;&gt;Progress monitoring → Scoped reports&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2042&quot;&gt;&lt;strong&gt;#2042&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Other engine improvements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Configurable logging&lt;/strong&gt;: set the Python logger level directly via the &lt;code&gt;COCOINDEX_LOG_LEVEL&lt;/code&gt; environment variable (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2035&quot;&gt;&lt;strong&gt;#2035&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cleaner Rust internals&lt;/strong&gt;: the workspace was unified and the core SDK isolated from PyO3, separating the engine from its Python bindings and laying the groundwork for native Rust consumers of the engine (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1973&quot;&gt;&lt;strong&gt;#1973&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Built-in operations&lt;/h2&gt;
&lt;h3&gt;LiteLLM speech-to-text&lt;/h3&gt;
&lt;p&gt;CocoIndex added &lt;strong&gt;speech-to-text (STT)&lt;/strong&gt; support through &lt;a href=&quot;https://cocoindex.io/docs/ops/litellm/&quot;&gt;LiteLLM&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1889&quot;&gt;&lt;strong&gt;#1889&lt;/strong&gt;&lt;/a&gt;), so audio sources can be transcribed inside a pipeline using any LiteLLM-backed STT provider. A &lt;code&gt;LiteLLMTranscriber&lt;/code&gt; wraps the transcription API:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cocoindex.ops.litellm import LiteLLMTranscriber

transcriber = LiteLLMTranscriber(&quot;whisper-1&quot;)
transcript = await transcriber.transcribe(audio_file)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Code splitter: eight new languages&lt;/h3&gt;
&lt;p&gt;The &lt;a href=&quot;https://cocoindex.io/docs/ops/text/&quot;&gt;&lt;code&gt;RecursiveSplitter&lt;/code&gt;&lt;/a&gt; gained &lt;a href=&quot;https://cocoindex.io/blogs/index-codebase-v1&quot;&gt;tree-sitter&lt;/a&gt; support for &lt;strong&gt;eight new languages&lt;/strong&gt; this cycle, so syntax-aware chunking now covers a much wider slice of real codebases and config:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Svelte&lt;/strong&gt; and &lt;strong&gt;Vue&lt;/strong&gt;: component-aware chunking for frontend code (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1937&quot;&gt;&lt;strong&gt;#1937&lt;/strong&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Julia&lt;/strong&gt;: for scientific and numerical codebases (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1942&quot;&gt;&lt;strong&gt;#1942&lt;/strong&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Elm&lt;/strong&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1955&quot;&gt;&lt;strong&gt;#1955&lt;/strong&gt;&lt;/a&gt;) and &lt;strong&gt;Astro&lt;/strong&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1984&quot;&gt;&lt;strong&gt;#1984&lt;/strong&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bash, CMake, and HCL&lt;/strong&gt;: shell scripts, build files, and Terraform/infra configs (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1954&quot;&gt;&lt;strong&gt;#1954&lt;/strong&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pass the language name to &lt;code&gt;language=&lt;/code&gt; (or let &lt;a href=&quot;https://cocoindex.io/docs/ops/text/&quot;&gt;&lt;code&gt;detect_code_language()&lt;/code&gt;&lt;/a&gt; infer it from a filename):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from cocoindex.ops.text import RecursiveSplitter

splitter = RecursiveSplitter()
chunks = splitter.split(source_code, chunk_size=2000, chunk_overlap=200, language=&quot;svelte&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Entity resolution: parallel by default&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/docs/ops/entity_resolution/&quot;&gt;Entity resolution&lt;/a&gt; (dedup-and-canonicalize a set of extracted entity names) now resolves independent connected components of the candidate graph in parallel, speeding up LLM-backed deduplication on large entity sets (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2006&quot;&gt;&lt;strong&gt;#2006&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h2&gt;Connectors&lt;/h2&gt;
&lt;h3&gt;Source: OCI Object Storage with live bucket watching&lt;/h3&gt;
&lt;p&gt;A new &lt;a href=&quot;https://cocoindex.io/docs/connectors/oci_object_storage/&quot;&gt;&lt;strong&gt;Oracle Cloud Infrastructure (OCI) Object Storage&lt;/strong&gt;&lt;/a&gt; source mirrors the S3 source&apos;s API for scanning a bucket, and adds an optional live mode.&lt;/p&gt;
&lt;p&gt;It&apos;s a good illustration of the &lt;strong&gt;stream–state duality&lt;/strong&gt; in CocoIndex&apos;s source model. A bucket is &lt;em&gt;state&lt;/em&gt;: &lt;code&gt;list_objects()&lt;/code&gt; scans it and establishes the full set of objects. A change feed is a &lt;em&gt;stream&lt;/em&gt;: when you supply a &lt;code&gt;live_stream&lt;/code&gt;, CocoIndex does the initial scan once and then keeps watching, applying &lt;code&gt;createobject&lt;/code&gt; / &lt;code&gt;updateobject&lt;/code&gt; / &lt;code&gt;deleteobject&lt;/code&gt; events incrementally instead of rescanning. For OCI those events flow through OCI Streaming, consumed over the &lt;a href=&quot;https://cocoindex.io/blogs/csv-to-kafka-live&quot;&gt;Kafka&lt;/a&gt; protocol; an event-time cutoff with a 5-second clock-skew tolerance decides which streamed events predate the scan. Under the hood it&apos;s built on a new lower-level &lt;code&gt;LiveStream&lt;/code&gt; abstraction, a keyless stream of messages with in-memory watermark tracking and ack-on-completion (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1905&quot;&gt;&lt;strong&gt;#1905&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;This is the same model CocoIndex already uses for the &lt;a href=&quot;https://cocoindex.io/docs/connectors/localfs/&quot;&gt;local file system&lt;/a&gt;, where the change stream comes from OS file-system events rather than Kafka. More sources, such as S3, will gain live mode over time.&lt;/p&gt;
&lt;h3&gt;Source: Apache Iggy&lt;/h3&gt;
&lt;p&gt;CocoIndex now reads from &lt;strong&gt;Apache Iggy&lt;/strong&gt;, a persistent message-streaming platform (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1969&quot;&gt;&lt;strong&gt;#1969&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Target: Turbopuffer&lt;/h3&gt;
&lt;p&gt;A new &lt;a href=&quot;https://cocoindex.io/docs/connectors/turbopuffer/&quot;&gt;&lt;strong&gt;Turbopuffer&lt;/strong&gt;&lt;/a&gt; target connector landed, along with a runnable text-embedding example (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1934&quot;&gt;&lt;strong&gt;#1934&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Target: Neo4j&lt;/h3&gt;
&lt;p&gt;A native &lt;a href=&quot;https://cocoindex.io/docs/connectors/neo4j/&quot;&gt;&lt;strong&gt;Neo4j&lt;/strong&gt;&lt;/a&gt; property-graph target landed alongside a &lt;code&gt;meeting_notes_graph_neo4j&lt;/code&gt; example, mapping nodes and relationships into Neo4j without writing Cypher by hand (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1932&quot;&gt;&lt;strong&gt;#1932&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Target: FalkorDB&lt;/h3&gt;
&lt;p&gt;The &lt;a href=&quot;https://cocoindex.io/docs/connectors/falkordb/&quot;&gt;&lt;strong&gt;FalkorDB&lt;/strong&gt;&lt;/a&gt; property-graph target was brought forward into the v1 connector set, with support for nodes, relationships, and vector/FTS indexes (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1908&quot;&gt;&lt;strong&gt;#1908&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Target: LanceDB&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/docs/connectors/lancedb/&quot;&gt;LanceDB&lt;/a&gt; got two upgrades for production workloads:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The v1 target now &lt;strong&gt;optimizes (compacts) tables periodically&lt;/strong&gt; in the background, keeping query performance steady as data churns (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2008&quot;&gt;&lt;strong&gt;#2008&lt;/strong&gt;&lt;/a&gt;, with a scheduling fix in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2013&quot;&gt;&lt;strong&gt;#2013&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Table targets can &lt;strong&gt;add new columns in place&lt;/strong&gt;, so schema evolution doesn&apos;t require a rebuild (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1951&quot;&gt;&lt;strong&gt;#1951&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Bug fixes&lt;/h2&gt;
&lt;h3&gt;Postgres correctness&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;halfvec&lt;/code&gt; op classes are now used for indexes on half-precision vectors (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2029&quot;&gt;&lt;strong&gt;#2029&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;U+0000&lt;/code&gt; (NUL) bytes are stripped when writing &lt;code&gt;text&lt;/code&gt;/&lt;code&gt;jsonb&lt;/code&gt;, so Postgres no longer rejects otherwise-valid payloads (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2032&quot;&gt;&lt;strong&gt;#2032&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;pgvector&lt;/code&gt; extension now installs into the default schema, avoiding &lt;code&gt;search_path&lt;/code&gt; surprises (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1979&quot;&gt;&lt;strong&gt;#1979&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Other fixes&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SQL identifier validation&lt;/strong&gt;: the Postgres and SQLite connectors now validate table and column identifiers before interpolating them into statements, closing a class of SQL-injection vectors at the connector boundary (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1947&quot;&gt;&lt;strong&gt;#1947&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1965&quot;&gt;&lt;strong&gt;#1965&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ownership-transfer race&lt;/strong&gt;: a pending-state protocol closes a preempt race so target-state ownership transfer stays correct under Postgres I/O latency, not just on microsecond-fast LMDB (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1994&quot;&gt;&lt;strong&gt;#1994&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean cancellation&lt;/strong&gt;: cancellation propagates through task spawn boundaries, tearing work down cleanly instead of leaking orphaned tasks (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1902&quot;&gt;&lt;strong&gt;#1902&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;on_error&lt;/code&gt; cascade&lt;/strong&gt;: errors cascade through the Build-mode GC sweep (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1999&quot;&gt;&lt;strong&gt;#1999&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;numpy serde&lt;/strong&gt;: &lt;code&gt;_frombuffer&lt;/code&gt; is registered under both numpy 1.x and 2.x paths (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2012&quot;&gt;&lt;strong&gt;#2012&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Responsive progress display&lt;/strong&gt;: the PTY reader moved onto a dedicated OS thread, keeping the async runtime responsive under heavy logging (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2033&quot;&gt;&lt;strong&gt;#2033&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2040&quot;&gt;&lt;strong&gt;#2040&lt;/strong&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Build with CocoIndex&lt;/h2&gt;
&lt;h3&gt;Meeting notes → knowledge graph, now on Neo4j&lt;/h3&gt;
&lt;p&gt;The popular &lt;strong&gt;meeting-notes-to-knowledge-graph&lt;/strong&gt; example now ships in a Neo4j flavor (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/meeting_notes_graph_neo4j&quot;&gt;&lt;code&gt;meeting_notes_graph_neo4j&lt;/code&gt;&lt;/a&gt;). It watches a folder of meeting notes, uses LLM extraction to pull out structured entities (Meetings, People, Tasks) and the relationships between them, then maps everything into Neo4j as nodes and edges. Change one note and CocoIndex reprocesses only that note, keeping the graph continuously in sync.&lt;/p&gt;
&lt;h3&gt;Turbopuffer and OCI text-embedding examples&lt;/h3&gt;
&lt;p&gt;Two more end-to-end examples landed alongside the new connectors: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/text_embedding_turbopuffer&quot;&gt;&lt;code&gt;text_embedding_turbopuffer&lt;/code&gt;&lt;/a&gt; shows a complete embed-and-query pipeline against Turbopuffer, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/oci_object_storage_embedding&quot;&gt;&lt;code&gt;oci_object_storage_embedding&lt;/code&gt;&lt;/a&gt; demonstrates ingesting from OCI Object Storage with live bucket watching. Both are ready to clone and run.&lt;/p&gt;
&lt;h3&gt;Watch: build it end to end on FalkorDB&lt;/h3&gt;
&lt;p&gt;Follow along with the &lt;a href=&quot;https://www.youtube.com/watch?v=r1eRG8JPMJM&quot;&gt;Build with CocoIndex walkthrough&lt;/a&gt;, which builds the meeting-notes-to-knowledge-graph pipeline end to end on FalkorDB, then clone the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/meeting_notes_graph_falkordb&quot;&gt;&lt;code&gt;meeting_notes_graph_falkordb&lt;/code&gt;&lt;/a&gt; example to run it yourself.&lt;/p&gt;
&lt;p&gt;For the complete list of changes, see the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/releases&quot;&gt;GitHub releases&lt;/a&gt;. If CocoIndex is useful to you, consider starring the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;repository&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Thanks to the community&lt;/h2&gt;
&lt;p&gt;Thanks to the contributors who shipped changes this cycle.&lt;/p&gt;
&lt;h3&gt;@prrao87&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prrao87&quot;&gt;@prrao87&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2013&quot;&gt;fixing LanceDB optimize scheduling&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@zherendong&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/zherendong&quot;&gt;@zherendong&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2006&quot;&gt;parallelizing entity resolution via candidate-graph components&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@countradooku&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/countradooku&quot;&gt;@countradooku&lt;/a&gt; for adding the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1969&quot;&gt;Apache Iggy connector&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@Haleshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Haleshot&quot;&gt;@Haleshot&lt;/a&gt; for keeping the docs in shape after the v1 launch: &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1924&quot;&gt;removing the stale &lt;code&gt;v1&lt;/code&gt; mention from the install command&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1958&quot;&gt;fixing a broken URL&lt;/a&gt;, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1959&quot;&gt;updating stale &lt;code&gt;v1&lt;/code&gt; branch links to &lt;code&gt;main&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@galshubeli&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/galshubeli&quot;&gt;@galshubeli&lt;/a&gt; for bringing the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1908&quot;&gt;FalkorDB target connector&lt;/a&gt; into the v1 connector set.&lt;/p&gt;
&lt;h3&gt;@Gohlub&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Gohlub&quot;&gt;@Gohlub&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1889&quot;&gt;LiteLLM speech-to-text support&lt;/a&gt;, CocoIndex&apos;s first audio operation.&lt;/p&gt;
&lt;h3&gt;@MrAnayDongre&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/MrAnayDongre&quot;&gt;@MrAnayDongre&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1888&quot;&gt;per-argument &lt;code&gt;memo_key&lt;/code&gt; support&lt;/a&gt; to &lt;code&gt;@coco.fn&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;@aaronjmars&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/aaronjmars&quot;&gt;@aaronjmars&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1947&quot;&gt;validating SQL identifiers in the Postgres and SQLite connectors&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@tuanaiseo&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/tuanaiseo&quot;&gt;@tuanaiseo&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1965&quot;&gt;reporting and fixing a potential SQL injection in the Postgres connector&lt;/a&gt;, responsibly disclosed.&lt;/p&gt;
&lt;h3&gt;@nuthalapativarun&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/nuthalapativarun&quot;&gt;@nuthalapativarun&lt;/a&gt; for a prolific cycle of splitter work: adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1955&quot;&gt;Elm&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1984&quot;&gt;Astro&lt;/a&gt;, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1954&quot;&gt;Bash, CMake, and HCL&lt;/a&gt; tree-sitter support, plus &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1986&quot;&gt;adding context to Rust→Python error messages&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1953&quot;&gt;expanding the supported-languages docs&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@qWaitCrypto&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/qWaitCrypto&quot;&gt;@qWaitCrypto&lt;/a&gt; for making the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2008&quot;&gt;LanceDB v1 target optimize tables periodically&lt;/a&gt; and for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1951&quot;&gt;adding columns in place&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@shaiar&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/shaiar&quot;&gt;@shaiar&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/2012&quot;&gt;fixing serde &lt;code&gt;_frombuffer&lt;/code&gt; registration across numpy 1.x and 2.x&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@octo-patch&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/octo-patch&quot;&gt;@octo-patch&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1855&quot;&gt;adding Python formatting and linting commands to CLAUDE.md&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@phuctoan123&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/phuctoan123&quot;&gt;@phuctoan123&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1949&quot;&gt;documenting &lt;code&gt;gws&lt;/code&gt; for Google Drive setup&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>changelog</category><category>announcement</category><category>connectors</category><category>performance</category><author>George He</author></item><item><title>Live CSV → Kafka with CocoIndex&apos;s New Kafka Target Connector</title><link>https://cocoindex.io/blogs/csv-to-kafka-live/</link><guid isPermaLink="true">https://cocoindex.io/blogs/csv-to-kafka-live/</guid><description>Walk through a live CocoIndex pipeline that watches a folder of CSV files and publishes each row as JSON to a Kafka topic incrementally, with no glue code.</description><pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; just got a &lt;strong&gt;Kafka target connector&lt;/strong&gt;. You can now declare Kafka topics as a target of your pipeline the same way you&apos;d declare a &lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;Postgres table&lt;/a&gt; or a vector index, and CocoIndex will incrementally produce messages as your source data changes: no producer loop or bookkeeping, and no &quot;did I already publish this row?&quot; logic.&lt;/p&gt;
&lt;p&gt;In this post we walk through a small but complete example: watch a local directory of CSV files, turn each row into a JSON message keyed by the row&apos;s primary key, and publish to a Kafka topic hosted on &lt;a href=&quot;https://streamnative.io/&quot;&gt;StreamNative&lt;/a&gt;. The whole pipeline is about 60 lines of Python, and it runs in &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/live_mode/&quot;&gt;&lt;strong&gt;live mode&lt;/strong&gt;&lt;/a&gt;: edit a CSV, and within a second only the changed rows show up on the topic.&lt;/p&gt;
&lt;p&gt;Getting data into Kafka has always been a pile of glue: producers, dedup state, deletes, schema re-bootstraps, file-watcher debouncing. Easy to get subtly wrong, never interesting to maintain.&lt;/p&gt;
&lt;p&gt;The new connector lets you skip it. Hand CocoIndex an &lt;code&gt;AIOProducer&lt;/code&gt;, a topic, and a function mapping each source row to a key and value. It handles the rest, producing messages only for rows that &lt;em&gt;actually changed&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;From static knowledge to streaming signals&lt;/h2&gt;
&lt;p&gt;Many agent stacks today are built around periodic snapshots of their knowledge sources. The wiki is re-indexed overnight, the codebase is re-embedded on a cron, the CRM is re-pulled on a schedule, and agents read those snapshots over and over, hoping to detect when something has changed. For long-running agents that may execute for hours at a time, a snapshot captured at the start of the run quickly drifts out of sync with the underlying data.&lt;/p&gt;
&lt;p&gt;A common next step is to wire sources directly to consumers via point-to-point webhooks, but this approach has well-known limitations once more than one consumer is involved. There is no shared path for replay or backfill, no buffer to absorb bursts when many changes arrive at once, and no common schema describing what a &quot;change&quot; looks like across systems. Teams that go down this path tend to end up reimplementing pieces of a durable log, separately, in each integration.&lt;/p&gt;
&lt;p&gt;The combination of CocoIndex and Kafka takes a different approach: &lt;strong&gt;it treats the knowledge layer the same way operational data has been handled for years: as a stream of change events rather than a snapshot to be re-read.&lt;/strong&gt; Drives, repos, design files, wikis, PDFs, and file shares (the unstructured data that has traditionally lived outside the streaming world) can be published to the same event backbone that already carries orders, clicks, and CDC traffic. The benefits show up in several places:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;More efficient AI workloads.&lt;/strong&gt; Embeddings, retrievals, and agent context are refreshed only when something has actually changed, which reduces redundant work and improves freshness at the same time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A single change reaches every consumer.&lt;/strong&gt; A commit, a renamed Drive document, or a Notion edit can update the vector index, notify an agent, update search, feed a Flink job, and land in a BI tile, without any of those systems needing to know about each other.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easier extensibility.&lt;/strong&gt; A new agent, a rebuilt RAG layer, or a compliance tool can be added as another subscriber to the topic, with the log providing replay so it sees historical changes the same way it sees new ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Better auditability.&lt;/strong&gt; Each change consumed by an agent is durably recorded with offsets and timestamps, which makes it possible to answer questions like &quot;did the agent see the updated policy before it acted?&quot; with concrete evidence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A stable contract over time.&lt;/strong&gt; The change-event schema on the topic provides a stable interface between sources and consumers. Detectors, sources, and models can evolve independently while the wire format stays consistent.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is worth stating the contrast directly. A &lt;strong&gt;static unstructured &lt;a href=&quot;https://cocoindex.io/blogs/knowledge-graph-for-docs&quot;&gt;knowledge graph&lt;/a&gt;&lt;/strong&gt; is rebuilt on a schedule, drifts between rebuilds, and pushes freshness logic into each consumer. A &lt;strong&gt;stream of unstructured change events&lt;/strong&gt; stays current by construction and gives every consumer (agents, indexes, analytics, auditors) a shared view of what has changed. Most AI stacks today are built around the former; production agents tend to benefit more from the latter.&lt;/p&gt;
&lt;h2&gt;Kafka: message in, message out — now from the unstructured world&lt;/h2&gt;
&lt;p&gt;Kafka&apos;s contract is famously simple: &lt;strong&gt;a message goes in, a message comes out.&lt;/strong&gt; That single shape is what makes the rest of the streaming ecosystem composable: Flink, ksqlDB, vector stores, OLAP sinks, search backends, microservices, agent runtimes. Anything downstream gets a real-time, replayable, fan-out-by-default view of the world by just reading a topic.&lt;/p&gt;
&lt;p&gt;The catch is that the messages going &lt;em&gt;in&lt;/em&gt; have historically been the structured kind: orders, clicks, IoT telemetry, Debezium CDC off Postgres or MySQL. The other half of the business (meeting notes, codebases, design files, PDFs, file shares, wikis) has lived in a parallel universe of vendor-specific webhooks, nightly batch jobs, and one-off ETL scripts no one wants to own.&lt;/p&gt;
&lt;p&gt;CocoIndex closes that gap. It treats dynamically-changing unstructured assets as &lt;strong&gt;first-class CDC sources&lt;/strong&gt; and emits clean key/value change events into Kafka, with upsert / delete / no-op semantics described later in this post.&lt;/p&gt;
&lt;p&gt;The point is not &quot;another connector.&quot; It is that Kafka stays Kafka (&lt;strong&gt;message in, message out&lt;/strong&gt;) and CocoIndex makes the &lt;em&gt;in&lt;/em&gt; side cover the dynamic, unstructured majority of the data that previously couldn&apos;t get there cleanly. Every team that already knows how to consume from Kafka now gets the unstructured world for free.&lt;/p&gt;
&lt;h2&gt;Why choose CocoIndex with Kafka&lt;/h2&gt;
&lt;p&gt;Kafka is great at durable, high-throughput event streaming, but it expects you to solve data shaping, schema evolution, and incremental change detection yourself. CocoIndex turns that into a declarative problem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Define transformations like formulas: “from this table or API, derive these messages and fields.” CocoIndex computes and re-computes derived data whenever sources change, similar to spreadsheet formulas reacting to edits.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Built-in &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;incremental processing&lt;/a&gt;: CocoIndex keeps local state (checksums, primary keys, or version markers) so subsequent runs push only changed or deleted rows into Kafka topics.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Schema-aware updates: when schemas change upstream, CocoIndex re-derives downstream messages instead of forcing you to manually re-bootstrap topics or write ad-hoc migration scripts.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Pluggable sinks beyond Kafka: the same flow that feeds Kafka can also populate vector databases, OLTP/OLAP stores, or search indexes, so Kafka events and your AI/search backends stay in sync from a single definition.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;Partnership with StreamNative&lt;/h2&gt;
&lt;p&gt;We&apos;re partnering with &lt;a href=&quot;https://streamnative.io/&quot;&gt;StreamNative&lt;/a&gt; to bring real-time data infrastructure to AI workloads. The two halves fit cleanly because they answer different questions: CocoIndex looks at noisy, dynamic, unstructured sources and produces a precise answer to &lt;strong&gt;&quot;what&apos;s different since last time?&quot;&lt;/strong&gt; StreamNative&apos;s Ursa-powered Kafka service makes sure that answer reaches every system that cares, reliably, at scale, with history intact. With Kafka in the middle, sources don&apos;t have to behave like streams and agents don&apos;t have to behave like database clients; the topic absorbs the impedance mismatch and lets each side evolve on its own schedule.&lt;/p&gt;
&lt;h2&gt;The example: CSV files → JSON messages&lt;/h2&gt;
&lt;p&gt;To make all of this concrete, we&apos;ll build the smallest end-to-end pipeline that exercises the new connector: a local &lt;code&gt;data/&lt;/code&gt; folder of CSV files, watched in real time, with each row published as a JSON message to a Kafka topic on StreamNative. Edit a cell, and within a second exactly one message (for that one row) appears on the topic. Add a row, get one new message. Delete a file, and every row from it is tombstoned. The whole thing is around sixty lines of Python.&lt;/p&gt;
&lt;p&gt;CSV is a deliberately humble starting point. It&apos;s the format that shows up everywhere and gets respect nowhere: exports from BI tools, dumps from vendors who won&apos;t give you a webhook, spreadsheets parked in a shared drive by a colleague, output from a nightly script no one wants to maintain. CSV files look structured but live like unstructured assets: dropped into a folder, edited at random, with no notifications and no schema contract. If we can turn a directory of them into a clean, row-keyed, live Kafka stream with diff-only writes, the same pattern carries over to the noisier sources mentioned earlier: PDFs, codebases, wikis, design files. CSV just lets us focus on the new connector and the live-update loop without getting tangled in a richer parser.&lt;/p&gt;
&lt;p&gt;The full example lives in the CocoIndex repo. Here&apos;s the shape of it.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;We have a &lt;code&gt;data/&lt;/code&gt; folder with a couple of CSV files:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# data/products.csv
sku,name,category,price
SKU001,Wireless Mouse,Electronics,29.99
SKU002,Mechanical Keyboard,Electronics,89.99
SKU003,USB-C Hub,Accessories,45.00
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# data/employees.csv
emp_id,first_name,last_name,department,email
E101,Alice,Chen,Engineering,alice.chen@example.com
E102,Bob,Smith,Marketing,bob.smith@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The goal: every row becomes a JSON message on a Kafka topic, keyed by the value of the row&apos;s first column (the primary key). When a CSV file is edited, only the rows that actually changed should be re-published.&lt;/p&gt;
&lt;h2&gt;The pipeline&lt;/h2&gt;
&lt;p&gt;First, the Kafka producer is set up once at app startup using a &lt;code&gt;lifespan&lt;/code&gt; hook, and stashed in a &lt;code&gt;ContextKey&lt;/code&gt; so the rest of the pipeline can grab it without passing it around:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import cocoindex as coco
from cocoindex.connectors import kafka, localfs
from confluent_kafka.aio import AIOProducer

KAFKA_PRODUCER = coco.ContextKey[AIOProducer](&quot;kafka_producer&quot;, tracked=False)

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder):
    config = {
        &quot;bootstrap.servers&quot;: KAFKA_BOOTSTRAP_SERVERS,
        &quot;sasl.mechanism&quot;: &quot;PLAIN&quot;,
        &quot;security.protocol&quot;: &quot;SASL_SSL&quot;,
        &quot;sasl.username&quot;: KAFKA_SASL_USERNAME,
        &quot;sasl.password&quot;: KAFKA_SASL_PASSWORD,
    }
    producer = AIOProducer(config)
    builder.provide(KAFKA_PRODUCER, producer)
    yield
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/context/&quot;&gt;&lt;code&gt;ContextKey&lt;/code&gt;&lt;/a&gt; is how CocoIndex shares the producer across components without threading it through every function call; we&apos;ll come back to it later. The SASL block is what StreamNative (or any production broker) wants. For a local broker you can drop those four lines and just point &lt;code&gt;bootstrap.servers&lt;/code&gt; at &lt;code&gt;localhost:9092&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Next, the per-file processor. This is where the new Kafka API shows up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_csv(file: FileLike, topic_target: kafka.KafkaTopicTarget) -&amp;gt; None:
    text = await file.read_text()
    reader = csv.DictReader(io.StringIO(text))

    headers = reader.fieldnames
    if not headers:
        return
    first_col = headers[0]

    for row in reader:
        key_value = row.get(first_col)
        if key_value is not None:
            value = json.dumps(row)
            topic_target.declare_target_state(key=key_value, value=value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;@coco.fn(memo=True)&lt;/code&gt; decorator means the per-file work itself is memoized too: if a file&apos;s contents haven&apos;t changed, &lt;code&gt;process_csv&lt;/code&gt; doesn&apos;t even run.&lt;/p&gt;
&lt;h3&gt;Declare states, not messages&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;topic_target.declare_target_state(key=key, value=value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s deliberately &lt;em&gt;not&lt;/em&gt; called &lt;code&gt;send_message()&lt;/code&gt; or &lt;code&gt;produce()&lt;/code&gt; or &lt;code&gt;declare_message()&lt;/code&gt;. It&apos;s &lt;code&gt;declare_target_state&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;CocoIndex is a &lt;strong&gt;state-driven&lt;/strong&gt; data framework. The mental model is the same one you&apos;d use for a spreadsheet, a React component tree, or a SQL materialized view: &lt;strong&gt;you describe what the target should look like as a function of the source, and the framework figures out the transitions.&lt;/strong&gt; You don&apos;t compute deltas. You don&apos;t track &quot;what did I send last time.&quot; You don&apos;t handle insert vs. update vs. delete as separate code paths. You just say, &quot;given this CSV row, the target state for key &lt;code&gt;SKU001&lt;/code&gt; is this JSON blob,&quot; and that&apos;s it.&lt;/p&gt;
&lt;p&gt;Kafka makes that distinction unusually visible because Kafka&apos;s wire model is the opposite: a topic is a &lt;em&gt;log of events&lt;/em&gt;, not a snapshot. Producers send change events; consumers (or compacted topics) reconstruct state from the log. So the question is: who&apos;s responsible for the gap between &quot;I have desired states&quot; and &quot;the broker needs to receive change events&quot;?&lt;/p&gt;
&lt;p&gt;In CocoIndex, the framework owns that gap. When you call &lt;code&gt;declare_target_state(key=k, value=v)&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;k&lt;/code&gt; is new or &lt;code&gt;v&lt;/code&gt; differs from the last value the framework remembers for &lt;code&gt;k&lt;/code&gt;, it emits an &lt;strong&gt;upsert&lt;/strong&gt; message: &lt;code&gt;(k, v)&lt;/code&gt; on the wire.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;k&lt;/code&gt; was previously declared but isn&apos;t declared this time, it emits a &lt;strong&gt;delete&lt;/strong&gt; message: &lt;code&gt;(k, None)&lt;/code&gt;, or &lt;code&gt;(k, deletion_value_fn(k))&lt;/code&gt; if you supplied a tombstone constructor.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;k&lt;/code&gt; was previously declared with the same &lt;code&gt;v&lt;/code&gt;, &lt;strong&gt;nothing is sent&lt;/strong&gt; to the broker, and no consumer wakes up.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Messages are derived from state transitions. You only ever talk about states. This is exactly the same pattern as the Postgres target (&lt;code&gt;declare_target_state&lt;/code&gt; → INSERT / UPDATE / DELETE) and the vector index targets: the wire-level operations differ, but the user-facing API is the same shape, because the &lt;em&gt;semantics&lt;/em&gt; are the same.&lt;/p&gt;
&lt;p&gt;The reason this matters in practice: it means the same &lt;code&gt;process_csv&lt;/code&gt; function works correctly on the first run and every run after, whether a row is edited or removed, a file is deleted, or the whole pipeline crashes and restarts. There is no separate &quot;initial load&quot; code path versus &quot;&lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental update&lt;/a&gt;&quot; code path. There&apos;s just &quot;given the source, here&apos;s what the target should look like,&quot; and that statement is true whether the target is empty, half-populated, or already in sync.&lt;/p&gt;
&lt;p&gt;Finally, wire it all together:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def app_main() -&amp;gt; None:
    topic_target = await kafka.mount_kafka_topic_target(KAFKA_PRODUCER, KAFKA_TOPIC)

    files = localfs.walk_dir(
        localfs.FilePath(path=&quot;./data&quot;),
        path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.csv&quot;]),
        live=True,
    )
    await coco.mount_each(process_csv, files.items(), topic_target)

app = coco.App(coco.AppConfig(name=&quot;CsvToKafka&quot;), app_main)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things to notice here:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;mount_kafka_topic_target(...)&lt;/code&gt; resolves the producer from the context key and gives back a handle. The topic itself is user-managed: CocoIndex never creates or deletes topics; it just produces into one you already own.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;localfs.walk_dir(..., live=True)&lt;/code&gt; turns the directory walker into a live source: it does an initial scan, then keeps watching the filesystem and pushes incremental updates downstream. Combined with &lt;code&gt;cocoindex update -L&lt;/code&gt;, the whole pipeline runs continuously instead of one-shot.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Live mode: one flag, everything else is the same&lt;/h2&gt;
&lt;p&gt;So far we&apos;ve described what happens on a single run. But in reality, source files get edited, rows get added and removed, and you usually want the topic to keep up. The same &lt;code&gt;process_csv&lt;/code&gt; runs as a &lt;strong&gt;catch-up run&lt;/strong&gt; (scan once, reconcile everything that&apos;s changed since last time, exit) &lt;em&gt;or&lt;/em&gt; as a continuously-running pipeline that keeps watching for changes. The diff between the two is one keyword argument and one CLI flag:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Catch-up run:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;files = localfs.walk_dir(
    localfs.FilePath(path=&quot;./data&quot;),
    path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.csv&quot;]),
)
await coco.mount_each(process_csv, files.items(), topic_target)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;files = localfs.walk_dir(
    localfs.FilePath(path=&quot;./data&quot;),
    path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.csv&quot;]),
    live=True,                 # ← +1 line
)
await coco.mount_each(process_csv, files.items(), topic_target)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update -L main.py   # ← +1 flag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the entire diff. &lt;code&gt;process_csv&lt;/code&gt; doesn&apos;t change. The Kafka target doesn&apos;t change. There&apos;s no separate &quot;streaming&quot; code path to maintain.&lt;/p&gt;
&lt;p&gt;Run the live version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update -L main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CocoIndex does a full scan first, publishes one message per row, then sits there watching &lt;code&gt;data/&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Edit one cell&lt;/strong&gt; in &lt;code&gt;products.csv&lt;/code&gt;: exactly one Kafka message is produced for that one row (modulo broker retries; the producer is at-least-once by default). The other four rows are silent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a new row&lt;/strong&gt;: one new message.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delete a row&lt;/strong&gt;: one delete message (no value, since this example doesn&apos;t supply a &lt;code&gt;deletion_value_fn&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a brand new CSV file&lt;/strong&gt;: &lt;code&gt;process_csv&lt;/code&gt; runs once for that file, publishing its rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delete a CSV file&lt;/strong&gt;: every row from that file gets a delete message.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;What the flag actually does (and doesn&apos;t do)&lt;/h3&gt;
&lt;p&gt;It&apos;s worth being precise about this, because it&apos;s easy to assume the flag is doing more magic than it is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reconciliation happens in both modes.&lt;/strong&gt; Whether you run &lt;code&gt;cocoindex update&lt;/code&gt; or &lt;code&gt;cocoindex update -L&lt;/code&gt;, you declare &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/target_state/&quot;&gt;target states&lt;/a&gt; and CocoIndex computes deltas against the previous run, emitting only the upsert and delete messages needed to bring the topic in line. To do that, CocoIndex maintains an &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/internal_storage/&quot;&gt;internal state store&lt;/a&gt; that remembers, for every declared key, the fingerprint of the value last sent. That store survives restarts, so when you stop and restart the pipeline it picks up where it left off: no re-broadcast of unchanged rows.&lt;/p&gt;
&lt;p&gt;This is also where the &lt;code&gt;ContextKey&lt;/code&gt; from earlier earns its keep. The state store doesn&apos;t identify &quot;this Kafka topic&quot; by SASL credentials or bootstrap address. It identifies it by the &lt;code&gt;ContextKey&lt;/code&gt; it was anchored to (here, &lt;code&gt;KAFKA_PRODUCER&lt;/code&gt;) plus the topic name. If you later rotate the SASL password, swap the broker endpoint, or replace the &lt;code&gt;AIOProducer&lt;/code&gt; with a differently configured one, the same &lt;code&gt;ContextKey&lt;/code&gt; is enough for CocoIndex to recognize the backend as the same one, and the state store carries over.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The flag controls when reconciliation runs.&lt;/strong&gt; That&apos;s it. In a catch-up run, CocoIndex scans sources once, reconciles up to the moment, and exits. In live mode, it does the same initial catch-up, &lt;em&gt;then&lt;/em&gt; keeps watching the sources for changes and reconciles incrementally as they arrive. The code path, reconciliation logic, and target API are identical: just catch-up-and-exit vs. catch-up-and-keep-watching.&lt;/p&gt;
&lt;p&gt;The &quot;watching&quot; half comes from the source itself, not the flag. &lt;code&gt;localfs.walk_dir(..., live=True)&lt;/code&gt; returns a &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/live_component/&quot;&gt;live source&lt;/a&gt; backed by &lt;a href=&quot;https://github.com/samuelcolvin/watchfiles&quot;&gt;&lt;code&gt;watchfiles&lt;/code&gt;&lt;/a&gt;; it knows how to deliver filesystem events. A non-live source (or &lt;code&gt;live=False&lt;/code&gt;) just enumerates current state and stops. The CLI &lt;code&gt;-L&lt;/code&gt; flag tells the runtime to keep the app alive so the watcher&apos;s events have something to drive.&lt;/p&gt;
&lt;p&gt;One Kafka detail worth mentioning since the connector doesn&apos;t change it: each declared key is hashed to a partition by Kafka&apos;s default partitioner, so the same key always lands on the same partition. Log compaction and key-based consumers behave exactly the way they&apos;d behave with any hand-rolled producer.&lt;/p&gt;
&lt;h2&gt;What do the messages look like on the topic?&lt;/h2&gt;
&lt;p&gt;We pointed this at a Kafka cluster on &lt;a href=&quot;https://streamnative.io/&quot;&gt;StreamNative Cloud&lt;/a&gt;. It gave us a real SASL_SSL endpoint in one click, with a hosted console for inspecting messages without writing a consumer. (Plain &lt;code&gt;localhost:9092&lt;/code&gt; works too if you skip the SASL fields in the producer config.)&lt;/p&gt;
&lt;p&gt;Here&apos;s what shows up in the console for the &lt;code&gt;cocoindex-csv-rows&lt;/code&gt; topic after running the example:&lt;/p&gt;
&lt;p&gt;Keys are the row&apos;s primary-key column (&lt;code&gt;SKU001&lt;/code&gt;, &lt;code&gt;E101&lt;/code&gt;, …); values are the JSON-encoded rows. Edit a CSV locally, refresh, and a new message with the same key appears: same key, latest value wins, log-compacted consumers get exactly the current state.&lt;/p&gt;
&lt;h2&gt;Try it&lt;/h2&gt;
&lt;p&gt;The Kafka connector ships as an optional dependency group: &lt;code&gt;pip install cocoindex[kafka]&lt;/code&gt; if you&apos;re pulling it into an existing project. To run the example directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/cocoindex-io/cocoindex
cd cocoindex/examples/csv_to_kafka
cp .env.example .env  # fill in your Kafka bootstrap + SASL creds
pip install -e .
cocoindex update -L main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No cloud broker handy? Point &lt;code&gt;KAFKA_BOOTSTRAP_SERVERS&lt;/code&gt; at &lt;code&gt;localhost:9092&lt;/code&gt; and leave the SASL fields empty. The example works against any Kafka the &lt;code&gt;confluent_kafka&lt;/code&gt; client can connect to.&lt;/p&gt;
&lt;p&gt;Then go edit &lt;code&gt;data/products.csv&lt;/code&gt; and watch the messages land.&lt;/p&gt;
&lt;h2&gt;Support our work&lt;/h2&gt;
&lt;p&gt;If this project helps you, we&apos;d appreciate a GitHub star at the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex project&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>feature</category><category>examples</category><category>connectors</category><category>incremental-processing</category><category>ai-agents</category><author>George He</author></item><item><title>CocoIndex V1 is Live!</title><link>https://cocoindex.io/blogs/cocoindex-v1/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-v1/</guid><description>CocoIndex V1 is live: a ground-up redesign of incremental data pipelines, built for AI engineers and agent builders shipping RAG, memory, and knowledge graphs.</description><pubDate>Wed, 22 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; is an incremental engine for long-horizon agents. It continuously transforms data from any source (codebase, meeting notes) and builds the context for agents deployed in production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CocoIndex V1 is now live&lt;/strong&gt;. It is a fundamental redesign of how you write incremental data pipelines, built from a year of watching what people actually wanted to do with CocoIndex. CocoIndex V1 is built for AI engineers and agent builders: people building context, RAG, memory, knowledge graphs that live agents depend on. The design choice that matters most to that audience is deliberately small: the framework meets you in the language you already write, with the data types you already use. If Claude or Cursor can already fluently write it, it works here.&lt;/p&gt;
&lt;p&gt;The headline change is simple: &lt;strong&gt;there is no DSL anymore&lt;/strong&gt;. Your whole pipeline is now regular &lt;code&gt;async&lt;/code&gt; Python, growing organically as functions call each other. The engine still does what CocoIndex has always done (managed targets, incremental processing, change tracking in Rust), but it now lives behind a Python-native surface, not behind a DSL with its own type system and its own rules about what you can and can&apos;t do.&lt;/p&gt;
&lt;p&gt;The mental model is unchanged, and it&apos;s the clearest way to describe what CocoIndex does: you declare what the &lt;em&gt;target&lt;/em&gt; should look like as a function of the &lt;em&gt;source&lt;/em&gt;, and the engine figures out the transitions. CocoIndex calls this &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;&lt;strong&gt;state-driven programming&lt;/strong&gt;&lt;/a&gt;, the same shape as React, spreadsheets, or SQL materialized views. Declare the state; Coco handles the rest.&lt;/p&gt;
&lt;h2&gt;Why CocoIndex: the incremental engine for long-horizon agents&lt;/h2&gt;
&lt;p&gt;Agents run roughly &lt;span&gt;50× faster&lt;/span&gt; than humans, but the tools they rely on were built for &lt;em&gt;human speed&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;At &lt;a href=&quot;https://www.nvidia.com/en-us/on-demand/session/gtc26-s82167/&quot;&gt;GTC 2026&lt;/a&gt;, Jeff Dean and Bill Dally named a bottleneck that&apos;s about to reshape every piece of infrastructure around AI. Amdahl&apos;s law takes over: no matter how fast the model gets, end-to-end throughput is capped by the slowest thing in the loop. Jeff&apos;s specific emphasis was on ultra-low-latency inference for agents &quot;operating in the background,&quot; running tasks that &quot;take hours or perhaps even days, independently doing a bunch of things, correcting themselves, doing some more things.&quot;&lt;/p&gt;
&lt;p&gt;Data infrastructure is one of those tools, and it matters beyond inference. An agent reasoning over a codebase, a conversation graph, a document corpus, or a stream of events needs that data &lt;strong&gt;fresh, organized, and cheap to query&lt;/strong&gt;, not just on the first call, but throughout the run, because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Agents write code.&lt;/strong&gt; The artifacts they produce become source data for the next reasoning step.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agents make decisions and record them.&lt;/strong&gt; Traces, plans, audit logs: written by agents, read by other agents.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agents update data while they run.&lt;/strong&gt; A long-horizon agent isn&apos;t a one-shot query; it&apos;s a process that edits its own context as it proceeds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Source data arrives faster, too.&lt;/strong&gt; Codebases, logs, Slack, PRs, tickets, sensor feeds: all updating in the background, all expected to be visible to whatever the agent is doing next.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The default pattern for indexing that data (&quot;batch-rebuild overnight&quot;) was built for humans who check a dashboard every morning. For agents running 50x faster, that cadence &lt;em&gt;is&lt;/em&gt; the bottleneck. What agents need is an engine that treats &lt;strong&gt;derived data as a function of source data&lt;/strong&gt; and keeps it in sync &lt;strong&gt;incrementally&lt;/strong&gt;: only what actually changed gets re-embedded, re-upserted, or re-published, with no full rebuild and no waiting for the next batch window.&lt;/p&gt;
&lt;p&gt;That has always been CocoIndex&apos;s job. V1 makes it the &lt;em&gt;right shape&lt;/em&gt; for agent-era workloads: the same incremental, state-driven guarantees, but now expressive enough to cover the pipeline shapes agents actually produce (entity resolution, clustering, multi-phase reduction, per-tenant topologies, conditional targets) instead of just &quot;chunk → embed → upsert.&quot; Every pattern in the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples&quot;&gt;examples gallery&lt;/a&gt; is something a long-horizon agent might want to run itself, and have its outputs become fresh source data for the next agent, without a human babysitting the refresh job.&lt;/p&gt;
&lt;h2&gt;What is an incremental engine, and why it&apos;s hard to build for production&lt;/h2&gt;
&lt;p&gt;An incremental engine does one job: keep derived data in sync with source data without redoing work that&apos;s already been done. The source data is changing and the processing code is dynamic.&lt;/p&gt;
&lt;p&gt;To do it right, you need: reliable change detection across heterogeneous sources (filesystems, databases, queues, APIs), content fingerprints stable enough to diff but cheap enough to compute every run, persistent state that records what the last run produced so this run can compare against it, memoization keyed on &lt;em&gt;both&lt;/em&gt; data and code fingerprints so that editing a helper function invalidates only the callers that actually depended on it, managed target lifecycles (schema evolution, orphan cleanup when a source disappears, idempotent upserts), transactional behavior when you&apos;re writing to multiple targets at once, and recovery logic for every partial failure in between, and many more.&lt;/p&gt;
&lt;p&gt;Teams that take this on seriously typically allocate 10 – 20 engineers for at least six months to land the first production-worthy version, and then keep paying for maintenance indefinitely as sources, targets, and schemas evolve. CocoIndex ships all of this in the engine, so the code you write is the pipeline itself, not the scaffolding around it.&lt;/p&gt;
&lt;p&gt;Notion has recently &lt;a href=&quot;https://www.notion.com/blog/two-years-of-vector-search-at-notion&quot;&gt;published&lt;/a&gt; their engineering work around the tip of the iceberg on maintaining an in-house data pipeline with incremental processing on simpler data transformation (e.g., without clustering, etc.) for live agents.&lt;/p&gt;
&lt;h2&gt;The V1 mental model: declare states, not steps&lt;/h2&gt;
&lt;div&gt;
&lt;/div&gt;
&lt;p&gt;The mental model didn&apos;t change. The execution model did.&lt;/p&gt;
&lt;p&gt;CocoIndex has always been a &lt;strong&gt;state-driven&lt;/strong&gt; framework. You don&apos;t write code that says &quot;compute this delta and apply it&quot;; you describe what the &lt;em&gt;target&lt;/em&gt; should look like as a function of the &lt;em&gt;source&lt;/em&gt;, and the engine works out the transitions. If you&apos;ve used React, spreadsheets, or SQL materialized views, you already know the shape of this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;React&lt;/strong&gt;: declare UI as a function of state → React re-renders what changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spreadsheets&lt;/strong&gt;: declare formulas → cells recompute when inputs change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CocoIndex&lt;/strong&gt;: declare target states as a function of source → engine syncs what changed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What&apos;s new in V1 is &lt;em&gt;how&lt;/em&gt; you express that declaration. The whole pipeline is now plain &lt;code&gt;async&lt;/code&gt; Python, without &lt;code&gt;FlowBuilder&lt;/code&gt;, &lt;code&gt;DataScope&lt;/code&gt;, or a two-phase &quot;define, then run&quot; lifecycle. Functions call other functions. Loops are loops. &lt;code&gt;if&lt;/code&gt; statements are &lt;code&gt;if&lt;/code&gt; statements. Behind the scenes, CocoIndex tracks every target state you declare and every memoized intermediate value, so the next run only does what changed.&lt;/p&gt;
&lt;p&gt;V1 is deliberately not intrusive. You can start with a single &lt;code&gt;@coco.fn&lt;/code&gt; on a plain Python function and opt into more of the engine as the pipeline grows: flip on &lt;code&gt;memo=True&lt;/code&gt; when re-running starts to cost real time, add &lt;code&gt;declare_*&lt;/code&gt; targets when you want the engine to own the output lifecycle, move to &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/live_mode/&quot;&gt;live mode&lt;/a&gt; when you want the pipeline to keep watching for changes. There&apos;s no &quot;set up the framework first&quot; step and no ceremony you have to write before you get value. You turn the incremental knobs on when you actually need them.&lt;/p&gt;
&lt;p&gt;That model rests on a small vocabulary: an &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/app/&quot;&gt;&lt;strong&gt;App&lt;/strong&gt;&lt;/a&gt; is the top-level entity you run; inside it, each &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;&lt;strong&gt;processing component&lt;/strong&gt;&lt;/a&gt; groups one item&apos;s work with its &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/target_state/&quot;&gt;&lt;strong&gt;target states&lt;/strong&gt;&lt;/a&gt; and runs independently; &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;&lt;strong&gt;incremental processing&lt;/strong&gt;&lt;/a&gt; means only what changed gets reapplied to the target; and &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;&lt;strong&gt;function memoization&lt;/strong&gt;&lt;/a&gt; skips any function whose input and code haven&apos;t changed since the last run.&lt;/p&gt;
&lt;p&gt;What that means in practice is code you don&apos;t have to write: a bookkeeping table tracking which file was last processed, insert / update / delete branches, &quot;did I already embed this chunk?&quot; checks, migration scripts when your schema changes. The engine owns the target state, diffs against the previous run, and only does what changed. V0 made that tractable. V1 makes it feel like writing native Python.&lt;/p&gt;
&lt;p&gt;A complete V1 app (a PDF-to-Markdown converter) is short enough to read at a glance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import pathlib
import cocoindex as coco
from cocoindex.connectors import localfs
from cocoindex.resources.file import PatternFilePathMatcher
from docling.document_converter import DocumentConverter

_converter = DocumentConverter()

@coco.fn(memo=True)
def process_file(file: localfs.File, outdir: pathlib.Path) -&amp;gt; None:
    markdown = _converter.convert(file.file_path.resolve()).document.export_to_markdown()
    outname = file.file_path.path.stem + &quot;.md&quot;
    localfs.declare_file(outdir / outname, markdown, create_parent_dirs=True)

@coco.fn
async def app_main(sourcedir: pathlib.Path, outdir: pathlib.Path) -&amp;gt; None:
    files = localfs.walk_dir(
        sourcedir, path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.pdf&quot;]),
    )
    await coco.mount_each(process_file, files.items(), outdir)

app = coco.App(&quot;PdfToMarkdown&quot;, app_main,
               sourcedir=pathlib.Path(&quot;./pdf_files&quot;),
               outdir=pathlib.Path(&quot;./out&quot;))
app.update_blocking()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;process_file&lt;/code&gt; is just a function: you can set a breakpoint in it, call it directly, run it under pytest, whatever. The &lt;code&gt;@coco.fn(memo=True)&lt;/code&gt; decorator hooks it into the engine&apos;s change-detection and memoization, but doesn&apos;t change its calling convention.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;localfs.declare_file(...)&lt;/code&gt; is a &lt;em&gt;declaration&lt;/em&gt;, not a write. It tells CocoIndex &quot;the target state for this path is this content.&quot; If the target state hasn&apos;t changed since the last run, nothing happens on disk. If the source file disappears, the declaration disappears too, and CocoIndex deletes the corresponding output file automatically.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coco.mount_each(...)&lt;/code&gt; mounts one &lt;strong&gt;processing component&lt;/strong&gt; per file. Each component is its own unit of incremental change detection: when one file&apos;s content changes, only that component re-runs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That&apos;s the whole programming model. Everything below is consequences.&lt;/p&gt;
&lt;h2&gt;Why V1: the V0 ceiling&lt;/h2&gt;
&lt;p&gt;V0 worked (developers shipped real pipelines in production), but four constraints kept coming back, all consequences of the DSL-first design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Two worlds, one program.&lt;/strong&gt; &lt;code&gt;FlowBuilder&lt;/code&gt; / &lt;code&gt;DataScope&lt;/code&gt; / &lt;code&gt;DataSlice&lt;/code&gt; for topology, regular Python for leaves. The DSL world couldn&apos;t &lt;code&gt;if&lt;/code&gt; its way into a different shape, couldn&apos;t pass values into normal Python and back. Clustering, entity resolution, reduction across items, conditional sources: every one of them hit the seam.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A separate type system.&lt;/strong&gt; Like a database, the engine had its own types distinct from Python&apos;s, so every Python type you wanted to use needed a bi-directional conversion wired in. &lt;code&gt;dataclass&lt;/code&gt; and &lt;code&gt;NDArray&lt;/code&gt; were covered; a PIL &lt;code&gt;Image&lt;/code&gt; or a pyarrow array was not: if no conversion existed, you couldn&apos;t hand it to a function, and what did cross the boundary got marshaled on the way in and out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Postgres as a hard dependency.&lt;/strong&gt; The engine used Postgres for its own bookkeeping, so &lt;code&gt;pip install cocoindex &amp;amp;&amp;amp; python script.py&lt;/code&gt; wasn&apos;t a real path: you stood up a database first, even for a tiny local pipeline.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Static topology.&lt;/strong&gt; &lt;code&gt;add_source()&lt;/code&gt; and &lt;code&gt;.export()&lt;/code&gt; were declared before execution, so multi-tenancy, config-driven topologies, and runtime target selection all required scaffolding &lt;em&gt;around&lt;/em&gt; CocoIndex rather than being expressible &lt;em&gt;in&lt;/em&gt; it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Fixing any one of these meant changing the design.&lt;/p&gt;
&lt;h2&gt;Four shifts that change what you can build&lt;/h2&gt;
&lt;h3&gt;1. No more DSL — the flow grows during execution&lt;/h3&gt;
&lt;p&gt;In V0, the flow had to be fully described before any data moved through it. &lt;code&gt;FlowBuilder&lt;/code&gt; returned a frozen graph; the engine then interpreted that graph at execution time. In V1, the flow &lt;em&gt;is&lt;/em&gt; the execution. &lt;code&gt;app_main&lt;/code&gt; runs as the root processing component. Every &lt;code&gt;await coco.mount(...)&lt;/code&gt; it does adds a child component. Every &lt;code&gt;declare_*&lt;/code&gt; call records a target state. The component tree is built by running your code, not by parsing a graph beforehand.&lt;/p&gt;
&lt;p&gt;This unlocks shapes that were awkward or impossible in V0:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reduction patterns&lt;/strong&gt;: &quot;for each project, look at all its files, then aggregate.&quot; The &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/multi_codebase_summarization&quot;&gt;multi-codebase summarization example&lt;/a&gt; does exactly this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_project(project_name, files, output_dir):
    file_infos = await coco.map(extract_file_info, files)         # fan out
    project_info = await aggregate_project_info(project_name, file_infos)  # reduce
    markdown = generate_markdown(project_name, project_info, file_infos)
    localfs.declare_file(output_dir / f&quot;{project_name}.md&quot;, markdown)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Multi-phase pipelines with cross-item logic&lt;/strong&gt;: entity resolution across sessions, clustering, deduplication. The &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/conversation_to_knowledge&quot;&gt;conversation-to-knowledge example&lt;/a&gt; uses three phases (per-session extraction, cross-session entity resolution, knowledge-base assembly) all expressed as function calls in a single &lt;code&gt;app_main&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Conditional topology&lt;/strong&gt;: &lt;code&gt;if config.enable_kafka: await mount_kafka_target(...)&lt;/code&gt;. No flag-pattern, no separate flow definitions.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And because everything is real Python code, debugging is real Python debugging. Set a breakpoint. Step through. Print things. The engine&apos;s not interpreting an opaque graph; it&apos;s calling your functions.&lt;/p&gt;
&lt;h3&gt;2. Native Python types — no separate type system&lt;/h3&gt;
&lt;p&gt;V1 doesn&apos;t have a separate type system: it uses Python&apos;s directly. The types v0 supported through bindings (&lt;code&gt;dataclass&lt;/code&gt;, &lt;code&gt;Pydantic&lt;/code&gt;, &lt;code&gt;NDArray&lt;/code&gt;, &lt;code&gt;dict&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;tuple&lt;/code&gt;) still work with nothing to wire up, and types v0 couldn&apos;t express (a PIL &lt;code&gt;Image&lt;/code&gt;, a pyarrow array, an arbitrary class from a library you just pulled in) can be passed as function arguments the same way:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from PIL import Image

@coco.fn(memo=True)
async def caption_image(img: Image) -&amp;gt; str:
    return await vlm.describe(img)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;Image&lt;/code&gt; is just &lt;code&gt;PIL.Image&lt;/code&gt;: no wrapper, no conversion. Pass the function a pyarrow &lt;code&gt;Table&lt;/code&gt;, a &lt;code&gt;torch.Tensor&lt;/code&gt;, or any class from a library you happen to have imported; the signature is whatever you&apos;d write in plain Python.&lt;/p&gt;
&lt;p&gt;Targets, function arguments, return types, memoization keys: everything is described in the type hints you&apos;d write anyway. For memoized return values that persist between runs, the same type hint drives serialization; we wrote a long &lt;a href=&quot;https://cocoindex.io/blogs/type-guided-serde&quot;&gt;post on how the type-guided serializer works&lt;/a&gt; underneath.&lt;/p&gt;
&lt;h3&gt;3. Embedded LMDB — &lt;code&gt;pip install&lt;/code&gt; and go&lt;/h3&gt;
&lt;p&gt;V0&apos;s &quot;Postgres for engine bookkeeping&quot; requirement is gone. V1 stores its internal state in &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/internal_storage/&quot;&gt;LMDB&lt;/a&gt;, an embedded key-value store that lives in a single local file. There&apos;s no server process to run, schema to migrate, or port to expose.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install cocoindex
cocoindex update main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the whole setup. The LMDB file holds the target-state ledger, the memoization cache, and the component-path tree across runs. On a 64-bit system the default 4 GiB map size is virtual address space, not physical memory. You can &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/internal_storage/&quot;&gt;bump it to 8 GiB or more&lt;/a&gt; without paying the cost upfront.&lt;/p&gt;
&lt;p&gt;Postgres is still a first-class &lt;strong&gt;target&lt;/strong&gt; (with pgvector support); it&apos;s just no longer required for the engine to function.&lt;/p&gt;
&lt;h3&gt;4. Dynamic sources and targets&lt;/h3&gt;
&lt;p&gt;In V0, sources and targets were declared in the flow definition, before execution. In V1, they&apos;re created during execution, by regular function calls, and the &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;component-path tree&lt;/a&gt; tracks their identity across runs. So you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mount a target conditionally&lt;/strong&gt; based on a config row, an environment variable, or a feature flag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create different targets per item&lt;/strong&gt;: e.g., one Postgres schema per tenant, one Kafka topic per category, one S3 prefix per dataset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build the topology from a database&lt;/strong&gt;: query the list of active tenants at startup and mount one component per tenant, each with its own set of targets. When a tenant disappears from the list, CocoIndex automatically cleans up their target states (because the path is no longer mounted).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/conversation_to_knowledge&quot;&gt;conversation-to-knowledge example&lt;/a&gt; does this for entity types: it iterates over a list of entity configurations and mounts one SurrealDB target per type, all from inside a normal &lt;code&gt;for&lt;/code&gt; loop in &lt;code&gt;app_main&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;entity_tables = {
    cfg.name: await surrealdb.mount_table_target(SURREAL_DB, cfg.name, entity_schema)
    for cfg in ENTITY_TYPES
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add a new entity type to &lt;code&gt;ENTITY_TYPES&lt;/code&gt;, restart, and a new table appears, managed by CocoIndex from then on. Remove a type, and its table goes away. No migration, no orphaned data.&lt;/p&gt;
&lt;p&gt;The real leap over v0 is when that list isn&apos;t a constant at all (a changing file, a database row, a live tenant registry), since v0 could already generate topology from static inputs at flow-creation time, but couldn&apos;t react when those inputs changed.&lt;/p&gt;
&lt;h2&gt;What we kept: the core promise&lt;/h2&gt;
&lt;p&gt;The redesign was the means, not the goal. The reason V1 exists is to make the things V0 was already good at &lt;em&gt;more&lt;/em&gt; available. Those things are still here, and now they apply across a much wider set of pipeline shapes.&lt;/p&gt;
&lt;h3&gt;Fully managed targets&lt;/h3&gt;
&lt;p&gt;You declare what your target should look like; CocoIndex makes sure it&apos;s in that state, on &lt;em&gt;any&lt;/em&gt; change, whether to source data or to your code. This is the lifecycle, top to bottom:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Upsert (first or changed declaration)&lt;/th&gt;
&lt;th&gt;Delete (no longer declared)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container&lt;/strong&gt;: schema (tables · directories · other containers)&lt;/td&gt;
&lt;td&gt;Create or alter&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Leaf&lt;/strong&gt;: data (rows · files · embeddings · other leaves)&lt;/td&gt;
&lt;td&gt;Insert or update&lt;/td&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No manual migrations. No orphaned data. No &quot;did I already write this row?&quot; code paths. The target is fully owned by CocoIndex: if you stop declaring something, it goes away. This is the same contract whether the target is a Postgres table, a LanceDB collection, a Neo4j graph node, a Kafka topic, an S3 prefix, or a directory of files on disk.&lt;/p&gt;
&lt;h3&gt;Incremental processing, two levels deep&lt;/h3&gt;
&lt;p&gt;Incremental processing is still the core of the engine, and V1 exposes it through two concepts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Components own target states.&lt;/strong&gt; When a component finishes, CocoIndex diffs its declared target states against the last run and applies only the changes (create / alter / drop for containers, insert / update / delete for contents), including recursive cleanup of sub-paths no longer mounted. When a component&apos;s inputs and code haven&apos;t changed, the whole component is skipped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@coco.fn&lt;/code&gt; functions participate in change detection.&lt;/strong&gt; Every decorated function&apos;s code is fingerprinted, and that fingerprint propagates up the call chain. Add &lt;code&gt;memo=True&lt;/code&gt; and the return value is also cached, keyed on the function&apos;s arguments plus its code fingerprint, so the body is skipped when neither has changed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Edit a helper function and only the components and memoized callers that actually depend on it are invalidated.&lt;/p&gt;
&lt;h3&gt;Rust-powered engine&lt;/h3&gt;
&lt;p&gt;The change-detection, fingerprinting, target-state diffing, and persistence are all written in Rust on top of Tokio. That&apos;s still true: Python is the user-facing API, but the hot paths run in Rust. You get pythonic ergonomics without giving up the performance characteristics that made V0 feel snappy.&lt;/p&gt;
&lt;h2&gt;What it looks like end-to-end&lt;/h2&gt;
&lt;p&gt;Here&apos;s a complete text-embedding pipeline that walks markdown files, chunks them, embeds the chunks with a SentenceTransformer model, and writes everything (with a vector index) to Postgres. It&apos;s the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/text_embedding&quot;&gt;&lt;code&gt;text_embedding&lt;/code&gt; example&lt;/a&gt; trimmed down: every line of pipeline logic is below.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import pathlib
from dataclasses import dataclass
from typing import AsyncIterator, Annotated

import asyncpg
from numpy.typing import NDArray

import cocoindex as coco
from cocoindex.connectors import localfs, postgres
from cocoindex.ops.text import RecursiveSplitter
from cocoindex.ops.sentence_transformers import SentenceTransformerEmbedder
from cocoindex.resources.chunk import Chunk
from cocoindex.resources.file import FileLike, PatternFilePathMatcher
from cocoindex.resources.id import IdGenerator

PG_DB = coco.ContextKey[asyncpg.Pool](&quot;text_embedding_db&quot;)
EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder](&quot;embedder&quot;, detect_change=True)

_splitter = RecursiveSplitter()

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -&amp;gt; AsyncIterator[None]:
    async with await postgres.create_pool(&quot;postgres://...&quot;) as pool:
        builder.provide(PG_DB, pool)
        builder.provide(EMBEDDER, SentenceTransformerEmbedder(&quot;sentence-transformers/all-MiniLM-L6-v2&quot;))
        yield

@dataclass
class DocEmbedding:
    id: int
    filename: str
    chunk_start: int
    chunk_end: int
    text: str
    embedding: Annotated[NDArray, EMBEDDER]

@coco.fn
async def process_chunk(chunk, filename, id_gen, table):
    table.declare_row(row=DocEmbedding(
        id=await id_gen.next_id(chunk.text),
        filename=str(filename),
        chunk_start=chunk.start.char_offset,
        chunk_end=chunk.end.char_offset,
        text=chunk.text,
        embedding=await coco.use_context(EMBEDDER).embed(chunk.text),
    ))

@coco.fn(memo=True)
async def process_file(file: FileLike, table) -&amp;gt; None:
    text = await file.read_text()
    chunks = _splitter.split(text, chunk_size=2000, chunk_overlap=500, language=&quot;markdown&quot;)
    id_gen = IdGenerator()
    await coco.map(process_chunk, chunks, file.file_path.path, id_gen, table)

@coco.fn
async def app_main(sourcedir: pathlib.Path) -&amp;gt; None:
    table = await postgres.mount_table_target(
        PG_DB, table_name=&quot;doc_embeddings&quot;,
        table_schema=await postgres.TableSchema.from_class(DocEmbedding, primary_key=[&quot;id&quot;]),
    )
    table.declare_vector_index(column=&quot;embedding&quot;)

    files = localfs.walk_dir(
        sourcedir, recursive=True,
        path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.md&quot;]),
    )
    await coco.mount_each(process_file, files.items(), table)

app = coco.App(coco.AppConfig(name=&quot;TextEmbedding&quot;), app_main,
               sourcedir=pathlib.Path(&quot;./markdown_files&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Worth pausing on what&apos;s &lt;em&gt;not&lt;/em&gt; in there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No flow definition phase. &lt;code&gt;app_main&lt;/code&gt; runs and the topology emerges.&lt;/li&gt;
&lt;li&gt;No custom column types. &lt;code&gt;embedding: Annotated[NDArray, EMBEDDER]&lt;/code&gt; is the schema; &lt;code&gt;from_class&lt;/code&gt; reads it.&lt;/li&gt;
&lt;li&gt;No insert / update / delete branches. &lt;code&gt;declare_row(...)&lt;/code&gt; describes the desired state; the engine diffs.&lt;/li&gt;
&lt;li&gt;No &quot;did this chunk already get embedded?&quot; check. Memoization handles it.&lt;/li&gt;
&lt;li&gt;No bookkeeping table for which file was last processed. LMDB has the previous run&apos;s fingerprints.&lt;/li&gt;
&lt;li&gt;No manual migration if you change &lt;code&gt;DocEmbedding&lt;/code&gt;. Schema evolution is part of the managed-target contract.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Add a markdown file → its chunks get embedded and inserted. Edit one paragraph → only the affected chunks re-embed; the others reuse cached embeddings. Delete a file → its rows vanish from Postgres. Change the splitter&apos;s &lt;code&gt;chunk_size&lt;/code&gt; → every file re-chunks, but &lt;code&gt;embed(chunk)&lt;/code&gt; calls whose chunk text didn&apos;t change still hit the memo cache. Change the embedder model → because &lt;code&gt;EMBEDDER&lt;/code&gt; is &lt;code&gt;detect_change=True&lt;/code&gt;, every embedding gets recomputed and the column re-syncs.&lt;/p&gt;
&lt;p&gt;That&apos;s the whole thing. None of those scenarios need a separate code path.&lt;/p&gt;
&lt;h2&gt;Living shapes V0 couldn&apos;t reach&lt;/h2&gt;
&lt;p&gt;A short tour of pipeline shapes the V1 model makes natural, drawn from the example gallery:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/multi_codebase_summarization&quot;&gt;Multi-codebase summarization&lt;/a&gt;&lt;/strong&gt;: fan out per project, fan out per file with &lt;code&gt;coco.map&lt;/code&gt;, reduce back up to a project summary, write a markdown file per project. Pure Python control flow; the engine handles which projects need re-summarizing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/conversation_to_knowledge&quot;&gt;Conversation-to-knowledge&lt;/a&gt;&lt;/strong&gt;: three phases in one &lt;code&gt;app_main&lt;/code&gt;: per-session extraction (&lt;code&gt;mount&lt;/code&gt; per YouTube ID), cross-session entity resolution (&lt;code&gt;mount&lt;/code&gt; per entity type), and knowledge-base assembly (one final &lt;code&gt;mount&lt;/code&gt;). Each phase reads the outputs of the prior one through normal function returns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/csv_to_kafka&quot;&gt;CSV → Kafka live&lt;/a&gt;&lt;/strong&gt;: a live filesystem watcher feeding a Kafka topic target with at-most-once-per-change semantics, in about 60 lines. File-level source, row-level change events: one Kafka message per changed CSV row.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/hn_trending_topics&quot;&gt;HN trending topics&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/image_search&quot;&gt;image search&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/pdf_embedding&quot;&gt;PDF embedding&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/paper_metadata&quot;&gt;paper metadata extraction&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/amazon_s3_embedding&quot;&gt;Amazon S3 + embeddings&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/gdrive_text_embedding&quot;&gt;Google Drive + embeddings&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/code_embedding_lancedb&quot;&gt;code embedding to LanceDB&lt;/a&gt;&lt;/strong&gt;: the connector ecosystem that landed during V0 came over to V1, and a few new ones (SurrealDB, SQLite, Kafka, S3) joined it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Try it&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install cocoindex
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then either follow the &lt;a href=&quot;https://cocoindex.io/docs/getting_started/quickstart/&quot;&gt;Quickstart&lt;/a&gt; (PDF → Markdown in about thirty lines), or clone the repo and run any of the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples&quot;&gt;examples&lt;/a&gt; directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/cocoindex-io/cocoindex
cd cocoindex/examples/text_embedding
pip install -e .
cocoindex update main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The docs for V1 live at &lt;a href=&quot;https://cocoindex.io/docs/&quot;&gt;cocoindex.io/docs&lt;/a&gt;. The pieces most worth reading first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;&lt;strong&gt;Core Concepts&lt;/strong&gt;&lt;/a&gt;: state-driven processing, target states, processing components, memoization&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/app/&quot;&gt;&lt;strong&gt;App&lt;/strong&gt;&lt;/a&gt;: top-level entry point, lifespan, CLI&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;&lt;strong&gt;Processing Component&lt;/strong&gt;&lt;/a&gt;: &lt;code&gt;mount&lt;/code&gt;, &lt;code&gt;use_mount&lt;/code&gt;, &lt;code&gt;mount_each&lt;/code&gt;, component paths&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;&lt;strong&gt;Function&lt;/strong&gt;&lt;/a&gt;: &lt;code&gt;@coco.fn&lt;/code&gt;, memoization, change detection&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/live_mode/&quot;&gt;&lt;strong&gt;Live Mode&lt;/strong&gt;&lt;/a&gt;: pipelines that keep watching for changes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Bug reports, feature requests, and &quot;this section of the docs is unclear&quot; notes are all very welcome on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/issues&quot;&gt;GitHub issues&lt;/a&gt;, and we hang out in &lt;a href=&quot;https://discord.com/invite/zpA9S2DR7s&quot;&gt;Discord&lt;/a&gt;. ⭐ us on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt; if you want to follow where this goes. There&apos;s a lot more shipping over the next few months.&lt;/p&gt;
&lt;h2&gt;Support us&lt;/h2&gt;
&lt;p&gt;If you found this useful, starring &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex on GitHub&lt;/a&gt; is the most direct way to help. It&apos;s how other developers find the project :)&lt;/p&gt;
</content:encoded><category>announcement</category><category>feature</category><category>incremental-processing</category><category>architecture</category><category>ai-agents</category><author>Linghua Jin, George He</author></item><item><title>Turn Podcasts into a Knowledge Graph with LLM and CocoIndex</title><link>https://cocoindex.io/blogs/podcast-to-knowledge-graph/</link><guid isPermaLink="true">https://cocoindex.io/blogs/podcast-to-knowledge-graph/</guid><description>Build a pipeline that turns YouTube podcasts into a knowledge graph: extract speakers, statements, and entities with an LLM, then dedupe them with embeddings.</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Podcasts are one of the richest sources of expert knowledge on the internet. A single Lex Fridman or Dwarkesh Patel episode can contain dozens of substantive claims about people, technologies, and organizations, but it&apos;s all locked inside hours of audio. You can&apos;t query any of it. You can&apos;t cross-reference what two different guests said about the same topic.&lt;/p&gt;
&lt;p&gt;In this post, we&apos;ll build a &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; pipeline that turns YouTube podcast episodes into a queryable &lt;a href=&quot;https://cocoindex.io/blogs/knowledge-graph-for-docs&quot;&gt;knowledge graph&lt;/a&gt;. The pipeline downloads audio, transcribes with speaker diarization, &lt;a href=&quot;https://cocoindex.io/docs/ops/litellm/&quot;&gt;uses an LLM to extract&lt;/a&gt; structured statements and entities, resolves duplicates across episodes, and stores everything in &lt;a href=&quot;https://github.com/surrealdb/surrealdb&quot;&gt;SurrealDB&lt;/a&gt; as a graph.&lt;/p&gt;
&lt;p&gt;We use &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; to build the pipeline. CocoIndex is a data indexing framework for building &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;incremental data transformation pipelines&lt;/a&gt;: it tracks what&apos;s been processed, so re-running the pipeline only processes new or changed episodes. It also made it simple to take out any unwanted podcast content that gets merged into the knowledge graph.&lt;/p&gt;
&lt;p&gt;The full source code is available at &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/conversation_to_knowledge&quot;&gt;CocoIndex Examples: podcast_to_knowledge&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What We&apos;re Building&lt;/h2&gt;
&lt;p&gt;Here&apos;s the knowledge graph schema, five node types connected by four relationship types:&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;session&lt;/strong&gt; is one podcast episode. A &lt;strong&gt;statement&lt;/strong&gt; is a thematic claim extracted from the conversation, e.g., &quot;Scaling laws suggest that larger models will continue to improve.&quot; Each statement is linked to who said it and what entities it mentions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Person&lt;/strong&gt;, &lt;strong&gt;tech&lt;/strong&gt;, and &lt;strong&gt;org&lt;/strong&gt; are named entities. The tricky part is that the same entity can appear under different names across episodes (&quot;GPT-4&quot;, &quot;GPT4&quot;, &quot;OpenAI&apos;s GPT-4&quot;). We handle this with &lt;a href=&quot;https://cocoindex.io/docs/ops/entity_resolution/&quot;&gt;entity resolution&lt;/a&gt;, more on that later.&lt;/p&gt;
&lt;h2&gt;Pipeline Overview&lt;/h2&gt;
&lt;p&gt;The pipeline runs in three phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1&lt;/strong&gt; processes each episode independently: download audio, transcribe, and use an LLM to extract metadata, speakers, and statements. Sessions and statements are written to the database immediately since they don&apos;t need cross-episode deduplication.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2&lt;/strong&gt; collects all raw entity names (persons, techs, orgs) from every episode and deduplicates them using embedding similarity + LLM confirmation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 3&lt;/strong&gt; writes the deduplicated entities and all relationships to the database.&lt;/p&gt;
&lt;h2&gt;Phase 1: Per-Session Processing&lt;/h2&gt;
&lt;p&gt;Each session goes through a multi-step pipeline, starting from a YouTube URL.&lt;/p&gt;
&lt;h3&gt;Fetch Transcript&lt;/h3&gt;
&lt;p&gt;We download the audio with &lt;code&gt;yt-dlp&lt;/code&gt; and transcribe it with AssemblyAI, which gives us speaker-diarized utterances (labeled &quot;Speaker A&quot;, &quot;Speaker B&quot;, etc.) along with YouTube metadata (channel name, title, description, upload date):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def fetch_transcript(youtube_id: str) -&amp;gt; SessionTranscript:
    &quot;&quot;&quot;Download audio via yt-dlp, transcribe with speaker diarization via AssemblyAI.&quot;&quot;&quot;
    url = f&quot;https://www.youtube.com/watch?v={youtube_id}&quot;
    with tempfile.TemporaryDirectory() as tmpdir:
        # 1. Download audio via yt-dlp, convert to mp3
        audio_path = os.path.join(tmpdir, &quot;audio.mp3&quot;)
        ydl_opts = {&quot;format&quot;: &quot;bestaudio/best&quot;, &quot;outtmpl&quot;: audio_path, &quot;quiet&quot;: True,
                    &quot;postprocessors&quot;: [{&quot;key&quot;: &quot;FFmpegExtractAudio&quot;,
                                        &quot;preferredcodec&quot;: &quot;mp3&quot;}]}
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(url, download=True)

        # 2. Transcribe with AssemblyAI (speaker diarization)
        config = aai.TranscriptionConfig(speaker_labels=True)
        transcript = aai.Transcriber().transcribe(audio_path, config)

    # 3. Convert diarized output to structured utterances
    utterances = [Utterance(speaker=u.speaker, text=u.text)
                  for u in transcript.utterances]
    return SessionTranscript(
        utterances=utterances,
        yt_channel=info[&quot;channel&quot;],
        yt_title=info[&quot;title&quot;],
        yt_description=info.get(&quot;description&quot;),
        yt_upload_date=info.get(&quot;upload_date&quot;),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;@coco.fn(memo=True)&lt;/code&gt; decorator is a CocoIndex feature that &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/memoization_keys/&quot;&gt;&lt;strong&gt;memoizes&lt;/strong&gt;&lt;/a&gt; the function: if you&apos;ve already fetched and transcribed a video, re-running the pipeline skips it entirely. This is essential when you&apos;re iterating on the downstream extraction logic and don&apos;t want to re-download hours of audio every time.&lt;/p&gt;
&lt;h3&gt;Two-Step LLM Extraction&lt;/h3&gt;
&lt;p&gt;Here&apos;s where it gets interesting. We can&apos;t extract everything in a single LLM call because of a bootstrapping problem: to extract good statements, the LLM needs to know who the speakers are (so it can attribute statements correctly). But the raw transcript only has generic labels like &quot;Speaker A&quot;. We need to figure out who&apos;s who first.&lt;/p&gt;
&lt;p&gt;So we split extraction into two passes. Both steps use a shared &lt;code&gt;format_transcript()&lt;/code&gt; function that replaces raw diarization labels with names: Step 1 passes an empty map (no names known yet, so all speakers show as &quot;Speaker A&quot;, &quot;Speaker B&quot;), and Step 2 passes the mapping from Step 1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1: Identify speakers and extract metadata.&lt;/strong&gt; We format the transcript with generic labels and give the LLM the YouTube metadata (channel name, title, description) as context. The LLM returns who each speaker is, plus session metadata:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def extract_metadata(
    reformatted_transcript: str, transcript: SessionTranscript
) -&amp;gt; SessionMetadata:
    client = instructor.from_litellm(litellm.acompletion, mode=instructor.Mode.JSON)
    return await client.chat.completions.create(
        model=coco.use_context(LLM_MODEL),
        response_model=SessionMetadata,
        messages=[
            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: METADATA_PROMPT},
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;:
                f&quot;YouTube channel: {transcript.yt_channel}\n&quot;
                f&quot;Video title: {transcript.yt_title}\n&quot;
                f&quot;Description: {transcript.yt_description or &apos;N/A&apos;}\n\n&quot;
                f&quot;Transcript:\n{reformatted_transcript}&quot;},
        ],
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The LLM output is a Pydantic model, enforced by &lt;a href=&quot;https://github.com/instructor-ai/instructor&quot;&gt;instructor&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class SpeakerIdentification(pydantic.BaseModel):
    label: str      # e.g. &quot;A&quot;, &quot;B&quot;
    name: str       # e.g. &quot;Lex Fridman&quot; — unidentifiable speakers are excluded

class SessionMetadata(pydantic.BaseModel):
    name: str
    description: str | None
    date: str | None
    speakers: list[SpeakerIdentification]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 2: Extract statements with real names.&lt;/strong&gt; Now that we know &quot;Speaker A&quot; is &quot;Lex Fridman&quot;, we reformat the transcript with real names and ask the LLM to extract thematic statements:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Reformat: replace &quot;Speaker A&quot; with &quot;Lex Fridman&quot;, etc.
speaker_map = {s.label: s.name for s in metadata.speakers}
step2_text = format_transcript(transcript.utterances, speaker_map)

# Extract statements from the named transcript
stmt_extraction = await extract_statements(step2_text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each extracted statement includes the speakers who made it and the entities it mentions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class RawStatement(pydantic.BaseModel):
    statement: str              # &quot;Scaling laws suggest larger models will improve&quot;
    speakers: list[str]         # [&quot;Lex Fridman&quot;]
    mentioned_person: list[str] # [&quot;Ilya Sutskever&quot;]
    mentioned_tech: list[str]   # [&quot;Large language model&quot;]
    mentioned_org: list[str]    # [&quot;OpenAI&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All entity names must be &lt;strong&gt;self-contained&lt;/strong&gt;. The extraction prompt explicitly instructs the LLM: &lt;em&gt;&quot;Never use pronouns, speaker labels, or contextual references. Every name must be a clear, unambiguous identifier that stands on its own.&quot;&lt;/em&gt; This matters because statements from different episodes will later be cross-referenced: an entity name like &quot;he&quot; or &quot;the host&quot; would be meaningless outside its original transcript.&lt;/p&gt;
&lt;h3&gt;Declaring Results with CocoIndex&lt;/h3&gt;
&lt;p&gt;After extraction, we declare the session and its statements as records in SurrealDB. CocoIndex uses a &lt;strong&gt;declarative&lt;/strong&gt; model: you describe what the &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/target_state/&quot;&gt;target state&lt;/a&gt; should look like, and the framework handles inserts, updates, and deletes.&lt;/p&gt;
&lt;p&gt;IDs are generated using CocoIndex&apos;s &lt;a href=&quot;https://cocoindex.io/docs/common_resources/id_generation/&quot;&gt;&lt;code&gt;IdGenerator&lt;/code&gt;&lt;/a&gt;, which produces stable IDs: the same inputs always yield the same ID, so re-running the pipeline doesn&apos;t create duplicates. Calling &lt;code&gt;next_id()&lt;/code&gt; without arguments generates a sequential ID; calling &lt;code&gt;next_id(content)&lt;/code&gt; incorporates the content into the ID so it remains stable even if the order of statements changes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;id_gen = IdGenerator(youtube_id)
session_id = await id_gen.next_id()

session_table.declare_record(row=Session(
    id=session_id,
    youtube_id=youtube_id,
    name=metadata.name,
    transcript=step2_text,
    # ... description, date
))

for stmt in stmt_extraction.statements:
    stmt_id = await id_gen.next_id(stmt.statement)
    statement_table.declare_record(row=Statement(id=stmt_id, statement=stmt.statement))
    session_statement_rel.declare_relation(from_id=session_id, to_id=stmt_id)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Mounted Components&lt;/h3&gt;
&lt;p&gt;Each session is processed as an independent &lt;strong&gt;mounted component&lt;/strong&gt; via &lt;code&gt;coco.use_mount()&lt;/code&gt;. A mounted component is a self-contained unit of work that CocoIndex tracks independently: each YouTube video ID gets its own component, so adding a new episode only processes that episode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for youtube_id in video_ids:
    raw = await coco.use_mount(
        coco.component_subpath(&quot;session&quot;, youtube_id),
        process_session, youtube_id,
        session_table, statement_table, session_statement_rel,
    )
    all_session_raw.append(raw)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;process_session&lt;/code&gt; call returns a &lt;code&gt;SessionRawEntities&lt;/code&gt;, the raw entity names and statement linkages that Phases 2 and 3 will need:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class SessionRawEntities:
    session_id: int
    raw_entities: dict[str, list[str]]       # e.g. {&quot;person&quot;: [&quot;Lex Fridman&quot;, ...]}
    statements: list[IdentifiedStatement]     # statements with generated IDs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sessions and statements are already written to SurrealDB at this point. The raw entities are carried forward for cross-session deduplication.&lt;/p&gt;
&lt;h2&gt;Phase 2: Entity Resolution&lt;/h2&gt;
&lt;p&gt;After processing all sessions, we have a pile of raw entity names from every episode. The same entity often appears under different names: &quot;GPT-4&quot; vs &quot;GPT4&quot;, &quot;Google&quot; vs &quot;Google LLC&quot;, &quot;Sam Altman&quot; vs &quot;Samuel Altman&quot;. We need to collapse these into canonical names.&lt;/p&gt;
&lt;p&gt;The approach uses &lt;strong&gt;embedding similarity to find candidates, then an LLM to confirm&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Compute an embedding for each entity name (using a small sentence transformer model).&lt;/li&gt;
&lt;li&gt;For each entity, search a FAISS index for the most similar names already processed.&lt;/li&gt;
&lt;li&gt;If any are close enough (cosine distance &amp;lt; 0.3, i.e. similarity &amp;gt; 0.7), ask an LLM: &quot;Is &lt;code&gt;GPT4&lt;/code&gt; the same thing as &lt;code&gt;GPT-4&lt;/code&gt;?&quot;&lt;/li&gt;
&lt;li&gt;The LLM picks the canonical name. Build a deduplication map.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;async def resolve_entities(all_raw_entities: set[str]) -&amp;gt; dict[str, str | None]:
    index = faiss.IndexFlatIP(dim)

    for entity in entity_list:
        vec = embeddings[entity].reshape(1, -1).copy()
        faiss.normalize_L2(vec)

        # Find similar entities already in the index
        candidates = []
        if index.ntotal &amp;gt; 0:
            sims, idxs = index.search(vec, k=min(TOP_N, index.ntotal))
            for sim, idx in zip(sims[0], idxs[0]):
                if sim &amp;gt;= 1.0 - MAX_DISTANCE and idx &amp;gt;= 0:
                    canonical = resolve_canonical(index_names[idx], dedup)
                    if canonical != entity:
                        candidates.append(canonical)

        # Deduplicate candidate list
        unique_candidates = list(dict.fromkeys(candidates))

        if unique_candidates:
            resolution = await resolve_entity_pair(entity, unique_candidates)
            # Update dedup map based on LLM decision
            ...

        index.add(vec)
        index_names.append(entity)

    return dedup  # e.g. {&quot;Apple Inc.&quot;: None, &quot;Apple&quot;: &quot;Apple Inc.&quot;, &quot;AAPL&quot;: &quot;Apple Inc.&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The embedding step is fast and cheap: it filters out the vast majority of entity pairs that are obviously different. The LLM call only happens for the small set of near-matches, keeping the cost down. We use a smaller, cheaper model for these resolution calls since the task is simple (configurable via the &lt;code&gt;RESOLUTION_LLM_MODEL&lt;/code&gt; environment variable).&lt;/p&gt;
&lt;p&gt;Entity resolution runs independently per entity type (person, tech, org), so CocoIndex processes them concurrently:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;entity_dedup = dict(zip(
    [cfg.name for cfg in ENTITY_TYPES],
    await asyncio.gather(*(
        coco.use_mount(
            coco.component_subpath(&quot;resolve&quot;, cfg.name),
            resolve_entities,
            collect_all_raw(all_session_raw, cfg.name),
        )
        for cfg in ENTITY_TYPES
    )),
))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Phase 3: Knowledge Base Creation&lt;/h2&gt;
&lt;p&gt;With the deduplication maps ready, we write the final knowledge graph. Canonical entities become nodes, and all relationships use the resolved canonical names. A helper &lt;code&gt;resolve_canonical(name, dedup)&lt;/code&gt; chases the dedup chain to find the root, e.g., &lt;code&gt;resolve_canonical(&quot;AAPL&quot;, dedup)&lt;/code&gt; → &lt;code&gt;&quot;Apple Inc.&quot;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def create_knowledge_base(all_session_raw, entity_dedup, ...):
    # Declare canonical entity nodes (name IS the id)
    for cfg in ENTITY_TYPES:
        dedup = entity_dedup[cfg.name]
        table = entity_tables[cfg.name]
        for name, upstream in dedup.items():
            if upstream is None:  # this name is canonical
                table.declare_record(row=Entity(id=name, name=name))

    # Declare relationships using canonical names
    person_dedup = entity_dedup[&quot;person&quot;]
    for session_raw in all_session_raw:
        for person_name in session_raw.raw_entities.get(&quot;person&quot;, []):
            canonical = resolve_canonical(person_name, person_dedup)
            person_session_rel.declare_relation(from_id=canonical, to_id=session_raw.session_id)

        for stmt in session_raw.statements:
            # person_statement: who made the statement
            for speaker in stmt.raw.speakers:
                canonical = resolve_canonical(speaker, person_dedup)
                person_statement_rel.declare_relation(from_id=canonical, to_id=stmt.id)

            # statement_mentions: what the statement is about (polymorphic)
            for cfg in ENTITY_TYPES:
                dedup = entity_dedup[cfg.name]
                for entity_name in getattr(stmt.raw, f&quot;mentioned_{cfg.name}&quot;):
                    canonical = resolve_canonical(entity_name, dedup)
                    statement_mentions_rel.declare_relation(
                        from_id=stmt.id, to_id=canonical,
                        to_table=entity_tables[cfg.name])  # polymorphic target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;statement_mentions&lt;/code&gt; relationship is &lt;strong&gt;polymorphic&lt;/strong&gt;: the target can be a person, tech, or org table. The &lt;code&gt;to_table&lt;/code&gt; parameter tells CocoIndex which table the target ID belongs to.&lt;/p&gt;
&lt;h2&gt;Incremental Updates&lt;/h2&gt;
&lt;p&gt;A pipeline like this isn&apos;t a one-shot job. You&apos;ll add new podcast episodes over time, and you&apos;ll want to evolve the schema, maybe adding &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;Event&lt;/code&gt;, or &lt;code&gt;Place&lt;/code&gt; as new entity types, or refining the extraction prompts. CocoIndex&apos;s memoization and component model make both scenarios efficient.&lt;/p&gt;
&lt;h3&gt;Adding new episodes&lt;/h3&gt;
&lt;p&gt;When you add a new YouTube URL and re-run the pipeline, only the new episode is processed. Existing episodes are untouched:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fetch transcript&lt;/strong&gt;: Skipped for existing episodes (&lt;code&gt;memo=True&lt;/code&gt; on &lt;code&gt;fetch_transcript&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM extraction&lt;/strong&gt; (Step 1 + Step 2): Skipped for existing episodes (both are memoized).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entity resolution&lt;/strong&gt;: Re-runs to incorporate new entity names, but &lt;code&gt;compute_entity_embedding&lt;/code&gt; and &lt;code&gt;resolve_entity_pair&lt;/code&gt; are memoized: existing entities&apos; embeddings and resolution decisions are reused. Only the new names trigger fresh LLM calls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Knowledge base&lt;/strong&gt;: CocoIndex&apos;s declarative targets handle the diff: new records are inserted, removed episodes are cleaned up.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Removing an episode works the same way: its mounted component is gone, so CocoIndex deletes its session, statements, and relationships from SurrealDB.&lt;/p&gt;
&lt;h3&gt;Evolving the schema&lt;/h3&gt;
&lt;p&gt;Say you want to add a new entity type, like &lt;code&gt;Product&lt;/code&gt;. You&apos;d add it to &lt;code&gt;ENTITY_TYPES&lt;/code&gt;, update the extraction prompt, and re-run. Here&apos;s what happens:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline step&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fetch transcript&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reused&lt;/strong&gt; for all episodes&lt;/td&gt;
&lt;td&gt;Memoized, no input changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step 1: Speaker identification&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reused&lt;/strong&gt; for all episodes&lt;/td&gt;
&lt;td&gt;Memoized, prompt unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step 2: Statement extraction&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Re-runs&lt;/strong&gt; for all episodes&lt;/td&gt;
&lt;td&gt;Extraction prompt changed (new entity type)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entity resolution (person, tech, org)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reused&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Raw entities unchanged, memoized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entity resolution (product)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Runs fresh&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;New entity type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge base creation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Re-declared&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;New entities and relationships added&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The expensive operations (downloading audio, transcribing, and identifying speakers) are fully reused. Only the statement extraction LLM calls re-run (because the prompt changed), plus entity resolution for the new type. If you have 50 episodes and add one entity type, you save most of the compute cost.&lt;/p&gt;
&lt;p&gt;This is the payoff of CocoIndex&apos;s &lt;code&gt;memo&lt;/code&gt; and &lt;code&gt;use_mount&lt;/code&gt; design: each function is memoized by its inputs, and each session is an independent component. Changes propagate only to what&apos;s actually affected.&lt;/p&gt;
&lt;h2&gt;Running the Pipeline&lt;/h2&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.11+, FFmpeg, Docker&lt;/li&gt;
&lt;li&gt;An &lt;a href=&quot;https://www.assemblyai.com/&quot;&gt;AssemblyAI API key&lt;/a&gt; (for transcription)&lt;/li&gt;
&lt;li&gt;An &lt;a href=&quot;https://platform.openai.com/&quot;&gt;OpenAI API key&lt;/a&gt; (for LLM extraction)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Setup&lt;/h3&gt;
&lt;p&gt;Start &lt;a href=&quot;https://cocoindex.io/docs/connectors/surrealdb/&quot;&gt;SurrealDB&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run -d --name surrealdb --user root -p 8787:8000 \
  -v surrealdb-data:/data surrealdb/surrealdb:latest \
  start --user root --pass root surrealkv:/data/database
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set environment variables and install:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export ASSEMBLYAI_API_KEY=&quot;...&quot;
export OPENAI_API_KEY=&quot;sk-...&quot;
pip install -e .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add YouTube URLs to &lt;code&gt;input/sample.txt&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# AI podcasts
https://www.youtube.com/watch?v=VIDEO_ID_1
https://www.youtube.com/watch?v=VIDEO_ID_2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Build the Knowledge Graph&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update podcast_knowledge.app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is incremental: re-running skips episodes that have already been processed.&lt;/p&gt;
&lt;h2&gt;Exploring the Results&lt;/h2&gt;
&lt;p&gt;Once the pipeline completes, you can explore the knowledge graph in SurrealDB&apos;s built-in explorer (&lt;a href=&quot;https://surrealdb.com/surrealist&quot;&gt;Surrealist&lt;/a&gt;). Connect to &lt;code&gt;ws://localhost:8787&lt;/code&gt;, namespace &lt;code&gt;cocoindex&lt;/code&gt;, database &lt;code&gt;yt_conversations&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The graph view shows the relationships between persons and statements: each pink node is a statement, each blue node is a person:&lt;/p&gt;
&lt;p&gt;You can also run analytical queries. For example, finding which technologies are mentioned by the most people across all episodes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
  name,
  array::distinct(
    &amp;lt;-statement_mentions&amp;lt;-statement&amp;lt;-person_statement&amp;lt;-person.name
  ) AS persons,
  array::len(array::distinct(
    &amp;lt;-statement_mentions&amp;lt;-statement&amp;lt;-person_statement&amp;lt;-person.id
  )) AS person_count
FROM tech
ORDER BY person_count DESC
LIMIT 15;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Other useful queries:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- All statements a person made
SELECT &amp;lt;-person_statement&amp;lt;-person.name AS speaker, statement FROM statement;

-- Full graph around a person
SELECT name,
  -&amp;gt;person_session-&amp;gt;session.name AS sessions,
  -&amp;gt;person_statement-&amp;gt;statement.statement AS statements
FROM person;

-- All entities involved in statements
SELECT statement,
  -&amp;gt;statement_mentions-&amp;gt;person.name AS persons,
  -&amp;gt;statement_mentions-&amp;gt;tech.name AS techs,
  -&amp;gt;statement_mentions-&amp;gt;org.name AS orgs
FROM statement;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;We built a pipeline that turns YouTube podcasts into a queryable knowledge graph:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Fetch&lt;/strong&gt;: Download audio and transcribe with speaker diarization.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extract&lt;/strong&gt; (two-step): First identify speakers using YouTube metadata, then extract statements with real names, solving the bootstrapping problem of needing to know who&apos;s speaking before you can attribute what they said.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolve&lt;/strong&gt;: Deduplicate entities across episodes using embedding similarity + LLM confirmation: cheap embeddings filter candidates, expensive LLM calls only happen for near-matches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store&lt;/strong&gt;: Write the knowledge graph to SurrealDB with CocoIndex&apos;s declarative target model.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because every expensive step is memoized and each session is an independent component, the pipeline is fully incremental, whether you&apos;re adding episodes or evolving the schema. See &lt;a href=&quot;#incremental-updates&quot;&gt;Incremental Updates&lt;/a&gt; for details.&lt;/p&gt;
&lt;p&gt;The full source code is at &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/conversation_to_knowledge&quot;&gt;CocoIndex Examples: podcast_to_knowledge&lt;/a&gt;. To stay updated on CocoIndex, star us on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Support us ❤️&lt;/h2&gt;
&lt;p&gt;If this walkthrough was useful, the easiest way to support CocoIndex is to &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;give the project a ⭐ on GitHub&lt;/a&gt;. It&apos;s how other developers discover the project, and every star keeps us motivated to ship better tools for building knowledge graphs, RAG pipelines, and live data infrastructure for AI.&lt;/p&gt;
&lt;p&gt;Got a podcast, a meeting archive, or any other corpus you want to turn into a graph? We&apos;d love to hear what you&apos;re building: drop by our &lt;a href=&quot;https://discord.com/invite/zpA9S2DR7s&quot;&gt;Discord&lt;/a&gt; or open an issue on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/issues&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>examples</category><category>knowledge-graph</category><category>llm</category><category>structured-extraction</category><category>incremental-processing</category><author>George He</author></item><item><title>From pickle to type-guided, safer Python serialization</title><link>https://cocoindex.io/blogs/type-guided-serde/</link><guid isPermaLink="true">https://cocoindex.io/blogs/type-guided-serde/</guid><description>How CocoIndex moved from pickle to type-guided serialization that uses Python type hints to pick the right serializer, no decorators or registration needed.</description><pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; is a framework for building &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;incremental data pipelines&lt;/a&gt;. It has a Rust core for performance and exposes a Python SDK for users to define their pipelines. Under the hood, the engine needs to &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/serialization/&quot;&gt;serialize and deserialize&lt;/a&gt; Python objects constantly, caching function results, persisting pipeline state, tracking records for change detection, with the serialized data crossing the Rust/Python boundary and stored by the Rust core.&lt;/p&gt;
&lt;p&gt;In CocoIndex v0, we had a closed type system: a fixed set of supported data types, with serialization handled entirely in Rust. Safe and fast, but too rigid. Users couldn&apos;t use their own data types without converting them into our type system first.&lt;/p&gt;
&lt;p&gt;When we started building &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;v1&lt;/a&gt;, we wanted to natively support any Python data type. Pickle was the obvious first choice.&lt;/p&gt;
&lt;h2&gt;Pickle: Fast to Prototype, Painful to Live With&lt;/h2&gt;
&lt;p&gt;Pickle is Python&apos;s built-in serialization. It handles virtually any Python object out of the box: dataclasses, NamedTuples, Pydantic models, nested containers, custom classes. For early prototyping under prerelease, it was perfect: one line of code, and everything just worked.&lt;/p&gt;
&lt;p&gt;But pickle has two fundamental problems that make it unsuitable for production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security.&lt;/strong&gt; Pickle executes arbitrary code during deserialization. A crafted payload can run any Python code on your machine. In CocoIndex&apos;s case, the serialized data lives in &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/internal_storage/&quot;&gt;internal storage&lt;/a&gt; (a database managed by the framework), so the risk requires an attacker to have access to that storage, which is unlikely for most deployments. Still, defense in depth matters: if the storage is ever exposed through a misconfiguration or a compromised database, pickle becomes an escalation path from data access to code execution. We didn&apos;t want that risk in the foundation of our persistence layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Overhead.&lt;/strong&gt; Pickle encodes type information into every value: each value is a sequence of opcodes that tells the deserializer what type to reconstruct and how. This makes pickle self-describing, but at a cost. The integer &lt;code&gt;1024&lt;/code&gt; takes 15 bytes in pickle, while msgpack encodes it in just 3. Msgpack can be this compact because it relies on the deserializer already knowing the expected type:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;pickle&lt;/th&gt;
&lt;th&gt;msgpack&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt; (empty string)&lt;/td&gt;
&lt;td&gt;15 bytes&lt;/td&gt;
&lt;td&gt;1 byte&lt;/td&gt;
&lt;td&gt;15x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1024&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15 bytes&lt;/td&gt;
&lt;td&gt;3 bytes&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&quot;hello&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20 bytes&lt;/td&gt;
&lt;td&gt;6 bytes&lt;/td&gt;
&lt;td&gt;3.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{&quot;key&quot;: [1,2,3]}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31 bytes&lt;/td&gt;
&lt;td&gt;9 bytes&lt;/td&gt;
&lt;td&gt;3.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Point(42, &quot;origin&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;63 bytes&lt;/td&gt;
&lt;td&gt;16 bytes&lt;/td&gt;
&lt;td&gt;3.9x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For a pipeline that caches thousands of intermediate results, this overhead adds up.&lt;/p&gt;
&lt;h2&gt;Whitelisted Pickle: Secure but Tedious&lt;/h2&gt;
&lt;p&gt;Our first attempt to fix the security problem was a restricted unpickler. We maintained an allowlist of types that were safe to deserialize, and rejected everything else. Users could opt their types into the allowlist with a &lt;code&gt;@coco.unpickle_safe&lt;/code&gt; decorator:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.unpickle_safe
@dataclass
class DocumentChunk:
    text: str
    embedding: list[float]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This solved the security problem. But it introduced a new one: annotation burden.&lt;/p&gt;
&lt;p&gt;Every type that appeared anywhere in a serialized object graph needed the decorator, not just the top-level type, but every type nested inside it. Miss one, and you get a runtime error.&lt;/p&gt;
&lt;p&gt;We experienced this firsthand when building the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/fc3b4b54044875fe3777b556b0e66498c0050942/examples/conversation_to_knowledge&quot;&gt;&lt;code&gt;conversation_to_knowledge&lt;/code&gt;&lt;/a&gt; example. It needed &lt;strong&gt;9&lt;/strong&gt; &lt;code&gt;@coco.unpickle_safe&lt;/code&gt; annotations across the codebase. Getting there took &lt;strong&gt;4 rounds&lt;/strong&gt; of run-fail-annotate-repeat: run the pipeline, hit an error about an unregistered type, add the decorator, run again, hit the next error. Four times.&lt;/p&gt;
&lt;p&gt;For our own example. Imagine what this is like for users building real pipelines with dozens of model types.&lt;/p&gt;
&lt;h2&gt;Type-Guided Deserialization: Letting Type Hints Do the Work&lt;/h2&gt;
&lt;p&gt;We stepped back and asked: what do we actually have that describes the shape of users&apos; data? The answer was already in their code: &lt;strong&gt;type hints&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In CocoIndex, there are three places where data gets serialized and deserialized: &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/memoization_keys/&quot;&gt;memoized function return values&lt;/a&gt; (the most common case), memo state values for change detection, and target tracking records. For memoized return values, the type hint is right there in the function signature:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def embed_chunk(chunk: str) -&amp;gt; list[float]:
    return await embedding_model.embed(chunk)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The return type &lt;code&gt;list[float]&lt;/code&gt; tells us exactly how to deserialize the cached result. No extra annotation needed: the type hint that users already write for readability and IDE support doubles as the deserialization schema.&lt;/p&gt;
&lt;p&gt;This led us to a type-guided serialization system built on &lt;a href=&quot;https://jcristharif.com/msgspec/&quot;&gt;msgspec&lt;/a&gt;, a fast msgpack-based serialization library for Python. Msgspec natively handles dataclasses, NamedTuples, and standard types (str, int, list, dict, datetime, UUID, etc.), and it does so by relying on type information at deserialization time.&lt;/p&gt;
&lt;h3&gt;The routing byte&lt;/h3&gt;
&lt;p&gt;Different types need different serialization engines. We use a single routing byte at the start of each payload to select the engine:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Byte&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;msgspec (msgpack)&lt;/td&gt;
&lt;td&gt;Dataclasses, NamedTuples, primitives, collections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pydantic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BaseModel&lt;/code&gt; subclasses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x80&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pickle&lt;/td&gt;
&lt;td&gt;Opted-in types, legacy data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Serialization checks types in priority order: explicit pickle opt-in first (the user specifically requested it), then Pydantic models, then msgspec as the default. Deserialization reads the routing byte and dispatches accordingly.&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;Pydantic models&lt;/strong&gt;, we serialize via &lt;code&gt;model_dump(mode=&quot;json&quot;)&lt;/code&gt; into msgpack, and deserialize via &lt;code&gt;TypeAdapter.validate_python()&lt;/code&gt;. This avoids pickle entirely while preserving Pydantic&apos;s validation semantics.&lt;/p&gt;
&lt;p&gt;For types that msgspec can&apos;t handle natively, like numpy arrays or pathlib paths, users can opt in to pickle serialization with &lt;code&gt;@coco.serialize_by_pickle&lt;/code&gt;. These types get the pickle routing byte (&lt;code&gt;0x80&lt;/code&gt;), still through a restricted unpickler for safety.&lt;/p&gt;
&lt;h3&gt;What changed for users&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;conversation_to_knowledge&lt;/code&gt; example? All 9 &lt;code&gt;@coco.unpickle_safe&lt;/code&gt; annotations, gone. Users define their data types with standard Python type hints, and serialization just works:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class DocumentChunk:
    text: str
    embedding: list[float]
    metadata: dict[str, str]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No decorators. No registration. No run-fail-annotate-repeat cycle. The framework reads the type hint and picks the right serializer and deserializer automatically.&lt;/p&gt;
&lt;h2&gt;The Edge Cases: When Round-Trip Isn&apos;t Perfect&lt;/h2&gt;
&lt;p&gt;We&apos;d be lying if we said this approach is perfect for every type. There are edge cases with &lt;strong&gt;union types&lt;/strong&gt; that users should be aware of, and these aren&apos;t specific to CocoIndex. They&apos;re fundamental to how msgspec and Pydantic handle ambiguous types.&lt;/p&gt;
&lt;h3&gt;msgspec: loud failure&lt;/h3&gt;
&lt;p&gt;msgspec rejects ambiguous unions at construction time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import msgspec.msgpack

# These all raise TypeError immediately:
msgspec.msgpack.Decoder(str | datetime.date)     # two str-like types
msgspec.msgpack.Decoder(list[int] | tuple[int, ...])  # two array-like types
msgspec.msgpack.Decoder(bytes | bytearray)        # two bytes-like types
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is actually a feature. msgspec tells you upfront that these types can&apos;t be distinguished on the wire, rather than silently corrupting your data.&lt;/p&gt;
&lt;h3&gt;Pydantic: silent surprise&lt;/h3&gt;
&lt;p&gt;Pydantic is more permissive: it accepts &lt;code&gt;date | str&lt;/code&gt;, but the behavior after a serialization-deserialization round-trip can be surprising.&lt;/p&gt;
&lt;p&gt;Consider a Pydantic model with a &lt;code&gt;date | str&lt;/code&gt; field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Event(BaseModel):
    ts: datetime.date | str

e = Event(ts=datetime.date(2024, 1, 15))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you serialize this to JSON (or msgpack), &lt;code&gt;date&lt;/code&gt; becomes the string &lt;code&gt;&quot;2024-01-15&quot;&lt;/code&gt;. On deserialization, Pydantic sees a string input and needs to decide: is it a &lt;code&gt;str&lt;/code&gt; or a &lt;code&gt;date&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;With Pydantic v2&apos;s default &lt;strong&gt;smart mode&lt;/strong&gt;, the answer is always &lt;code&gt;str&lt;/code&gt;. Smart mode scores candidates by exactness: a string input is an &lt;em&gt;exact match&lt;/em&gt; for &lt;code&gt;str&lt;/code&gt; but only a &lt;em&gt;lax match&lt;/em&gt; (requires coercion) for &lt;code&gt;date&lt;/code&gt;. Exact wins. Ordering doesn&apos;t matter: &lt;code&gt;date | str&lt;/code&gt; and &lt;code&gt;str | date&lt;/code&gt; both produce &lt;code&gt;str&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Left-to-right mode&lt;/strong&gt; behaves differently. With &lt;code&gt;date | str&lt;/code&gt; ordering, Pydantic tries &lt;code&gt;date&lt;/code&gt; first, the lax coercion from &lt;code&gt;&quot;2024-01-15&quot;&lt;/code&gt; succeeds, and you get your date back:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic import Field
from typing import Annotated

class Event(BaseModel):
    # Left-to-right mode: more specific type first
    ts: Annotated[datetime.date | str, Field(union_mode=&apos;left_to_right&apos;)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But this requires users to explicitly opt in to left-to-right mode AND put the more specific type first.&lt;/p&gt;
&lt;h3&gt;The fundamental issue&lt;/h3&gt;
&lt;p&gt;This isn&apos;t a bug in any library. It&apos;s inherent to the wire format. Msgpack (like JSON) has no &lt;code&gt;date&lt;/code&gt; type: dates are serialized as strings. Once the type information is erased on the wire, no amount of clever decoding can reliably recover it when multiple types map to the same wire representation.&lt;/p&gt;
&lt;p&gt;The practical advice: &lt;strong&gt;avoid union types where members share a wire representation&lt;/strong&gt; in data objects that go through serialization. Use unambiguous types, or use Pydantic&apos;s discriminated unions with a literal tag field.&lt;/p&gt;
&lt;h2&gt;What We Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Type hints are an underutilized source of truth.&lt;/strong&gt; Python developers already annotate their data types. Using those annotations for serialization is a natural extension: no new concepts, decorators, or registration APIs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Make ambiguity loud, not silent.&lt;/strong&gt; msgspec&apos;s approach of rejecting ambiguous unions at construction time is better than silently producing wrong results. When building a serialization layer, fail early and clearly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&apos;t fight the wire format.&lt;/strong&gt; If the wire format can&apos;t distinguish two types, your serialization layer can&apos;t either. Design your data types around what the serialization format can express, not the other way around.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start simple, but have a plan.&lt;/strong&gt; Pickle was right for prototyping. The whitelisted pickle was right for the security fix. The type-guided approach was right for production. Each step taught us what the next step needed to be.&lt;/p&gt;
&lt;h2&gt;Learn More&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex on GitHub&lt;/a&gt;: the open-source data indexing framework&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;CocoIndex v1 Documentation&lt;/a&gt;: getting started guides and API reference&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blogs/building-an-invisible-daemon&quot;&gt;Building an Invisible Daemon&lt;/a&gt;: how we built the local daemon architecture behind CocoIndex&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Support us&lt;/h2&gt;
&lt;p&gt;⭐ Star &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex on GitHub&lt;/a&gt; and share with your community if you find it useful!&lt;/p&gt;
</content:encoded><category>insight</category><category>architecture</category><category>best-practices</category><author>George He</author></item><item><title>Invisible Daemon: architecture patterns for local dev tools</title><link>https://cocoindex.io/blogs/building-an-invisible-daemon/</link><guid isPermaLink="true">https://cocoindex.io/blogs/building-an-invisible-daemon/</guid><description>Five patterns for a Python CLI background daemon that auto-starts, upgrades transparently, and shuts down fast, from the daemon behind cocoindex-code.</description><pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Building a Python CLI tool with a background daemon? The five patterns that eliminate manual start/restart, connection leaks, and 15-second shutdown times are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Auto-start on first use&lt;/strong&gt; via a Unix socket probe (no &lt;code&gt;daemon start&lt;/code&gt; command).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version handshake&lt;/strong&gt; on every connection for transparent upgrades after &lt;code&gt;pipx upgrade&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-request connections&lt;/strong&gt; instead of persistent sessions: connection leaks become impossible by construction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resource closure&lt;/strong&gt; instead of thread signaling: close the listener; don&apos;t poll a &lt;code&gt;threading.Event&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PID file removal&lt;/strong&gt; as the exit signal: &lt;code&gt;os.kill(pid, 0)&lt;/code&gt; returns True for zombies.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is how we built the daemon behind &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex-code&quot;&gt;cocoindex-code&lt;/a&gt; (&lt;code&gt;ccc&lt;/code&gt;), an embedded AST-based semantic code search tool for Claude Code, Codex, and Cursor.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why this post exists.&lt;/strong&gt; &lt;code&gt;cocoindex-code&lt;/code&gt; is a local MCP server and CLI that gives coding agents semantic understanding of your whole codebase via &lt;a href=&quot;https://cocoindex.io/docs/examples/index-codebase/&quot;&gt;AST-based chunking&lt;/a&gt;: &lt;a href=&quot;https://cocoindex.io/blogs/index-codebase-v1&quot;&gt;Tree-sitter&lt;/a&gt; parses code by syntax (functions, classes, methods), and only &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;changed chunks are re-embedded on update&lt;/a&gt;. The daemon described here is the background process that makes that possible: it loads the ML model once, watches for file changes, and serves searches in under a second. If you&apos;re building something similar (a local AI context layer, a code-aware MCP server, any dev tool with a heavy background process), the patterns below are what we landed on after shipping to production.&lt;/p&gt;
&lt;p&gt;We moved cocoindex-code from a monolithic per-session process (load model, index, search, exit) to a persistent daemon with a thin CLI (&lt;code&gt;ccc&lt;/code&gt;) and a lightweight MCP client as frontends. This unlocked a benefit beyond performance: humans can call &lt;code&gt;ccc search &quot;auth logic&quot;&lt;/code&gt; directly from the terminal and pipe it into other tools.&lt;/p&gt;
&lt;p&gt;The journey from &quot;obvious daemon&quot; to production-ready was full of design decisions, and the best ones were usually about what to &lt;em&gt;remove&lt;/em&gt;. Here&apos;s what the user sees:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ccc init                    # one-time setup: creates settings files (no daemon)
ccc index                   # build the search index (daemon auto-starts here)
ccc search &quot;auth logic&quot;     # semantic search (daemon handles it)
ccc search --refresh &quot;auth&quot; # re-index then search (two daemon requests)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ccc init&lt;/code&gt; is purely local: it creates configuration files and a &lt;code&gt;.gitignore&lt;/code&gt; entry. Every other command talks to the daemon. But the user never starts, stops, or restarts the daemon manually. It just works.&lt;/p&gt;
&lt;p&gt;The initial daemon was largely built by Claude Code: persistent connections, async handler loops, multi-step shutdown. It worked, but it was complex in ways we didn&apos;t appreciate until we looked closely. The simplifications came from stepping back and questioning the design rather than fixing individual bugs.&lt;/p&gt;
&lt;p&gt;This post covers the patterns we landed on, in the hope that they&apos;re useful to anyone building a local daemon for developer tools.&lt;/p&gt;
&lt;h2&gt;How do you make a local daemon invisible to the user?&lt;/h2&gt;
&lt;p&gt;The most important design constraint: &lt;strong&gt;users should never think about the daemon&lt;/strong&gt;. No &lt;code&gt;ccc daemon start&lt;/code&gt; in the setup guide. No &quot;please restart the daemon after upgrading.&quot; No stale config surprises.&lt;/p&gt;
&lt;h3&gt;How do you auto-start a Python daemon on first use without a manual command?&lt;/h3&gt;
&lt;p&gt;The first time a user runs &lt;code&gt;ccc index&lt;/code&gt; or &lt;code&gt;ccc search&lt;/code&gt;, the client tries to connect to the daemon&apos;s Unix socket. If it fails, it starts the daemon as a detached background process and waits for the socket to appear.&lt;/p&gt;
&lt;p&gt;This logic is built into the lowest-level connection function, not into a separate &quot;ensure daemon&quot; step:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_daemon_ensured = False

def _connect_and_handshake() -&amp;gt; Connection:
    global _daemon_ensured

    if _daemon_ensured:
        return _raw_connect_and_handshake()

    # First connection — auto-start/restart as needed.
    try:
        conn = _raw_connect_and_handshake()
        _daemon_ensured = True
        return conn
    except DaemonVersionError:
        stop_daemon()
    except (ConnectionRefusedError, OSError):
        pass

    start_daemon()
    _wait_for_daemon()
    # ... retry and return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A module-level &lt;code&gt;_daemon_ensured&lt;/code&gt; flag means the first call in a CLI session pays the startup cost. Every subsequent call connects directly, with no separate &quot;ensure&quot; step or throwaway probe connection.&lt;/p&gt;
&lt;h3&gt;How do you auto-restart a daemon when the CLI tool upgrades?&lt;/h3&gt;
&lt;p&gt;When a user runs &lt;code&gt;pipx upgrade cocoindex-code&lt;/code&gt;, the next CLI command should just work, using the new version. No manual restart.&lt;/p&gt;
&lt;p&gt;Every connection starts with a version handshake. The client sends its version; the daemon responds with its version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# In the client
conn.send_bytes(encode_request(HandshakeRequest(version=__version__)))
resp = decode_response(conn.recv_bytes())
if not resp.ok or _needs_restart(resp):
    raise DaemonVersionError(resp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the first call (&lt;code&gt;_daemon_ensured = False&lt;/code&gt;), a version mismatch triggers a restart: stop the old daemon, start a new one. On subsequent calls, a mismatch raises an error (the daemon was replaced mid-session, which is unusual and worth surfacing).&lt;/p&gt;
&lt;h3&gt;How should a local daemon handle config — restart, reload, or re-read fresh?&lt;/h3&gt;
&lt;p&gt;We have two levels of configuration:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Global settings&lt;/strong&gt; (&lt;code&gt;~/.cocoindex_code/global_settings.yml&lt;/code&gt;): embedding model, API keys, environment variables. These affect daemon-wide state: the ML model is loaded once at startup, environment variables are set once. When these change, the daemon &lt;em&gt;must&lt;/em&gt; restart.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project settings&lt;/strong&gt; (&lt;code&gt;$PROJECT/.cocoindex_code/settings.yml&lt;/code&gt;): include/exclude patterns, language overrides. These only affect per-operation behavior: which files to index, how to parse them.&lt;/p&gt;
&lt;p&gt;Both files are created automatically by &lt;code&gt;ccc init&lt;/code&gt; with sensible defaults. Users only edit them if they need to customize.&lt;/p&gt;
&lt;p&gt;For global settings, the daemon records the file&apos;s mtime (as integer microseconds) at startup and reports it in the handshake response. The client compares it with the current mtime. Different? Restart.&lt;/p&gt;
&lt;p&gt;For project settings, we do nothing special. Each indexing pass reads the settings file fresh from disk, so there is no staleness check or cache to invalidate.&lt;/p&gt;
&lt;p&gt;The principle: &lt;strong&gt;minimize what the daemon holds as state&lt;/strong&gt;. Global settings are unavoidable state (the loaded model), so we detect staleness. Project settings can be stateless, so we make them stateless.&lt;/p&gt;
&lt;h2&gt;Per-request vs. persistent connections — which is better for local IPC?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Per-request.&lt;/strong&gt; Each request opens a fresh Unix socket connection, sends, receives, closes. Setup overhead is ~0.1ms, negligible for human-initiated CLI commands. Persistent connections require idle handler tasks, shutdown signaling across async/thread boundaries, and connection lifecycle management at every call site. We tried persistent connections first. We removed them. This was the single biggest simplification, and it came from a bug.&lt;/p&gt;
&lt;h3&gt;Why we tried persistent connections first&lt;/h3&gt;
&lt;p&gt;Our first design followed the obvious pattern: the client connects, handshakes, then sends multiple requests on the same connection. The daemon handler loops, reading and dispatching requests until the client disconnects:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# The original handler — persistent connection with recv loop
async def handle_connection(conn, registry, ...):
    loop = asyncio.get_event_loop()

    def _recv():
        while not shutdown_event.is_set():
            if conn.poll(0.5):          # wake every 0.5s to check shutdown
                return conn.recv_bytes()
        raise EOFError(&quot;shutdown&quot;)

    try:
        while not shutdown_event.is_set():
            data = await loop.run_in_executor(None, _recv)
            req = decode_request(data)
            # ... handshake, dispatch, respond ...
    except (EOFError, OSError, asyncio.CancelledError):
        pass
    finally:
        conn.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This handler had to deal with a lot:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;_recv&lt;/code&gt; helper that polls every 0.5 seconds, checking a &lt;code&gt;threading.Event&lt;/code&gt; for shutdown&lt;/li&gt;
&lt;li&gt;An executor thread per connection, blocked in &lt;code&gt;conn.poll()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;while&lt;/code&gt; loop waiting for the next request that may never come&lt;/li&gt;
&lt;li&gt;Cancellation handling for shutdown&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two problems emerged from this design:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Leaked connections.&lt;/strong&gt; CLI commands didn&apos;t always close their connections. Each leaked connection meant a handler task stuck in &lt;code&gt;_recv&lt;/code&gt;, waiting forever. We added &lt;code&gt;__enter__&lt;/code&gt;/&lt;code&gt;__exit__&lt;/code&gt;, &lt;code&gt;with&lt;/code&gt; blocks, &lt;code&gt;.close()&lt;/code&gt; calls, but it was whack-a-mole.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shutdown was broken.&lt;/strong&gt; Our test suite took 15 seconds per test teardown. The &lt;code&gt;stop_daemon()&lt;/code&gt; function had a 5-step escalation (StopRequest, wait 5 seconds, SIGTERM, wait 2 seconds, SIGKILL) and always fell through to SIGKILL. Three bugs were stacked on top of each other:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;asyncio.Event.is_set()&lt;/code&gt; called from executor threads, but &lt;code&gt;asyncio.Event&lt;/code&gt; is not thread-safe. The &lt;code&gt;_recv&lt;/code&gt; threads never saw the shutdown signal.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;asyncio.run()&lt;/code&gt; cleanup hanging: it calls &lt;code&gt;shutdown_default_executor(wait=True)&lt;/code&gt;, which waits for the &lt;code&gt;_recv&lt;/code&gt; threads that would never exit (because of bug #1).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;os.kill(pid, 0)&lt;/code&gt; returning True for zombie processes, so the client kept waiting for a process that had already exited.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We fixed each bug individually. But the real insight came when we stepped back: &lt;strong&gt;all three bugs existed because of persistent connections&lt;/strong&gt;. Persistent connections required idle handler tasks. Idle tasks required a shutdown signal. The signal had to cross async-to-thread boundaries. And every layer of that chain had a bug.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We fixed three stacked shutdown bugs with careful threading, event types, and process liveness checks. Then we removed persistent connections and all three bugs disappeared. The debugging was correct. The design was wrong.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;The per-request model: read two messages, respond, close&lt;/h3&gt;
&lt;p&gt;Instead of fixing the plumbing, we removed the need for it. What if each request just opens a fresh connection?&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;connect → handshake → one request → response(s) → close
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The handler becomes straight-line code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# The current handler — per-request, no loop, no polling
async def handle_connection(conn, registry, ...):
    loop = asyncio.get_event_loop()
    try:
        # 1. Handshake
        data = await loop.run_in_executor(None, conn.recv_bytes)
        req = decode_request(data)
        # ... validate handshake, send response ...

        # 2. Single request
        data = await loop.run_in_executor(None, conn.recv_bytes)
        req = decode_request(data)
        result = await _dispatch(req, registry, ...)

        # 3. Send response(s) and done
        if isinstance(result, AsyncIterator):
            async for resp in result:
                conn.send_bytes(encode_response(resp))
        else:
            conn.send_bytes(encode_response(result))
    except (EOFError, OSError, asyncio.CancelledError):
        pass
    finally:
        conn.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read two messages. Respond. Close. That&apos;s it.&lt;/p&gt;
&lt;h3&gt;What per-request connections eliminated&lt;/h3&gt;
&lt;p&gt;The diff wasn&apos;t just shorter. Entire concepts disappeared:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;_recv&lt;/code&gt; polling loop (0.5-second wakeups checking &lt;code&gt;shutdown_event&lt;/code&gt;): &lt;strong&gt;gone&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;shutdown_event&lt;/code&gt; (&lt;code&gt;threading.Event&lt;/code&gt;): &lt;strong&gt;gone entirely&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;DaemonClient&lt;/code&gt; class, &lt;code&gt;__enter__&lt;/code&gt;/&lt;code&gt;__exit__&lt;/code&gt;, &lt;code&gt;.close()&lt;/code&gt;: &lt;strong&gt;gone&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;with client:&lt;/code&gt; blocks in every CLI command: &lt;strong&gt;gone&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Connection leak bugs: &lt;strong&gt;impossible by construction&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;All three shutdown bugs: &lt;strong&gt;gone&lt;/strong&gt; (no idle handler tasks means nothing to signal)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The client module went from a stateful class to module-level functions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# No class, no state, no close() to forget
def index(project_root, on_progress=None, on_waiting=None):
    conn = _connect_and_handshake()
    try:
        conn.send_bytes(encode_request(IndexRequest(project_root=project_root)))
        # ... read streaming responses ...
    finally:
        conn.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Compound operations — open a second connection, not a second protocol&lt;/h3&gt;
&lt;p&gt;What about operations that need multiple requests? &lt;code&gt;ccc search --refresh&lt;/code&gt; needs to index first, then search. Simple. Two connections:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def search_cmd(project_root, refresh=False):
    if refresh:
        client.index(project_root)       # connection 1
    client.search(project_root, query)   # connection 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MCP background indexing? Just call &lt;code&gt;client.index()&lt;/code&gt; from a thread. It opens its own connection, with no shared state or connection-isolation bugs to reason about.&lt;/p&gt;
&lt;p&gt;The overhead is negligible: Unix socket setup is ~0.1ms, the handshake is a tiny msgspec payload, and CLI commands are human-initiated. Streaming responses (like indexing progress) still work: the connection lives for one request&apos;s full response stream. It just doesn&apos;t linger afterwards.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Per-request connections can&apos;t leak by construction. The overhead of Unix socket setup is ~0.1ms, negligible for developer tools.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;How do you cleanly shut down a Python daemon without falling through to SIGKILL?&lt;/h2&gt;
&lt;p&gt;Per-request connections solved the hardest shutdown problems (no idle tasks to signal). The remaining lesson: &lt;strong&gt;design shutdown around closing resources, not coordinating threads&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The accept loop runs in a background thread, blocking on &lt;code&gt;listener.accept()&lt;/code&gt;. To stop it, we don&apos;t set a flag and hope the thread notices: we close the listener. &lt;code&gt;accept()&lt;/code&gt; raises &lt;code&gt;OSError&lt;/code&gt;, the thread exits immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _accept_loop():
    while True:
        try:
            conn = listener.accept()
            loop.call_soon_threadsafe(_spawn_handler, conn)
        except OSError:
            break  # listener was closed — exit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same principle applies to the full shutdown sequence:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try:
    loop.run_forever()
finally:
    listener.close()                    # 1. stop accepting
    for task in tasks: task.cancel()    # 2. cancel handlers
    if tasks:
        loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
    registry.close_all()                # 3. release resources
    loop.close()
    # 4. remove socket and PID file ...
    os._exit(0)                         # 5. skip slow Python teardown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few details worth noting:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PID file as the exit signal.&lt;/strong&gt; The client needs to know when the daemon has finished cleaning up.&lt;/p&gt;
&lt;p&gt;:::note
&lt;code&gt;os.kill(pid, 0)&lt;/code&gt; returns True for zombie processes: they&apos;re still in the process table. The fix: the daemon removes its PID file as the last cleanup step, and the client polls for its absence instead.
:::&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;os._exit(0)&lt;/code&gt; for fast teardown.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;:::note
Python&apos;s normal exit takes seconds when torch is loaded (atexit handlers, GC finalizers, module teardown). &lt;code&gt;os._exit(0)&lt;/code&gt; skips all of that, safe because we&apos;ve already cleaned up explicitly.
:::&lt;/p&gt;
&lt;p&gt;(One caveat: &lt;code&gt;os._exit(0)&lt;/code&gt; terminates the &lt;em&gt;entire process&lt;/em&gt;, so we guard it with &lt;code&gt;threading.current_thread() is threading.main_thread()&lt;/code&gt; for tests that run the daemon in a thread.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Event loop on the main thread.&lt;/strong&gt; The accept loop runs in a background thread, but the asyncio event loop stays on the main thread. This is required for &lt;code&gt;loop.add_signal_handler()&lt;/code&gt; to work: SIGTERM and SIGINT call &lt;code&gt;loop.stop()&lt;/code&gt;, which causes &lt;code&gt;run_forever()&lt;/code&gt; to return into the &lt;code&gt;finally&lt;/code&gt; block above.&lt;/p&gt;
&lt;h2&gt;The Result&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;                    per-request connections
  CLI / MCP  ──────────────────────────────►  Daemon
  (thin)        connect → handshake →         (persistent)
                request → response →          - event loop (main thread)
                close                         - accept loop (background thread)
                                              - project registry
                                              - ML model (loaded once)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before and after:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connection handler&lt;/td&gt;
&lt;td&gt;~70 lines, recv loop, polling, shutdown event&lt;/td&gt;
&lt;td&gt;~40 lines, straight-line read-respond-close&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DaemonClient&lt;/code&gt; class, &lt;code&gt;__enter__&lt;/code&gt;/&lt;code&gt;__exit__&lt;/code&gt;, &lt;code&gt;.close()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Module-level functions, no state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shutdown concepts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;threading.Event&lt;/code&gt;, executor polling, 5-step escalation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;listener.close()&lt;/code&gt;, &lt;code&gt;loop.stop()&lt;/code&gt;, &lt;code&gt;os._exit(0)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daemon stop time&lt;/td&gt;
&lt;td&gt;~15 seconds (fell through to SIGKILL)&lt;/td&gt;
&lt;td&gt;&amp;lt;1 second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impact for coding-agent users&lt;/td&gt;
&lt;td&gt;Per-session ML model reload on every &lt;code&gt;ccc search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Model loaded once; search returns in &amp;lt;1 second&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Patterns for Local Daemon Architecture&lt;/h2&gt;
&lt;p&gt;If you&apos;re building a local daemon for developer tools, here&apos;s what we&apos;d suggest:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Make the daemon invisible.&lt;/strong&gt; Auto-start on first use. Auto-restart on version mismatch. Auto-reload config where possible. The user&apos;s mental model should be &quot;I run CLI commands&quot;. The daemon is an implementation detail.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prefer stateless IPC.&lt;/strong&gt; Per-request connections can&apos;t leak by construction. The overhead of Unix socket setup (~0.1ms) is negligible for developer tools. The handshake on every connection catches staleness faster.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Categorize your state.&lt;/strong&gt; What &lt;em&gt;must&lt;/em&gt; live in the daemon (loaded ML model, open databases) vs what can be read fresh (config files, file lists)? Only restart for the former. The less state the daemon holds, the fewer staleness bugs you&apos;ll have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design shutdown around resource closure, not signaling.&lt;/strong&gt; Close the listener to interrupt &lt;code&gt;accept()&lt;/code&gt;. Remove the PID file to signal completion. Don&apos;t poll threads to check if they&apos;ve noticed an event. Close the thing they&apos;re waiting on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When debugging gets complex, question the design.&lt;/strong&gt; We fixed three stacked shutdown bugs with careful threading, event types, and process liveness checks. Then we removed persistent connections and all three bugs disappeared. The debugging was correct. The design was wrong.&lt;/p&gt;
&lt;h2&gt;Support the Project&lt;/h2&gt;
&lt;p&gt;If you find this article helpful, please check out &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex-code&quot;&gt;cocoindex-code&lt;/a&gt; for the implementation and star the project if you like it!&lt;/p&gt;
</content:encoded><category>best-practices</category><category>architecture</category><category>ai-agents</category><author>George He</author></item><item><title>CocoIndex Changelog 0.3.27 - 0.3.34</title><link>https://cocoindex.io/blogs/changelog-0327-0334/</link><guid isPermaLink="true">https://cocoindex.io/blogs/changelog-0327-0334/</guid><description>Featuring five new target connectors, filesystem-level change detection, Python 3.14 free-threading, and smarter pipeline lifecycle management.</description><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Since the last release, CocoIndex shipped &lt;strong&gt;8 releases (0.3.27–0.3.34)&lt;/strong&gt;, and this cycle was focused on one clear goal: making CocoIndex the &lt;strong&gt;context engine&lt;/strong&gt; that adapts to the knowledge ecosystems (vector stores, graph databases, or analytics backends your agents need) while getting even smarter about keeping itself healthy underneath.&lt;/p&gt;
&lt;p&gt;We are super excited to build with the amazing infrastructure projects together on the path of agents going to production in 2026.&lt;/p&gt;
&lt;p&gt;Agents don&apos;t just need fresh data: they need it &lt;strong&gt;wherever it lives&lt;/strong&gt;. Different teams pick different databases by their use cases. Some want LanceDB for multi-modal capability. Some want ChromaDB for local-first development. Graph-heavy workloads need FalkorDB or Ladybug. Analytics &amp;amp; Search workloads want Apache Doris. And… a lot more great projects in the ecosystems coming soon. This cycle, we made sure CocoIndex speaks all of them, so you pick the best target for your use case, and CocoIndex handles the rest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CocoIndex helps you ace the data for agents without heavy data engineering work.&lt;/strong&gt; Continuous fresh, structured and programmable context for AI, from PDFs, Codebase, Emails, Screenshots, Meeting Notes, ... Always up-to-date, at any scale, ultra-performant, with a smart incremental engine. &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;&lt;strong&gt;⭐ Star the repo&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here is what we shipped since the last release.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;0.3.27–0.3.34 was about target reach and engine hardening&lt;/strong&gt;: five new target connectors, filesystem-level change detection, Python 3.14 free-threading, and smarter pipeline lifecycle management.&lt;/p&gt;
&lt;h2&gt;Core capability&lt;/h2&gt;
&lt;p&gt;CocoIndex&apos;s core engine got sharper this cycle. The runtime now automatically cleans up stale derived metadata and data when you rename or remove a source: no manual intervention, no orphaned rows. Tighter validation catches bad table and column names before they hit your database. A new &lt;code&gt;print_stats&lt;/code&gt; option surfaces pipeline statistics directly in the &lt;code&gt;update()&lt;/code&gt; API. And for the first time, CocoIndex supports &lt;strong&gt;Python 3.14 free-threaded mode&lt;/strong&gt;, running without the GIL for true parallelism in Python-heavy pipelines.&lt;/p&gt;
&lt;h3&gt;Automatic stale source cleanup&lt;/h3&gt;
&lt;p&gt;When CocoIndex detects that a source has been renamed or removed, it now automatically streams through tracking metadata, deletes corresponding target rows, and removes stale state records, all during flow setup with controlled parallelism to avoid overloading your database (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1468&quot;&gt;&lt;strong&gt;#1468&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Python 3.14 free-threaded support&lt;/h3&gt;
&lt;p&gt;CocoIndex now runs natively in Python 3.14&apos;s free-threaded mode (no GIL). CI builds ship free-threaded wheels on manylinux, so you can leverage true multi-threaded Python without any config changes (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1357&quot;&gt;&lt;strong&gt;#1357&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1609&quot;&gt;&lt;strong&gt;#1609&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1638&quot;&gt;&lt;strong&gt;#1638&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Tighter target validation&lt;/h3&gt;
&lt;p&gt;Table names and column names are now validated before target setup, catching naming issues early instead of surfacing cryptic database errors downstream (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1714&quot;&gt;&lt;strong&gt;#1714&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Execution plan initialization fix&lt;/h3&gt;
&lt;p&gt;Fixed a race condition where the execution plan could be used before target setup completed, ensuring correct pipeline initialization order (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1715&quot;&gt;&lt;strong&gt;#1715&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Pipeline stats in update() API&lt;/h3&gt;
&lt;p&gt;Added a &lt;code&gt;print_stats&lt;/code&gt; option to the &lt;code&gt;update()&lt;/code&gt; API, giving you runtime statistics directly at the call site, useful for monitoring, benchmarking, and debugging pipeline performance (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1525&quot;&gt;&lt;strong&gt;#1525&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Custom database schema for internal tables&lt;/h3&gt;
&lt;p&gt;CocoIndex&apos;s internal metadata and tracking tables now support custom Postgres schemas via explicit &lt;code&gt;schema.table&lt;/code&gt; qualification. This means cleaner isolation in shared databases: no more &lt;code&gt;search_path&lt;/code&gt; side effects (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1459&quot;&gt;&lt;strong&gt;#1459&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Slow HTTP request warnings&lt;/h3&gt;
&lt;p&gt;The runtime now warns when HTTP requests (e.g., to LLM providers or external APIs) are running slow, making it easier to spot bottlenecks in your pipeline (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1617&quot;&gt;&lt;strong&gt;#1617&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Enhanced tracing&lt;/h3&gt;
&lt;p&gt;Improved tracing coverage across the engine for better observability and debugging (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1618&quot;&gt;&lt;strong&gt;#1618&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;ColPali/ColQwen organization prefix fix&lt;/h3&gt;
&lt;p&gt;Fixed a crash when using ColPali or ColQwen models with organization-prefixed model names (e.g., &lt;code&gt;org/model-name&lt;/code&gt;) (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1624&quot;&gt;&lt;strong&gt;#1624&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Together, these changes make the core engine more self-healing, more observable, and ready for the next generation of Python runtimes.&lt;/p&gt;
&lt;h2&gt;Integrations&lt;/h2&gt;
&lt;p&gt;This cycle is a &lt;strong&gt;massive expansion of where CocoIndex can send data&lt;/strong&gt;: five brand-new target connectors, filesystem-level change detection for local files, and deeper LanceDB indexing support.&lt;/p&gt;
&lt;h3&gt;Sources&lt;/h3&gt;
&lt;h4&gt;Local file change notifications&lt;/h4&gt;
&lt;p&gt;The &lt;a href=&quot;https://cocoindex.io/docs/connectors/localfs/&quot;&gt;&lt;code&gt;LocalFile&lt;/code&gt; source&lt;/a&gt; now supports &lt;strong&gt;native filesystem change notifications&lt;/strong&gt; via the &lt;code&gt;notify&lt;/code&gt; crate. Enable the new &lt;code&gt;watch_changes&lt;/code&gt; option and CocoIndex will detect file changes through OS-level events instead of polling: faster reaction times, less CPU overhead, and your include/exclude patterns are respected automatically (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1669&quot;&gt;&lt;strong&gt;#1669&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Targets&lt;/h3&gt;
&lt;h4&gt;Apache Doris&lt;/h4&gt;
&lt;p&gt;Full-featured native support for &lt;a href=&quot;https://cocoindex.io/docs/connectors/doris/&quot;&gt;&lt;strong&gt;Apache Doris 4.0&lt;/strong&gt;&lt;/a&gt; (and VeloDB Cloud) as a vector database target. Includes HNSW and IVF vector indexes, inverted full-text search indexes with Unicode/English/Chinese parsers, Stream Load ingestion with batching and exponential backoff, and upsert/delete support. A complete text embedding example ships alongside (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1475&quot;&gt;&lt;strong&gt;#1475&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1642&quot;&gt;&lt;strong&gt;#1642&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;ChromaDB&lt;/h4&gt;
&lt;p&gt;Initial ChromaDB integration with support for &lt;code&gt;PersistentClient&lt;/code&gt;, &lt;code&gt;CloudClient&lt;/code&gt; (with SPANN+SPFresh), and &lt;code&gt;HttpClient&lt;/code&gt;. Includes HNSW configuration options, explicit &lt;code&gt;document_field&lt;/code&gt; specification, and early validation for vector fields (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1548&quot;&gt;&lt;strong&gt;#1548&lt;/strong&gt;&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1663&quot;&gt;&lt;strong&gt;#1663&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;FalkorDB&lt;/h4&gt;
&lt;p&gt;New &lt;strong&gt;property graph target&lt;/strong&gt; powered by &lt;a href=&quot;https://cocoindex.io/docs/connectors/falkordb/&quot;&gt;FalkorDB&lt;/a&gt;, a high-performance graph database built on Redis. Full support for nodes, relationships, vector and FTS indexes, and batch operations. Ships with a complete docs-to-knowledge-graph example using LLM extraction (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1481&quot;&gt;&lt;strong&gt;#1481&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Ladybug (successor to Kuzu)&lt;/h4&gt;
&lt;p&gt;Kuzu was archived in October 2025. &lt;strong&gt;Ladybug&lt;/strong&gt; is the active community fork and a drop-in replacement with an identical Cypher API. CocoIndex now targets Ladybug natively, with backward-compatible &lt;code&gt;Kuzu*&lt;/code&gt; type aliases so existing pipelines migrate seamlessly (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1487&quot;&gt;&lt;strong&gt;#1487&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;LanceDB&lt;/h4&gt;
&lt;p&gt;Added &lt;code&gt;VectorIndexMethod&lt;/code&gt; support for &lt;strong&gt;HNSW and IVF Flat indexes&lt;/strong&gt; in &lt;a href=&quot;https://cocoindex.io/docs/connectors/lancedb/&quot;&gt;LanceDB&lt;/a&gt;, giving you fine-grained control over index configuration (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1409&quot;&gt;&lt;strong&gt;#1409&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Pinecone&lt;/h4&gt;
&lt;p&gt;Pinecone is now a built-in target connector: bring your managed Pinecone indexes directly into CocoIndex pipelines with a text embedding example included out of the box (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1556&quot;&gt;&lt;strong&gt;#1556&lt;/strong&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;These integrations bring CocoIndex&apos;s supported target ecosystem to &lt;strong&gt;10+ databases&lt;/strong&gt;, covering vector stores, graph databases, relational systems, and analytics engines.&lt;/p&gt;
&lt;h2&gt;Build with CocoIndex&lt;/h2&gt;
&lt;h3&gt;SEC EDGAR financial analytics with CocoIndex + Apache Doris&lt;/h3&gt;
&lt;p&gt;A full &lt;strong&gt;multi-source ETL pipeline&lt;/strong&gt; demonstrating how to build a financial analytics system from SEC EDGAR data using CocoIndex and Apache Doris.&lt;/p&gt;
&lt;p&gt;The example ingests three data sources: TXT filings (10-K/10-Q risk factors), JSON company facts from the SEC XBRL API, and PDF proxy statements. It then indexes them into Apache Doris with &lt;strong&gt;hybrid search&lt;/strong&gt; (vector + keyword via Reciprocal Rank Fusion), &lt;strong&gt;topic filtering&lt;/strong&gt; with JSON arrays, and &lt;strong&gt;portfolio-level search&lt;/strong&gt; with per-company aggregation.&lt;/p&gt;
&lt;p&gt;It also demonstrates PII scrubbing before indexing and incremental updates with caching, a production-ready pattern for financial data pipelines.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/sec_edgar_financial_analytics&quot;&gt;&lt;strong&gt;tutorial&lt;/strong&gt;&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h3&gt;Slides-to-Speech: Turn slide decks into searchable, narrated knowledge&lt;/h3&gt;
&lt;p&gt;Static slide decks sitting in Google Drive are one of the most underutilized knowledge sources in any organization. We published a full walkthrough showing how to build a &lt;strong&gt;continuously updating slides-to-speech pipeline&lt;/strong&gt; using CocoIndex and LanceDB.&lt;/p&gt;
&lt;p&gt;The pipeline watches a Drive folder for new or modified PDF decks, converts each slide to an image with &lt;strong&gt;PyMuPDF&lt;/strong&gt;, extracts structured speaker notes using &lt;strong&gt;BAML + Gemini Vision&lt;/strong&gt;, synthesizes narration with &lt;strong&gt;Piper-TTS&lt;/strong&gt;, generates embeddings, and indexes everything into &lt;strong&gt;LanceDB&lt;/strong&gt;: images, transcripts, audio bytes, and metadata all in one place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&apos;s cool about this example:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Incremental by design&lt;/strong&gt;: Only changed slides get reprocessed. Update one slide in a 200-page deck? CocoIndex touches only that slide, regenerating its audio, transcript, and embedding while leaving the rest untouched.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;True multimodal search&lt;/strong&gt;: Semantic queries return the top matching slides with images, speaker notes, and synced audio, not just filenames.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local-first TTS&lt;/strong&gt;: Neural text-to-speech runs locally via Piper, so there are no API costs for audio generation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Living knowledge base&lt;/strong&gt;: What used to be a static PDF becomes a searchable, listenable, always-fresh dataset ready for agents, accessibility tools, or video generation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://cocoindex.io/blogs/slides-to-speech&quot;&gt;&lt;strong&gt;blog post&lt;/strong&gt;&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h2&gt;Thanks to the community 🤗🎉&lt;/h2&gt;
&lt;p&gt;Welcome new contributors to the CocoIndex community! We are so excited to have you!&lt;/p&gt;
&lt;h3&gt;@tomz-alt&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/tomz-alt&quot;&gt;@tomz-alt&lt;/a&gt; for adding the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1475&quot;&gt;Apache Doris 4.0 vector database connector&lt;/a&gt; (a full-featured target with vector indexes, full-text search, and Stream Load ingestion) and for building the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1623&quot;&gt;SEC EDGAR financial analytics example&lt;/a&gt; demonstrating multi-source ETL with hybrid search, plus &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1567&quot;&gt;improving the Doris text embedding example&lt;/a&gt; and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1650&quot;&gt;cleaning up the SEC EDGAR tutorial&lt;/a&gt; for an end-to-end demo experience.&lt;/p&gt;
&lt;h3&gt;@prrao87&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prrao87&quot;&gt;@prrao87&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1487&quot;&gt;Ladybug&lt;/a&gt; (the Kuzu successor) as a target, ensuring CocoIndex users have a smooth migration path to the actively maintained graph database fork.&lt;/p&gt;
&lt;h3&gt;@galshubeli&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/galshubeli&quot;&gt;@galshubeli&lt;/a&gt; for adding the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1481&quot;&gt;FalkorDB target&lt;/a&gt;, a complete property graph integration with nodes, relationships, vector/FTS indexes, and a docs-to-knowledge-graph example.&lt;/p&gt;
&lt;h3&gt;@Geoff-Robin&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Geoff-Robin&quot;&gt;@Geoff-Robin&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1459&quot;&gt;custom database schema support&lt;/a&gt; for CocoIndex&apos;s internal tables, enabling cleaner isolation in shared Postgres databases with explicit &lt;code&gt;schema.table&lt;/code&gt; qualification.&lt;/p&gt;
&lt;h3&gt;@wuxiaobai24&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/wuxiaobai24&quot;&gt;@wuxiaobai24&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1357&quot;&gt;Python 3.14 free-threaded support&lt;/a&gt;, configuring PyO3 for GIL-free operation and bringing CocoIndex to the cutting edge of Python parallelism.&lt;/p&gt;
&lt;h3&gt;@ceshine&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/ceshine&quot;&gt;@ceshine&lt;/a&gt; for &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1624&quot;&gt;fixing the ColPali/ColQwen crash&lt;/a&gt; when using models with organization prefixes, unblocking multimodal embedding workflows.&lt;/p&gt;
&lt;h3&gt;@LED-0102&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/LED-0102&quot;&gt;@LED-0102&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1669&quot;&gt;filesystem change notifications&lt;/a&gt; to the LocalFile source using the &lt;code&gt;notify&lt;/code&gt; crate, enabling OS-level file watching for faster live updates.&lt;/p&gt;
&lt;h3&gt;@prithvi-moonshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prithvi-moonshot&quot;&gt;@prithvi-moonshot&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1556&quot;&gt;Pinecone as a target connector&lt;/a&gt; with a text embedding example, bringing managed vector search into the CocoIndex ecosystem.&lt;/p&gt;
&lt;h3&gt;@Haleshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Haleshot&quot;&gt;@Haleshot&lt;/a&gt; for building the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1548&quot;&gt;initial ChromaDB target integration&lt;/a&gt; with support for PersistentClient, CloudClient, and HttpClient, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1663&quot;&gt;fixing ChromaDB collection creation on first run&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1621&quot;&gt;adding ChromaDB documentation&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1455&quot;&gt;adding llms.txt to docs&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1458&quot;&gt;adding short flag aliases for common CLI options&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1574&quot;&gt;clarifying .env usage across examples&lt;/a&gt;, and &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1564&quot;&gt;migrating CI from pre-commit to prek&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@thisisharsh7&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/thisisharsh7&quot;&gt;@thisisharsh7&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1468&quot;&gt;automatic cleanup of stale source data&lt;/a&gt;, ensuring renamed or removed sources don&apos;t leave orphaned tracking metadata or target rows.&lt;/p&gt;
&lt;h3&gt;@majiayu000&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/majiayu000&quot;&gt;@majiayu000&lt;/a&gt; for adding &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1409&quot;&gt;&lt;code&gt;VectorIndexMethod&lt;/code&gt; support for HNSW and IVF Flat indexes&lt;/a&gt; in LanceDB, expanding vector index configuration options.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;CocoIndex continues evolving into a universal, resilient data engine for the modern AI stack.&lt;/p&gt;
&lt;p&gt;This cycle focuses on target ecosystem breadth, engine self-healing, and Python 3.14 readiness, ensuring your pipelines can write to any database your agents need, clean up after themselves, and run on the latest Python runtime without compromise.&lt;/p&gt;
&lt;p&gt;If you like this project, please support us with a star ⭐ at &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;https://github.com/cocoindex-io/cocoindex&lt;/a&gt; !&lt;/p&gt;
&lt;p&gt;🔥🔥 We are cooking something big! v1 is coming soon. Stay tuned! If you read all the way here, you are a true fan!&lt;/p&gt;
</content:encoded><category>changelog</category><category>connectors</category><category>performance</category><category>incremental-processing</category><author>George He</author></item><item><title>CocoIndex joins the GitHub Secure Open Source Fund</title><link>https://cocoindex.io/blogs/cocoindex-joins-security-github-secure-open-source-fund/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-joins-security-github-secure-open-source-fund/</guid><description>CocoIndex joined the GitHub Secure Open Source Fund, hardening the AI data infrastructure developers depend on with threat modeling, CodeQL, and audits.</description><pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;At &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt;, we&apos;ve always believed that the strength of open source lies in its community and in building transparent, reliable infrastructure together.
CocoIndex is a &lt;a href=&quot;https://cocoindex.io/blogs/data-for-ai&quot;&gt;data transformation framework&lt;/a&gt; that sits between raw data and the AI systems that depend on it, and that position makes security a core requirement.&lt;/p&gt;
&lt;p&gt;When developers trust CocoIndex to process sensitive documents, codebases, and enterprise data, we owe them the highest standards.
We are honored to be selected to join the &lt;a href=&quot;https://resources.github.com/github-secure-open-source-fund/&quot;&gt;GitHub Secure Open Source Fund&lt;/a&gt;, to formalize our commitment and accelerate our security practices.&lt;/p&gt;
&lt;h2&gt;What is GitHub&apos;s Secure Open Source Fund?&lt;/h2&gt;
&lt;p&gt;When the Log4j &lt;a href=&quot;https://en.wikipedia.org/wiki/Log4Shell&quot;&gt;&quot;Log4Shell&quot;&lt;/a&gt; vulnerability hit in December 2021, it showed that a bug in one small, underfunded open-source project can ripple through the entire global tech stack.
Most apps today rely on countless dependencies, many maintained by unpaid volunteers, and a weakness in any one of them reaches everyone downstream.&lt;/p&gt;
&lt;p&gt;Recognizing this, GitHub launched the &lt;a href=&quot;https://github.com/open-source/github-secure-open-source-fund&quot;&gt;Secure Open Source (SOS) Fund&lt;/a&gt; in late 2024 to strengthen the foundation of open-source security.
The program pairs direct funding with a focused three-week security sprint that combines expert guidance, better tooling, and a
community of security-conscious maintainers.&lt;/p&gt;
&lt;p&gt;In its first two cohorts, 125 maintainers from 71 projects participated, collectively fixing over 1,100 vulnerabilities, disclosing 50+ new CVEs, and resolving nearly 270 secret exposures.&lt;/p&gt;
&lt;p&gt;Participants also exchanged knowledge, leveraged AI-assisted tools like GitHub Copilot for security tasks, and developed incident response plans that many have since published for others to reuse.&lt;/p&gt;
&lt;h2&gt;What is CocoIndex?&lt;/h2&gt;
&lt;p&gt;Dynamic context engineering for AI.&lt;/p&gt;
&lt;p&gt;As AI systems evolve from chatbots to autonomous agents, one hard problem keeps coming back: keeping context accurate and up to date as the underlying data changes.&lt;/p&gt;
&lt;p&gt;Enterprises sit on massive, dynamic datasets (documents, codebases, emails, APIs), yet most data infra is still batch-oriented and blind to change.
Every shift in data or logic forces engineers to rebuild from scratch, breaking the feedback loop between real-time world state and AI decision-making.
Dynamic context engineering addresses this: maintaining an AI system&apos;s &quot;mental model&quot; of the world, continuously, reliably, and incrementally.
CocoIndex is our take on making this simple, with a &lt;strong&gt;declarative Python-native &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;data transformation engine&lt;/a&gt; designed for AI workloads&lt;/strong&gt;, and you will never need to handle change.&lt;/p&gt;
&lt;p&gt;Think of it as &lt;strong&gt;React for data processing&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;The Cohort &amp;amp; Thanks to our batchmates&lt;/h2&gt;
&lt;p&gt;We were in the program alongside &lt;a href=&quot;https://github.com/pandas-dev/pandas&quot;&gt;Pandas&lt;/a&gt;, &lt;a href=&quot;https://github.com/apache/airflow&quot;&gt;Apache Airflow&lt;/a&gt;, &lt;a href=&quot;https://github.com/fabricjs/fabric.js&quot;&gt;Fabric.js&lt;/a&gt;, &lt;a href=&quot;https://github.com/pypi&quot;&gt;PyPI&lt;/a&gt;, and others.
We heard from teams dealing with similar challenges: how they handle dependency updates, structure security reviews, and think about trust boundaries.&lt;/p&gt;
&lt;p&gt;Open source can be isolating. You&apos;re deep in your own codebase, solving your own problems. Hearing how other maintainers answer the same security questions was one of the most useful parts of the program.&lt;/p&gt;
&lt;h2&gt;Strengthening CocoIndex: Security Improvements Implemented&lt;/h2&gt;
&lt;p&gt;During the program, the CocoIndex team ran a focused security sprint to harden both our codebase and the way we build and ship it.
With guidance from GitHub Security Lab and community experts, we concentrated on four areas that matter most for a framework sitting directly in AI data paths.&lt;/p&gt;
&lt;h3&gt;Threat modeling &amp;amp; AI-specific risk assessment&lt;/h3&gt;
&lt;p&gt;We systematically threat-modeled the CocoIndex framework, examining how an attacker might exploit a data transformation pipeline that feeds directly into AI systems.
We mapped out attack vectors including adversarial document content that could poison embeddings, prompt injection through source data that could manipulate LLM-based extraction, and
compromised dependencies that could silently corrupt downstream indexes. Resources like the &lt;a href=&quot;https://github.com/rzhade3/adversarial-ai-reading-list&quot;&gt;Adversarial AI Reading List&lt;/a&gt; and &lt;a href=&quot;https://embracethered.com/&quot;&gt;Embrace The Red&lt;/a&gt; were particularly useful in shaping this analysis.
By identifying these failure points upfront, we developed mitigation strategies before they could be exploited in production pipelines.&lt;/p&gt;
&lt;h3&gt;Secure coding &amp;amp; automated checks&lt;/h3&gt;
&lt;p&gt;We wired GitHub’s security tooling directly into CocoIndex’s development loop.
&lt;a href=&quot;https://codeql.github.com/&quot;&gt;CodeQL&lt;/a&gt; now runs on every pull request, scanning both our Rust core and Python SDK for vulnerability patterns.
For a system where data flows from sources through transformations to targets,
CodeQL’s data flow analysis maps naturally onto our architecture, tracing untrusted input from ingestion all
the way to exported artifacts. We also enabled secret scanning to keep credentials and API keys out of the repository,
and we started using GitHub Copilot as a guardrail during development to surface potential issues before code is even committed.
Together, these checks act as a continuous security layer in our CI pipeline, catching problems early instead of after a release.&lt;/p&gt;
&lt;h3&gt;Dependency &amp;amp; supply chain hardening&lt;/h3&gt;
&lt;p&gt;CocoIndex integrates with embedding models, vector databases, document parsers, and cloud storage backends, each one pulling in its own dependency chain.
We audited our dependencies across both the Rust core and Python SDK, going beyond direct dependencies to examine the
transitive tree underneath. We tightened our dependency review process with more systematic tracking of what we depend on and why,
and adopted the &lt;a href=&quot;https://securityscorecards.dev/&quot;&gt;OpenSSF Scorecard&lt;/a&gt; to continuously benchmark our project&apos;s security posture across branch protection, dependency updates,
CI/CD practices, and more. Following &lt;a href=&quot;https://openssf.org/&quot;&gt;OpenSSF&lt;/a&gt; standards,
we&apos;re working toward a comprehensive software bill of materials (SBOM) for each release,
giving users visibility into exactly what ships with CocoIndex.&lt;/p&gt;
&lt;h3&gt;Policy, documentation &amp;amp; vulnerability response&lt;/h3&gt;
&lt;p&gt;We formalized our &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/.github/SECURITY.md&quot;&gt;SECURITY.md&lt;/a&gt; with clear instructions for responsible disclosure and defined response commitments.
Vulnerabilities should be reported to security@cocoindex.io, not filed as public issues.
We also established an internal process for triaging and patching reported vulnerabilities: who gets involved,
how we communicate, and how quickly we ship fixes. Every new connector or &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;transformation function&lt;/a&gt; now gets
reviewed through the lens of: what&apos;s the worst-case input, and what happens if a dependency in this path is compromised?&lt;/p&gt;
&lt;p&gt;CodeQL is already catching issues that manual review missed, and the disclosure process gives users a clear channel when something does slip through. Because CocoIndex sits in the data path of the AI systems built on it, these improvements carry downstream to every application that depends on those pipelines.&lt;/p&gt;
&lt;h2&gt;Thank You to Our Contributors&lt;/h2&gt;
&lt;p&gt;None of this would have been possible without the people who build CocoIndex with us every day. Everyone who has submitted a PR, reported a bug, reviewed code, or helped another user in Discord: you&apos;re the reason this project exists and the reason it was recognized.&lt;/p&gt;
&lt;p&gt;We especially want to thank those doing the work that doesn&apos;t show up in commit logs: writing documentation, answering questions, and building examples that help new users get started.&lt;/p&gt;
&lt;h2&gt;Looking Forward&lt;/h2&gt;
&lt;p&gt;The sprint is over, but the checks it added run on every pull request, and the review habits stay with us for every new connector and integration.&lt;/p&gt;
&lt;p&gt;The GitHub Secure Open Source Fund gave us dedicated time, expert guidance, and a community of peers to learn from. The improvements we&apos;ve made will benefit every developer who builds an AI pipeline with CocoIndex.&lt;/p&gt;
&lt;p&gt;We&apos;re grateful to GitHub and the entire cohort for this experience, and we plan to keep sharing what we&apos;ve learned with the broader AI infrastructure community.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;CocoIndex is open source on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;. Questions or feedback: join our &lt;a href=&quot;https://discord.com/invite/zpA9S2DR7s&quot;&gt;Discord&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>announcement</category><category>security</category><category>best-practices</category><category>community</category><author>Linghua Jin</author></item><item><title>SEC EDGAR financial analytics with Apache Doris</title><link>https://cocoindex.io/blogs/sec-edgar-analytics/</link><guid isPermaLink="true">https://cocoindex.io/blogs/sec-edgar-analytics/</guid><description>A multi-format CocoIndex v1 pipeline that ingests SEC filings (TXT, JSON), scrubs PII, extracts topics, and powers hybrid search with Apache Doris.</description><pubDate>Mon, 09 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SEC filings are the backbone of financial transparency. Every public company in the United States files 10-Ks, 10-Qs, proxy statements, and exhibits with the SEC: thousands of documents each quarter across text, structured data, and PDF formats.&lt;/p&gt;
&lt;p&gt;Searching across all of these effectively requires more than keyword matching. You need semantic understanding, structured metadata filtering, and the ability to combine multiple document formats into a single searchable index.&lt;/p&gt;
&lt;p&gt;In this post, we walk through the &lt;strong&gt;SEC EDGAR Financial Analytics&lt;/strong&gt; example: a CocoIndex v1 pipeline that ingests two source formats (TXT 10-K filings and XBRL company-facts JSON), scrubs PII, extracts topic tags, generates embeddings, and loads everything into Apache Doris for hybrid search combining vector similarity with full-text matching using Reciprocal Rank Fusion (RRF). Both formats fan into one shared path, so adding another format like PDF is the same handful of lines.&lt;/p&gt;
&lt;p&gt;The project is open-sourced in the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/sec_edgar_analytics&quot;&gt;CocoIndex v1 repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Why CocoIndex?&lt;/h2&gt;
&lt;p&gt;CocoIndex is a Rust-based, open-source data transformation framework for AI workloads, combining high performance with flexibility.
It supports &lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental processing&lt;/a&gt;, &lt;a href=&quot;https://cocoindex.io/blogs/cocoinsight&quot;&gt;data lineage&lt;/a&gt;, and customizable logic, allowing teams to build efficient and intelligent data pipelines.
CocoIndex makes transformation pipelines modular, transparent, and easy to maintain.&lt;/p&gt;
&lt;h2&gt;Why Apache Doris?&lt;/h2&gt;
&lt;p&gt;Apache Doris is an open-source, Apache-licensed real-time MPP data warehouse built for lightning-fast analytics.
It supports high-concurrency workloads with real-time data ingestion and querying, handles both structured and semi-structured (VARIANT) data,
includes full-text inverted indexes, and native vector storage with approximate nearest neighbor (ANN) search.&lt;/p&gt;
&lt;p&gt;Together, CocoIndex and &lt;a href=&quot;https://cocoindex.io/docs/connectors/doris/&quot;&gt;Apache Doris&lt;/a&gt; form a powerful agentic data infrastructure stack.
CocoIndex transforms and indexes unstructured data through modular, lineage-tracked pipelines with built-in incremental processing, while Apache Doris delivers real-time analytics at scale with
sub-second ingestion latency, sub-100ms query response, and 10k QPS concurrency: capabilities purpose-built for AI agents that need to make fast, data-driven decisions.
The combination bridges the gap from raw, unstructured data to ultra-performant real-time search and analytics, while CocoIndex&apos;s data lineage and transparency ensure the auditability and
compliance that regulated industries demand.&lt;/p&gt;
&lt;h2&gt;Architecture overview&lt;/h2&gt;
&lt;p&gt;The pipeline follows a multi-format, shared-path pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────────┐
│                     CocoIndex Multi-Format Pipeline                       │
│                                                                           │
│        ┌──────────────────┐          ┌──────────────────┐                 │
│        │ TXT 10-K Filings │          │ XBRL JSON Facts  │                 │
│        │  (risk factors)  │          │ (company facts)  │                 │
│        └────────┬─────────┘          └────────┬─────────┘                 │
│                 │                             │                           │
│                 ▼                             ▼                           │
│        ┌────────────────────────────────────────────────┐                 │
│        │            Shared _index_text path              │                 │
│        │   Scrub PII → Chunk → Embed → Tag → declare_row │                 │
│        └────────────────────────────────────────────────┘                 │
│                             │                                             │
│                             ▼                                             │
│                      Apache Doris                                         │
│         (vector ANN index + inverted full-text index)                     │
└─────────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two file formats feed into a single table.
Each source extracts its own metadata, then converges on one shared function that scrubs PII, splits, embeds, and tags.
The output lands in a single Doris table with both a vector index and a full-text index.&lt;/p&gt;
&lt;h2&gt;Define the pipeline&lt;/h2&gt;
&lt;p&gt;CocoIndex v1 is declarative and Python-native: you write &lt;code&gt;target_state = transformation(source_state)&lt;/code&gt; in plain async Python, and the Rust engine handles incremental processing. The whole example lives in one &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/examples/sec_edgar_analytics/main.py&quot;&gt;&lt;code&gt;main.py&lt;/code&gt;&lt;/a&gt; with no DSL and no separate flow-definition file.&lt;/p&gt;
&lt;p&gt;The entry point is an &lt;code&gt;app_main&lt;/code&gt; function wrapped in a &lt;code&gt;coco.App&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import cocoindex as coco
from cocoindex.connectors import doris, localfs
from cocoindex.ops.text import RecursiveSplitter
from cocoindex.ops.sentence_transformers import SentenceTransformerEmbedder
from cocoindex.resources.file import FileLike, PatternFilePathMatcher

app = coco.App(coco.AppConfig(name=&quot;SECFilingAnalytics&quot;), app_main)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The row schema&lt;/h3&gt;
&lt;p&gt;Each chunk becomes one typed row. In v1 you declare the output as a plain dataclass; the &lt;code&gt;embedding&lt;/code&gt; field is annotated with the embedder context so the engine knows its vector shape:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class FilingChunk:
    chunk_id: str            # primary key: uuid5 of (filename, chunk offsets)
    source_type: str         # &quot;filing&quot; | &quot;facts&quot;
    doc_filename: str
    cik: str
    filing_date: str
    form_type: str
    text: str
    topics: list[str]
    embedding: Annotated[NDArray, EMBEDDER]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The dual-index target&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;app_main&lt;/code&gt; mounts one Doris table target that carries both a vector index and a full-text inverted index, then walks each source directory and mounts a per-file processor:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def app_main() -&amp;gt; None:
    table = await doris.mount_table_target(
        DORIS_DB,
        TABLE,
        await doris.TableSchema.from_class(FilingChunk, primary_key=[&quot;chunk_id&quot;]),
        vector_indexes=[
            doris.VectorIndexDef(field_name=&quot;embedding&quot;, metric_type=&quot;l2_distance&quot;)
        ],
        inverted_indexes=[doris.InvertedIndexDef(field_name=&quot;text&quot;, parser=&quot;unicode&quot;)],
    )

    txt = localfs.walk_dir(
        localfs.FilePath(path=&quot;./data/filings&quot;),
        path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.txt&quot;]),
    )
    await coco.mount_each(process_filing, txt.items(), table)

    facts = localfs.walk_dir(
        localfs.FilePath(path=&quot;./data/company_facts&quot;),
        path_matcher=PatternFilePathMatcher(included_patterns=[&quot;**/*.json&quot;]),
    )
    await coco.mount_each(process_facts, facts.items(), table)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A single &lt;code&gt;mount_table_target&lt;/code&gt; declares two indexes on one table:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vector (ANN) index&lt;/strong&gt; on the &lt;code&gt;embedding&lt;/code&gt; field for &lt;code&gt;l2_distance&lt;/code&gt; semantic similarity search&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inverted index&lt;/strong&gt; on the &lt;code&gt;text&lt;/code&gt; field for keyword matching with &lt;code&gt;MATCH_ANY&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This dual-index setup is what makes hybrid search possible without maintaining separate stores.&lt;/p&gt;
&lt;p&gt;Each source uses &lt;a href=&quot;https://cocoindex.io/docs/connectors/localfs/&quot;&gt;&lt;code&gt;localfs.walk_dir&lt;/code&gt;&lt;/a&gt; with pattern filtering, and &lt;code&gt;coco.mount_each&lt;/code&gt; runs the per-file processor once per matched file.&lt;/p&gt;
&lt;h3&gt;Process text filings&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;process_filing&lt;/code&gt; parses the structured filename convention &lt;code&gt;{CIK}_{date}_{form}.txt&lt;/code&gt; to pull out the company identifier (CIK), filing date, and form type (10-K, 10-Q, etc.), metadata needed for filtering and aggregation at query time. It then hands the text to the shared indexing path:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_filing(
    file: FileLike, table: doris.DorisTableTarget[FilingChunk]
) -&amp;gt; None:
    &quot;&quot;&quot;10-K text filing: metadata from the {CIK}_{date}_{form}.txt filename.&quot;&quot;&quot;
    name = file.file_path.path.name
    parts = name.rsplit(&quot;.&quot;, 1)[0].split(&quot;_&quot;)
    cik = parts[0] if parts else &quot;unknown&quot;
    filing_date = parts[1] if len(parts) &amp;gt; 1 else &quot;2024-01-01&quot;
    form_type = parts[2] if len(parts) &amp;gt; 2 else &quot;10-K&quot;
    await _index_text(
        await file.read_text(), &quot;filing&quot;, name, cik, filing_date, form_type, table
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;memo=True&lt;/code&gt; memoizes the function: re-running skips files whose content and code are unchanged, so only new or edited filings are reprocessed.&lt;/p&gt;
&lt;p&gt;We intentionally parse metadata with a deterministic string split rather than an LLM. When you know the file format upfront, a regex or string parser is faster, cheaper, and more reliable than calling an LLM on every document. CocoIndex still supports native &lt;a href=&quot;https://cocoindex.io/docs/examples/manuals-llm-extraction/&quot;&gt;LLM extraction&lt;/a&gt; for formats you don&apos;t know in advance, and tools like CocoInsight help validate the output either way.&lt;/p&gt;
&lt;h3&gt;The shared indexing path&lt;/h3&gt;
&lt;p&gt;Both sources converge on one &lt;code&gt;_index_text&lt;/code&gt; helper that does the real work: scrub PII, split into overlapping chunks, embed, tag, and declare one row per chunk.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def _index_text(text, source_type, filename, cik, filing_date, form_type, table):
    embedder = coco.use_context(EMBEDDER)
    for chunk in _splitter.split(
        _scrub_pii(text), chunk_size=1000, chunk_overlap=200, language=&quot;markdown&quot;
    ):
        table.declare_row(row=FilingChunk(
            chunk_id=_chunk_id(filename, chunk.start.char_offset, chunk.end.char_offset),
            source_type=source_type,
            doc_filename=filename,
            cik=cik,
            filing_date=filing_date,
            form_type=form_type,
            text=chunk.text,
            topics=_extract_topics(chunk.text),
            embedding=await embedder.embed(chunk.text),
        ))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;1. Scrub PII before chunking.&lt;/strong&gt; PII (Personally Identifiable Information) includes things like Social Security numbers, phone numbers, and email addresses. SEC filings sometimes contain these inadvertently (there are even SEC rules about this: Reg S-T Rule 83). Scrubbing happens on the full document &lt;em&gt;before&lt;/em&gt; chunking, so a phone number or SSN split across a chunk boundary can&apos;t slip through into the search index.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Split into chunks.&lt;/strong&gt; Chunk size is a tradeoff: too large and you lose search resolution (a 10,000-character chunk matches broadly but vaguely); too small and each chunk lacks enough context to be meaningful on its own. We use 1,000 characters with 200-character overlap as a practical middle ground for SEC filings. &lt;code&gt;RecursiveSplitter&lt;/code&gt; splits text by respecting document structure: it tries to break at markdown headings, then paragraphs, then sentences, before falling back to character boundaries. A flat splitter would just cut every N characters regardless of where a sentence or section ends, often splitting mid-thought.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Embed, tag, and declare a row.&lt;/strong&gt; For each chunk we generate an embedding vector (for semantic search) and extract topic tags (for structured filtering), then &lt;code&gt;declare_row&lt;/code&gt; assembles one &lt;code&gt;FilingChunk&lt;/code&gt; combining the chunk&apos;s text, embedding, and topics with the document-level metadata (CIK, filing date, form type). &lt;code&gt;chunk_id&lt;/code&gt; is a stable &lt;code&gt;uuid5&lt;/code&gt; of the filename and chunk offsets, so re-running reconciles rows in place instead of duplicating.&lt;/p&gt;
&lt;p&gt;Topics are extracted as string arrays, enabling Doris array filtering:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _extract_topics(text: str) -&amp;gt; list[str]:
    low = text.lower()
    return [t for t, kws in _TOPIC_KEYWORDS.items() if any(k in low for k in kws)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_TOPIC_KEYWORDS&lt;/code&gt; maps labels like &lt;code&gt;RISK:CYBER&lt;/code&gt;, &lt;code&gt;RISK:CLIMATE&lt;/code&gt;, &lt;code&gt;TOPIC:AI&lt;/code&gt;, and &lt;code&gt;TOPIC:FINANCIAL&lt;/code&gt; to keyword lists. This produces arrays like &lt;code&gt;[&quot;RISK:CYBER&quot;, &quot;TOPIC:AI&quot;]&lt;/code&gt;, stored as a JSON column you can filter in Doris with &lt;code&gt;json_contains(topics, &apos;&quot;RISK:CYBER&quot;&apos;)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Process XBRL company facts&lt;/h3&gt;
&lt;p&gt;The JSON facts path is the same shape: &lt;code&gt;process_facts&lt;/code&gt; reads the file, renders the XBRL metrics to searchable natural-language text, then calls the same &lt;code&gt;_index_text&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_facts(
    file: FileLike, table: doris.DorisTableTarget[FilingChunk]
) -&amp;gt; None:
    &quot;&quot;&quot;XBRL company-facts JSON: render to text, then index.&quot;&quot;&quot;
    name = file.file_path.path.name
    content = await file.read_text()
    cik = name.replace(&quot;CIK&quot;, &quot;&quot;).replace(&quot;.json&quot;, &quot;&quot;)
    filing_date = (
        json.loads(content).get(&quot;filingDate&quot;, &quot;2024-01-01&quot;) if content else &quot;2024-01-01&quot;
    )
    await _index_text(
        _company_facts_to_text(content), &quot;facts&quot;, name, cik, filing_date, &quot;FACTS&quot;, table
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_company_facts_to_text&lt;/code&gt; converts structured financial metrics (revenue, net income, R&amp;amp;D expense) into natural-language text so they&apos;re discoverable via semantic search. Because both sources end at the same &lt;code&gt;_index_text&lt;/code&gt; and &lt;code&gt;declare_row&lt;/code&gt; into the same table, adding a third format such as PDF is just another &lt;code&gt;process_*&lt;/code&gt; that converts to text and calls &lt;code&gt;_index_text&lt;/code&gt; (see &lt;a href=&quot;https://cocoindex.io/docs/examples/manuals-llm-extraction/&quot;&gt;Manuals to Structured Data&lt;/a&gt; for the docling PDF path).&lt;/p&gt;
&lt;h3&gt;Providing shared resources&lt;/h3&gt;
&lt;p&gt;The Doris connection and the embedder are provided once in the app lifespan and retrieved anywhere with &lt;code&gt;coco.use_context&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DORIS_DB = coco.ContextKey[doris.ManagedConnection](&quot;sec_doris&quot;)
EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder](&quot;embedder&quot;, detect_change=True)

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -&amp;gt; AsyncIterator[None]:
    builder.provide(DORIS_DB, doris.connect(doris.DorisConnectionConfig(...)))
    builder.provide(EMBEDDER, SentenceTransformerEmbedder(EMBED_MODEL))
    yield
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Hybrid search with RRF&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;search.py&lt;/code&gt; combines semantic and lexical ranking using Reciprocal Rank Fusion, all in one Doris SQL query over the MySQL protocol:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def search(query: str, source_type: str | None = None, limit: int = 5) -&amp;gt; None:
    vec = asyncio.run(_embedder.embed(query))
    vstr = &quot;[&quot; + &quot;,&quot;.join(f&quot;{x:.6f}&quot; for x in vec) + &quot;]&quot;
    keywords = &quot; &quot;.join(re.findall(r&quot;[A-Za-z]{3,}&quot;, query.lower()))
    where = f&quot;source_type = &apos;{source_type}&apos;&quot; if source_type else &quot;1 = 1&quot;

    sql = f&quot;&quot;&quot;
    WITH semantic AS (
        SELECT chunk_id, doc_filename, source_type, topics, text,
               ROW_NUMBER() OVER (ORDER BY l2_distance(embedding, {vstr})) AS rk
        FROM filing_chunks WHERE {where}
    ),
    lexical AS (
        SELECT chunk_id,
               ROW_NUMBER() OVER (ORDER BY CASE WHEN text MATCH_ANY &apos;{keywords}&apos;
                                  THEN 0 ELSE 1 END) AS rk
        FROM filing_chunks WHERE {where}
    )
    SELECT s.doc_filename, s.source_type, s.topics, s.text,
           1.0/(60 + s.rk) + 1.0/(60 + l.rk) AS rrf
    FROM semantic s JOIN lexical l USING (chunk_id)
    ORDER BY rrf DESC LIMIT {limit}
    &quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The RRF formula &lt;code&gt;1/(k + rank)&lt;/code&gt; with &lt;code&gt;k=60&lt;/code&gt; is a standard approach for combining rankings from different signals without needing to normalize scores. A chunk that ranks #1 in both semantic and lexical search gets &lt;code&gt;1/61 + 1/61 = 0.0328&lt;/code&gt;. A chunk that&apos;s #1 semantically but #100 lexically gets &lt;code&gt;1/61 + 1/160 = 0.0226&lt;/code&gt;. The formula naturally balances both signals.&lt;/p&gt;
&lt;p&gt;Pass &lt;code&gt;--source filing&lt;/code&gt; or &lt;code&gt;--source facts&lt;/code&gt; to restrict the search to one document format.&lt;/p&gt;
&lt;h2&gt;Running the example&lt;/h2&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.11+&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose (for Apache Doris 4.0+, which is required for vector-index support)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Quick start&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd examples/sec_edgar_analytics

# 1. Start Doris (FE + BE)
docker compose up -d fe be
# Wait ~90 seconds for Doris to initialize

# 2. Fetch sample data, configure, and install
python download.py          # writes data/filings/*.txt + data/company_facts/*.json
cp .env.example .env         # Doris host/ports
pip install -e .

# 3. Build the index
cocoindex update main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cocoindex update main&lt;/code&gt; loads 4 chunks (2 filings + 2 company-facts) into Doris, creating both the vector ANN index and the inverted full-text index. Topics come out as you&apos;d expect: Apple tagged &lt;code&gt;RISK:CYBER, RISK:CLIMATE, RISK:SUPPLY, RISK:REGULATORY, TOPIC:AI&lt;/code&gt;; Microsoft &lt;code&gt;RISK:CYBER, RISK:REGULATORY, TOPIC:AI, TOPIC:CLOUD&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Hybrid search&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python search.py &quot;cloud computing and AI risk&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Hybrid search: &quot;cloud computing and AI risk&quot;

[0.0328] 0000789019_2025-10-15_10-K.txt (filing)  topics=[&quot;RISK:CYBER&quot;,&quot;RISK:REGULATORY&quot;,&quot;TOPIC:AI&quot;,&quot;TOPIC:CLOUD&quot;]
    MICROSOFT CORPORATION FORM 10-K ANNUAL REPORT. Our cloud computing business (Azure) faces intense competition...

[0.0320] 0000320193_2025-11-01_10-K.txt (filing)  topics=[&quot;RISK:CYBER&quot;,&quot;RISK:CLIMATE&quot;,&quot;RISK:SUPPLY&quot;,&quot;RISK:REGULATORY&quot;,&quot;TOPIC:AI&quot;]
    APPLE INC. FORM 10-K ANNUAL REPORT. Cybersecurity threats are a material risk...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the sample data that ranks Microsoft&apos;s cloud-and-AI filing first (it carries both &lt;code&gt;TOPIC:CLOUD&lt;/code&gt; and &lt;code&gt;TOPIC:AI&lt;/code&gt;), Apple&apos;s second, and the company-facts rows below. Restrict to one format with &lt;code&gt;python search.py &quot;cloud revenue&quot; --source facts&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Direct Doris queries&lt;/h2&gt;
&lt;p&gt;You can also query the index directly via MySQL protocol:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql -h localhost -P 9030 -u root
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Array field filtering&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-- Find all chunks tagged with cybersecurity risk
SELECT doc_filename, text, topics
FROM sec_analytics.filing_chunks
WHERE json_contains(topics, &apos;&quot;RISK:CYBER&quot;&apos;);

-- Chunks matching any of multiple topics
SELECT doc_filename, text
FROM sec_analytics.filing_chunks
WHERE json_contains(topics, &apos;&quot;RISK:CYBER&quot;&apos;)
   OR json_contains(topics, &apos;&quot;RISK:CLIMATE&quot;&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Portfolio aggregation&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-- Top 3 relevant chunks per company
WITH ranked AS (
    SELECT cik, doc_filename, text,
           l2_distance(embedding, [...]) AS score,
           ROW_NUMBER() OVER (PARTITION BY cik ORDER BY score ASC) AS rank
    FROM sec_analytics.filing_chunks
    WHERE cik IN (&apos;0000320193&apos;, &apos;0000789019&apos;)
)
SELECT * FROM ranked WHERE rank &amp;lt;= 3;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Temporal trends&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-- Cybersecurity mentions by filing year
SELECT LEFT(filing_date, 4) AS filing_year,
       COUNT(DISTINCT cik) AS num_companies,
       COUNT(*) AS total_mentions
FROM sec_analytics.filing_chunks
WHERE text MATCH_ANY &apos;cybersecurity risk&apos;
GROUP BY filing_year
ORDER BY filing_year DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What&apos;s next&lt;/h2&gt;
&lt;p&gt;The techniques in this example (multi-format ingestion, array field filtering, hybrid search, PII scrubbing) generalize beyond financial documents. The same patterns apply to healthcare records, legal documents, or any domain where you need to search across heterogeneous document formats with structured metadata filtering.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/sec_edgar_analytics&quot;&gt;full source code&lt;/a&gt; in the CocoIndex v1 repo, including the sample-data generator and the hybrid-search CLI.&lt;/p&gt;
</content:encoded><category>examples</category><category>structured-extraction</category><category>vector-search</category><category>embeddings</category><category>connectors</category><author>Tom Zhang, Linghua Jin</author></item><item><title>Build a Self-Updating Wiki for Your Codebases with an LLM</title><link>https://cocoindex.io/blogs/multi-codebase-summarization/</link><guid isPermaLink="true">https://cocoindex.io/blogs/multi-codebase-summarization/</guid><description>Auto-generate documentation for every project in your codebase: a CocoIndex pipeline writes a wiki page per repo with an LLM, kept fresh as code changes.</description><pubDate>Thu, 05 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Documentation tends to drift out of sync with the code it describes. In this tutorial, we build a pipeline that generates a wiki page for each project in your codebase and keeps it current with &lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental processing&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The full source code is available at &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/multi_codebase_summarization&quot;&gt;CocoIndex Examples - multi_codebase_summarization&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The source is on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example of the generated documentation:&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Documentation drifts. A module gets refactored, and the wiki describing it is now wrong. It usually stays wrong until someone new asks whether the docs can still be trusted.&lt;/p&gt;
&lt;p&gt;The alternative is to derive the documentation from the code itself: a pipeline that reads your source, extracts structure from it, and produces documentation that updates when the code changes.&lt;/p&gt;
&lt;h2&gt;What We&apos;ll Build&lt;/h2&gt;
&lt;p&gt;:::note
This project uses &lt;a href=&quot;https://cocoindex.io/docs/&quot;&gt;CocoIndex v1&lt;/a&gt;.
:::&lt;/p&gt;
&lt;p&gt;A pipeline that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Scans subdirectories&lt;/strong&gt;, treating each as a separate project&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extracts structured information&lt;/strong&gt; from each Python file using an LLM (classes, functions, relationships)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aggregates&lt;/strong&gt; file-level data into project-level summaries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generates Markdown documentation&lt;/strong&gt; with Mermaid diagrams&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    subgraph Input
        A[projects/] --&amp;gt; B[project_1/]
        A --&amp;gt; C[project_2/]
        A --&amp;gt; D[project_N/]
    end

    subgraph &quot;Process (per project)&quot;
        B --&amp;gt; E[&quot;extract_file_info\n(LLM + Pydantic)&quot;]
        E --&amp;gt; F[&quot;aggregate_project_info&quot;]
        F --&amp;gt; G[&quot;generate_markdown&quot;]
    end

    subgraph Output
        G --&amp;gt; H[&quot;project_1.md&quot;]
    end

    classDef extract fill:#FBE7DA,stroke:#BE5133,stroke-width:1.5px,color:#7A2E1A;
    classDef aggregate fill:#FCF3D8,stroke:#8F3B24,stroke-width:1.5px,color:#5A2417;
    classDef generate fill:#DDF5DE,stroke:#16A534,stroke-width:1.5px,color:#11472A;
    class E extract;
    class F aggregate;
    class G generate;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model behind this is a single &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/target_state/&quot;&gt;relationship&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;target_state = transformation(source_state)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You declare the transformation. CocoIndex determines when to run it and which inputs need reprocessing.&lt;/p&gt;
&lt;h3&gt;The reality of modern codebases&lt;/h3&gt;
&lt;p&gt;Codebases change constantly. Teams merge many pull requests a day, and AI coding agents add and modify files continuously. A documentation pipeline has to keep up with that rate of change.&lt;/p&gt;
&lt;p&gt;Batch regeneration handles this poorly. A nightly job is already stale by morning. An hourly job spends most of its time rebuilding files that did not change. The cost of a full rebuild scales with the size of the codebase, not with the size of the change.&lt;/p&gt;
&lt;h3&gt;Why not just write a script?&lt;/h3&gt;
&lt;p&gt;A script that loops through files, calls an LLM, and writes Markdown works for a one-time run. Keeping it correct over time raises several problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;File changes&lt;/strong&gt;: after editing one file, re-running the whole pipeline is slow and expensive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tracking state&lt;/strong&gt;: to process only what changed, you have to track timestamps, checksums, or diffs yourself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM costs&lt;/strong&gt;: re-analyzing unchanged files wastes API calls, which adds up at scale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logic changes&lt;/strong&gt;: changing the extraction prompt invalidates results for that transformation across every file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are caching, invalidation, and orchestration concerns, separate from the extraction logic you actually want to write.&lt;/p&gt;
&lt;h3&gt;The declarative difference&lt;/h3&gt;
&lt;p&gt;CocoIndex separates the transformation logic from the change tracking. You provide:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Functions&lt;/strong&gt; that extract information from a file, aggregate it, and format it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependencies&lt;/strong&gt; between those functions, which CocoIndex records.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CocoIndex then handles incremental processing. When a source file is edited, or the processing logic changes (a different model or an updated prompt), only the affected outputs are recomputed, and the output stays consistent with the source.&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Install dependencies:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install --pre &apos;cocoindex&amp;gt;=1.0.0a6&apos; instructor litellm pydantic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set up environment:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export GEMINI_API_KEY=&quot;your-api-key&quot;
export LLM_MODEL=&quot;gemini/gemini-2.5-flash&quot;
echo &quot;COCOINDEX_DB=./cocoindex.db&quot; &amp;gt; .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a &lt;code&gt;projects/&lt;/code&gt; directory with subdirectories for each Python project:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;```bash
projects/
├── my_project_1/
│   ├── main.py
│   └── utils.py
├── my_project_2/
│   └── app.py
└── ...
```
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Define the app&lt;/h2&gt;
&lt;p&gt;Define a &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/app/&quot;&gt;CocoIndex App&lt;/a&gt;, the top-level runnable unit in CocoIndex.&lt;/p&gt;
&lt;p&gt;The snippets below focus on the pipeline logic; the full import list (&lt;code&gt;localfs&lt;/code&gt;, &lt;code&gt;instructor&lt;/code&gt;, the Pydantic models, and the rest) is in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/examples/multi_codebase_summarization/main.py&quot;&gt;&lt;code&gt;main.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import pathlib

import cocoindex as coco

LLM_MODEL = os.environ.get(&quot;LLM_MODEL&quot;, &quot;gemini/gemini-2.5-flash&quot;)

app = coco.App(
    &quot;MultiCodebaseSummarization&quot;,
    app_main,
    root_dir=pathlib.Path(&quot;./projects&quot;),
    output_dir=pathlib.Path(&quot;./output&quot;),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The app scans &lt;code&gt;projects/&lt;/code&gt; and outputs documentation to &lt;code&gt;output/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Define the main function&lt;/h2&gt;
&lt;p&gt;In the main function, we walk through each project in the subdirectories and process it.&lt;/p&gt;
&lt;p&gt;It is up to you to declare the process granularity. It can be&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;at a directory level per project. For example, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/code_embedding&quot;&gt;code_embedding&lt;/a&gt; is a project, containing multiple files,&lt;/li&gt;
&lt;li&gt;or at file level,&lt;/li&gt;
&lt;li&gt;or at even smaller units (e.g., page level, or semantic unit level).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this example, we have a &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples&quot;&gt;projects folder&lt;/a&gt; containing 20+ projects. It is natural to pick granularity at the directory level for each project, because we want to create a wiki page per project.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.function
def app_main(
    root_dir: pathlib.Path,
    output_dir: pathlib.Path,
) -&amp;gt; None:
    &quot;&quot;&quot;Scan subdirectories and generate documentation for each project.&quot;&quot;&quot;
    for entry in root_dir.resolve().iterdir():
        if not entry.is_dir() or entry.name.startswith(&quot;.&quot;):
            continue
        project_name = entry.name

        files = list(
            localfs.walk_dir(
                entry,
                recursive=True,
                path_matcher=PatternFilePathMatcher(
                    included_patterns=[&quot;*.py&quot;],
                    excluded_patterns=[&quot;.*&quot;, &quot;__pycache__&quot;],
                ),
            )
        )

        if files:
            coco.mount(
                coco.component_subpath(&quot;project&quot;, project_name),
                process_project,
                project_name,
                files,
                output_dir,
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The main function does two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Find all projects.&lt;/strong&gt; Loop through each subdirectory in &lt;code&gt;root_dir&lt;/code&gt;, treating each as a separate project.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Mount a processing component for each project.&lt;/strong&gt; For each project with Python files, &lt;code&gt;coco.mount()&lt;/code&gt; sets up a processing component. CocoIndex handles execution and tracks dependencies.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A processing component groups an item&apos;s processing together with its target states. Each component runs independently and in parallel, so when &lt;code&gt;project_a&lt;/code&gt; finishes, its results are applied to the external system without waiting for &lt;code&gt;project_b&lt;/code&gt; or any other project.&lt;/p&gt;
&lt;p&gt;To learn more about processing components, you can read the &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Process each project&lt;/h2&gt;
&lt;p&gt;For each project, we will&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;use an LLM to extract info&lt;/li&gt;
&lt;li&gt;aggregate all the extraction into a project-level summary&lt;/li&gt;
&lt;li&gt;output the extraction to nice documentation with a Mermaid diagram.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;@coco.function(memo=True)
async def process_project(
    project_name: str,
    files: Collection[localfs.File],
    output_dir: pathlib.Path,
) -&amp;gt; None:
    &quot;&quot;&quot;Process a project: extract, aggregate, and output markdown.&quot;&quot;&quot;
    # Extract info from each file concurrently using asyncio.gather
    file_infos = await asyncio.gather(*[extract_file_info(f) for f in files])

    # Aggregate into project-level summary
    project_info = await aggregate_project_info(project_name, file_infos)

    # Generate and output markdown
    markdown = generate_markdown(project_name, project_info, file_infos)
    localfs.declare_file(
        output_dir / f&quot;{project_name}.md&quot;, markdown, create_parent_dirs=True
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Concurrent extraction.&lt;/strong&gt; &lt;code&gt;asyncio.gather()&lt;/code&gt; runs the file extractions concurrently, which is faster than sequential processing when each call waits on an LLM API response.&lt;/p&gt;
&lt;h2&gt;Extract file information with LLM&lt;/h2&gt;
&lt;p&gt;Now let&apos;s take a look at the details for each transformation.
For file extraction, we define a structure using Pydantic and use &lt;a href=&quot;https://github.com/jxnl/instructor&quot;&gt;Instructor&lt;/a&gt; to extract with LLMs.&lt;/p&gt;
&lt;h3&gt;Define the data models&lt;/h3&gt;
&lt;p&gt;The key to structured LLM outputs is defining clear Pydantic models.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class FunctionInfo(BaseModel):
    &quot;&quot;&quot;Information about a public function.&quot;&quot;&quot;
    name: str = Field(description=&quot;Function name&quot;)
    signature: str = Field(
        description=&quot;Function signature, e.g. &apos;async def foo(x: int) -&amp;gt; str&apos;&quot;
    )
    is_coco_function: bool = Field(
        description=&quot;Whether decorated with @coco.function&quot;
    )
    summary: str = Field(description=&quot;Brief summary of what the function does&quot;)

class ClassInfo(BaseModel):
    &quot;&quot;&quot;Information about a public class.&quot;&quot;&quot;
    name: str = Field(description=&quot;Class name&quot;)
    summary: str = Field(description=&quot;Brief summary of what the class represents&quot;)

class CodebaseInfo(BaseModel):
    &quot;&quot;&quot;Extracted information from Python code.&quot;&quot;&quot;
    name: str = Field(description=&quot;File path or project name&quot;)
    summary: str = Field(description=&quot;Brief summary of purpose and functionality&quot;)
    public_classes: list[ClassInfo] = Field(default_factory=list)
    public_functions: list[FunctionInfo] = Field(default_factory=list)
    mermaid_graphs: list[str] = Field(
        default_factory=list,
        description=&quot;Mermaid graphs showing function relationships&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Extract file info&lt;/h3&gt;
&lt;p&gt;The core extraction function uses memoization to cache LLM results:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_instructor_client = instructor.from_litellm(acompletion, mode=instructor.Mode.JSON)

@coco.function(memo=True)
async def extract_file_info(file: FileLike) -&amp;gt; CodebaseInfo:
    &quot;&quot;&quot;Extract structured information from a single Python file using LLM.&quot;&quot;&quot;
    content = file.read_text()
    file_path = str(file.file_path.path)

    prompt = f&quot;&quot;&quot;Analyze the following Python file and extract structured information...&quot;&quot;&quot; # see full prompt below

    result = await _instructor_client.chat.completions.create(
        model=LLM_MODEL,
        response_model=CodebaseInfo,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}],
    )
    return CodebaseInfo.model_validate(result.model_dump())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
See the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/examples/multi_codebase_summarization/main.py&quot;&gt;full prompt in the repo&lt;/a&gt;.
:::&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;memo=True&lt;/code&gt;&lt;/strong&gt; &lt;a href=&quot;https://cocoindex.io/docs/advanced_topics/memoization_keys/&quot;&gt;caches the result by the function&apos;s inputs&lt;/a&gt;. When the file content and the code are unchanged, CocoIndex returns the previous result instead of calling the LLM again, so unchanged files skip the remote call on later runs.&lt;/p&gt;
&lt;h2&gt;Aggregate project information&lt;/h2&gt;
&lt;p&gt;For projects with multiple files, we aggregate into a unified summary:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.function
async def aggregate_project_info(
    project_name: str,
    file_infos: list[CodebaseInfo],
) -&amp;gt; CodebaseInfo:
    &quot;&quot;&quot;Aggregate multiple file extractions into a project-level summary.&quot;&quot;&quot;
    if not file_infos:
        return CodebaseInfo(
            name=project_name, summary=&quot;Empty project with no Python files.&quot;
        )

    # Single file - just update the name
    if len(file_infos) == 1:
        info = file_infos[0]
        return CodebaseInfo(
            name=project_name,
            summary=info.summary,
            public_classes=info.public_classes,
            public_functions=info.public_functions,
            mermaid_graphs=info.mermaid_graphs,
        )

    # Multiple files - use LLM to create unified summary
    files_text = &quot;\n\n&quot;.join(
        f&quot;### {info.name}\n&quot;
        f&quot;Summary: {info.summary}\n&quot;
        f&quot;Classes: {&apos;, &apos;.join(c.name for c in info.public_classes) or &apos;None&apos;}\n&quot;
        f&quot;Functions: {&apos;, &apos;.join(f.name for f in info.public_functions) or &apos;None&apos;}&quot;
        for info in file_infos
    )

    # Collect all mermaid graphs from files
    all_graphs = [g for info in file_infos for g in info.mermaid_graphs]

    prompt = f&quot;&quot;&quot;Aggregate the following Python files into a project-level summary...&quot;&quot;&quot; # see full prompt in repo

    result = await _instructor_client.chat.completions.create(
        model=LLM_MODEL,
        response_model=CodebaseInfo,
        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}],
    )
    result = CodebaseInfo.model_validate(result.model_dump())

    # Keep original file-level graphs if LLM didn&apos;t generate a unified one
    if not result.mermaid_graphs and all_graphs:
        result.mermaid_graphs = all_graphs

    return result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
See the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/examples/multi_codebase_summarization/main.py&quot;&gt;full prompt in the repo&lt;/a&gt;.
:::&lt;/p&gt;
&lt;p&gt;This function combines file-level extractions into a single project summary:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single-file project&lt;/strong&gt;: use that file&apos;s info directly, with no extra LLM call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-file project&lt;/strong&gt;: ask the LLM to synthesize the file summaries into one project overview.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is a unified &lt;code&gt;CodebaseInfo&lt;/code&gt; that represents the entire project rather than individual files.&lt;/p&gt;
&lt;h2&gt;Generate the Markdown documentation&lt;/h2&gt;
&lt;p&gt;Create output Markdown for each project, including a Mermaid pipeline diagram.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.function
def generate_markdown(
    project_name: str, info: CodebaseInfo, file_infos: list[CodebaseInfo]
) -&amp;gt; str:
    &quot;&quot;&quot;Generate markdown documentation from project info.&quot;&quot;&quot;
    lines = [
        f&quot;# {project_name}&quot;,
        &quot;&quot;,
        &quot;## Overview&quot;,
        &quot;&quot;,
        info.summary,
        &quot;&quot;,
    ]

    if info.public_classes or info.public_functions:
        lines.extend([&quot;## Components&quot;, &quot;&quot;])

        if info.public_classes:
            lines.append(&quot;**Classes:**&quot;)
            for cls in info.public_classes:
                lines.append(f&quot;- `{cls.name}`: {cls.summary}&quot;)
            lines.append(&quot;&quot;)

        if info.public_functions:
            lines.append(&quot;**Functions:**&quot;)
            for fn in info.public_functions:
                marker = &quot; ★&quot; if fn.is_coco_function else &quot;&quot;
                lines.append(f&quot;- `{fn.signature}`{marker}: {fn.summary}&quot;)
            lines.append(&quot;&quot;)

    if info.mermaid_graphs:
        lines.extend([&quot;## CocoIndex Pipeline&quot;, &quot;&quot;])
        for graph in info.mermaid_graphs:
            graph_content = graph.strip()
            if not graph_content.startswith(&quot;```&quot;):
                lines.append(&quot;```mermaid&quot;)
                lines.append(graph_content)
                lines.append(&quot;```&quot;)
            else:
                lines.append(graph_content)
            lines.append(&quot;&quot;)

    if len(file_infos) &amp;gt; 1:
        lines.extend([&quot;## File Details&quot;, &quot;&quot;])
        for fi in file_infos:
            lines.extend([f&quot;### {fi.name}&quot;, &quot;&quot;, fi.summary, &quot;&quot;])

    lines.extend([&quot;---&quot;, &quot;&quot;, &quot;*★ = CocoIndex function*&quot;])
    return &quot;\n&quot;.join(lines)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This function converts the structured &lt;code&gt;CodebaseInfo&lt;/code&gt; into readable documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Overview&lt;/strong&gt;: the project summary at the top.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Components&lt;/strong&gt;: classes and functions with descriptions (★ marks CocoIndex functions).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pipeline diagram&lt;/strong&gt;: Mermaid graphs showing how functions connect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File details&lt;/strong&gt;: per-file summaries, for multi-file projects.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Run the pipeline&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update main.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CocoIndex will:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Scan each subdirectory in &lt;code&gt;projects/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Extract structured information from Python files using the LLM&lt;/li&gt;
&lt;li&gt;Aggregate file summaries into project summaries&lt;/li&gt;
&lt;li&gt;Generate Markdown files in &lt;code&gt;output/&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Check the output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls output/
# project1.md project2.md ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Incremental Updates&lt;/h2&gt;
&lt;p&gt;The real power shows when you make changes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Modify a file:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Edit a Python file in one of your projects
cocoindex update main.py
# Only the modified file is re-analyzed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Add a new project:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir projects/new_project
# Add .py files
cocoindex update main.py
# Only the new project is processed
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Output Example&lt;/h2&gt;
&lt;p&gt;Each generated Markdown file includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Overview&lt;/strong&gt;: what the project does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Components&lt;/strong&gt;: public classes and functions with descriptions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pipeline diagram&lt;/strong&gt;: a Mermaid graph showing how functions connect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File details&lt;/strong&gt;: per-file summaries for multi-file projects.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;graph TD
    app_main[app_main] ==&amp;gt; process_project[process_project]
    process_project ==&amp;gt; extract_file_info[extract_file_info]
    process_project ==&amp;gt; aggregate_project_info[aggregate_project_info]
    process_project --&amp;gt; generate_markdown[generate_markdown]

    classDef extract fill:#FBE7DA,stroke:#BE5133,stroke-width:1.5px,color:#7A2E1A;
    classDef aggregate fill:#FCF3D8,stroke:#8F3B24,stroke-width:1.5px,color:#5A2417;
    classDef generate fill:#DDF5DE,stroke:#16A534,stroke-width:1.5px,color:#11472A;
    class extract_file_info extract;
    class aggregate_project_info aggregate;
    class generate_markdown generate;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Key Patterns&lt;/h2&gt;
&lt;p&gt;This example combines several patterns:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Structured LLM outputs&lt;/strong&gt;: Pydantic models with Instructor return validated, typed data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memoized LLM calls&lt;/strong&gt;: identical inputs return a cached result, which avoids most LLM calls on incremental runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Async concurrent processing&lt;/strong&gt;: &lt;code&gt;asyncio.gather()&lt;/code&gt; runs file extractions in parallel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hierarchical aggregation&lt;/strong&gt;: extract at the file level, then aggregate to the project level.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incremental processing&lt;/strong&gt;: only changed inputs are reprocessed.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Beyond documentation: keeping derived knowledge current&lt;/h2&gt;
&lt;p&gt;The same pipeline applies wherever derived knowledge has to track a changing source, including the context that &lt;a href=&quot;https://cocoindex.io/docs/getting_started/ai_coding_agents/&quot;&gt;coding agents&lt;/a&gt; read. It is the same incremental pattern behind &lt;a href=&quot;https://cocoindex.io/blogs/index-codebase-v1&quot;&gt;indexing a codebase for RAG&lt;/a&gt;, &lt;a href=&quot;https://cocoindex.io/blogs/text-embeddings-101&quot;&gt;text embeddings&lt;/a&gt;, and &lt;a href=&quot;https://cocoindex.io/blogs/knowledge-graph-for-docs&quot;&gt;knowledge graphs&lt;/a&gt;: declare the transformation once, and only changed inputs are reprocessed.&lt;/p&gt;
&lt;h3&gt;Long-horizon agents need current context&lt;/h3&gt;
&lt;p&gt;AI agents increasingly operate over long sessions, planning and acting across many steps. Their decisions depend on the state of the codebase as it is now: what changed recently, how modules interact, and the current structure of the system. Documentation that was accurate last week can lead an agent to act on assumptions that no longer hold.&lt;/p&gt;
&lt;h3&gt;Treating change as the unit of work&lt;/h3&gt;
&lt;p&gt;Processing change rather than reprocessing the whole corpus is what makes this practical:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Efficiency&lt;/strong&gt;: only changed inputs are processed, not the entire corpus.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency&lt;/strong&gt;: new information appears in minutes rather than after a full rebuild.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt;: unchanged content does not incur repeated LLM calls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scalability&lt;/strong&gt;: codebases too large to reprocess from scratch can still be kept current.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Declaring the transformation, rather than scripting the update logic by hand, is what lets the pipeline keep derived knowledge in sync with the source as the source changes.&lt;/p&gt;
&lt;h2&gt;Thanks to the community&lt;/h2&gt;
&lt;h3&gt;@prrao87&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prrao87&quot;&gt;@prrao87&lt;/a&gt; for reviewing the example and providing detailed feedback on terminology, style, and conceptual clarity, which helped improve the developer experience.&lt;/p&gt;
&lt;p&gt;If you have ideas, questions, or want to contribute, join us on &lt;a href=&quot;https://discord.com/invite/zpA9S2DR7s&quot;&gt;Discord&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Next Steps&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Try the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/multi_codebase_summarization&quot;&gt;full example&lt;/a&gt;, or read the &lt;a href=&quot;https://cocoindex.io/docs/examples/multi-codebase-summarization/&quot;&gt;example walkthrough in the docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Join the &lt;a href=&quot;https://discord.com/invite/zpA9S2DR7s&quot;&gt;CocoIndex Discord&lt;/a&gt; for questions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The source is on &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>examples</category><category>llm</category><category>structured-extraction</category><category>incremental-processing</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>Slides-to-speech: Bring your slide decks to life with narrated audio</title><link>https://cocoindex.io/blogs/slides-to-speech/</link><guid isPermaLink="true">https://cocoindex.io/blogs/slides-to-speech/</guid><description>Build a CocoIndex v1 pipeline that turns PDF slides into vision-generated speaker notes, local Pocket TTS narration, and searchable LanceDB records.</description><pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Presentation slide decks are information-rich, dense sources of company knowledge, containing everything from quarterly updates, technical specs, sales reports, and training materials. These decks are locked away in PDF files that get revised constantly and are generally hard to consume in their static form.&lt;/p&gt;
&lt;p&gt;What if we could turn those decks into a continuously updated, searchable, and narrated (audio) knowledge base? Doing that would result in a few immediate benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Listen to any slide deck&lt;/strong&gt; instead of reading every slide (useful for busy executives on the go, and for people with visual impairments).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extract insights from slide content&lt;/strong&gt; as structured speaker notes by using a VLM (vision language model).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search slide content semantically&lt;/strong&gt;, not just by keywords or filenames.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Producing those artifacts once is only half the task. When a deck changes, its speaker notes, audio, and embeddings also have to be kept fresh. Once everything is in sync, it can lay the foundation for more advanced user experiences later, including video generation from text.&lt;/p&gt;
&lt;p&gt;In this post, we’ll show how to turn the slide deck&apos;s assets into a multimodal knowledge base stored in LanceDB: the same dataset contains the slide content, speaker notes, MP3 audio narration, and embeddings for vector search, all of which are orchestrated and kept fresh by CocoIndex.&lt;/p&gt;
&lt;h2&gt;Introducing CocoIndex&lt;/h2&gt;
&lt;p&gt;CocoIndex is an ultra-performant framework that keeps context continuously fresh for AI Agents. Think “React for data processing.” You declare state transformations, and CocoIndex handles the processing logic: it continuously watches for changes, recomputes only what’s necessary, and applies minimal updates downstream for end-to-end freshness. As AI systems become increasingly autonomous, the bottleneck is no longer model capability: it’s keeping context fresh and relevant.&lt;/p&gt;
&lt;p&gt;CocoIndex follows an incremental update philosophy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New slide decks appear in the source folder&lt;/li&gt;
&lt;li&gt;Updated files get reprocessed automatically&lt;/li&gt;
&lt;li&gt;Only affected rows get new embeddings and regenerated audio, if needed&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Introducing LanceDB&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.lancedb.com&quot;&gt;LanceDB&lt;/a&gt; is a multimodal lakehouse for AI, available as an open-source, embedded, developer-friendly retrieval library that pairs well with CocoIndex. Under the hood, it’s powered by the Lance columnar format, which is designed for fast random access (great for search workloads) without sacrificing scan performance (great for large-scale aggregation queries).&lt;/p&gt;
&lt;p&gt;For this Slides-to-Speech pipeline, that translates into a few concrete benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Multimodal in one table: store speaker notes, MP3 audio bytes, embeddings, and metadata side by side as a single dataset.&lt;/li&gt;
&lt;li&gt;Indexes are first-class: vector search, full-text search (FTS), and secondary indexes are stored alongside your dataset, so you don’t need to manage those using separate systems.&lt;/li&gt;
&lt;li&gt;Data evolution: add or refine columns over time (backfill new embeddings, quality tags, moderation fields) without touching unmodified columns, which is great for feature engineering and experimentation.&lt;/li&gt;
&lt;li&gt;Local-first simplicity: run it as an embedded database with minimal operational overhead.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CocoIndex&apos;s LanceDB connector automatically appends and updates rows as new results arrive via the CocoIndex pipeline, so you don’t have to rewrite your table (or rebuild your indexes) from scratch each time content changes.&lt;/p&gt;
&lt;h2&gt;What we are building: one slide, three derived modalities&lt;/h2&gt;
&lt;p&gt;To begin building our pipeline, let&apos;s first get the shape of the data clear. A PDF slide deck doesn’t just become one large database record. We first fan it out page by page, then turn each slide into three related outputs: speaker notes, MP3 narration (audio), and a text embedding for semantic search.&lt;/p&gt;
&lt;p&gt;The speaker notes sit at the center of the pipeline, but they first need to be generated by a vision model that reasons over the slide content. Once they exist, audio generation and embedding can run on them concurrently. The completed outputs are then declared together as one &lt;code&gt;SlideRecord&lt;/code&gt;, identified by its filename and page number.&lt;/p&gt;
&lt;p&gt;Each tool has one focused job:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role in the pipeline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CocoIndex&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Discovers PDFs, runs the transformations, and reconciles the declared slide rows with the source files.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PyMuPDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Renders every PDF page as a PNG for the vision model.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DSPy + Gemini&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DSPy lets us call the VLM via a concise signature, and the VLM (&lt;code&gt;gemini/gemini-2.5-flash&lt;/code&gt;) reads each rendered slide PNG and writes the speaker notes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pocket TTS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synthesizes those notes as MP3 audio locally on the CPU.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sentence Transformers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embeds the speaker notes locally with &lt;code&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LanceDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stores text, binary audio, metadata, and vectors together, then searches the notes by meaning.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Most of the pipeline runs locally on CPU: PyMuPDF renders the pages as images for the VLM, &lt;a href=&quot;https://github.com/kyutai-labs/pocket-tts&quot;&gt;Pocket TTS&lt;/a&gt; synthesizes audio from the speaker notes, &lt;code&gt;sentence-transformers&lt;/code&gt; generates text embeddings, and LanceDB runs as an embedded, local retrieval library.&lt;/p&gt;
&lt;h2&gt;Start backwards from the target&lt;/h2&gt;
&lt;p&gt;CocoIndex pipelines are easiest to reason about &lt;em&gt;backwards&lt;/em&gt; from the target, i.e., where the data sits in its final form. Put simply, &lt;code&gt;target_state = transformation(source_state)&lt;/code&gt;, where &lt;code&gt;transformation&lt;/code&gt; can be any function, or a set of functions. For this pipeline, the target state is one clean LanceDB row for &lt;strong&gt;each slide&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Declare the LanceDB table shape via a strictly typed schema
# Pydantic or PyArrow schemas work well, but dataclasses are simpler for this example
@dataclass
class SlideRecord:
    id: str
    filename: str
    page: int
    speaker_notes: str
    voice: bytes
    embedding: Annotated[NDArray, EMBEDDER]

# Mount a LanceDB table as the target
table = await lancedb.mount_table_target(
    LANCE_DB,
    table_name=&quot;slides_to_speech&quot;,
    table_schema=await lancedb.TableSchema.from_class(
        SlideRecord,
        primary_key=[&quot;id&quot;],
    ),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Multimodal data is a first-class citizen in LanceDB.&lt;/strong&gt; Each row can hold the slide’s metadata, generated notes, MP3 narration bytes, and vector embedding side by side. &lt;code&gt;SlideRecord&lt;/code&gt; is the typed contract that keeps those values together, via a dataclass.&lt;/p&gt;
&lt;p&gt;Each slide needs a stable primary key so CocoIndex can match it to the same LanceDB row across runs, updating or removing the correct record as the source changes. This example derives that key from the filename and page number: page 3 of &lt;code&gt;quarterly_review.pdf&lt;/code&gt; becomes &lt;code&gt;quarterly_review.pdf#3&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Functions vs. processing components&lt;/h2&gt;
&lt;p&gt;Incremental processing is only useful when its boundaries match the unit of change. We want our pipeline to run every time a change is detected in the source files, without wastefully recomputing what didn&apos;t change.&lt;/p&gt;
&lt;p&gt;In CocoIndex, functions define the work, while processing components group that work into independently synchronized units. Where we draw those component boundaries determines how much of the pipeline runs each time a change is detected, and which target states CocoIndex reconciles afterward.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Functions&lt;/strong&gt; are callable Python functions decorated with &lt;code&gt;@coco.fn&lt;/code&gt;. Setting &lt;code&gt;memo=True&lt;/code&gt; lets CocoIndex reuse results for unchanged inputs and code. Several functions can run inside the same processing component.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Processing components&lt;/strong&gt; are created by grouping functions and &lt;em&gt;mounting&lt;/em&gt; them to the target. They manage the necessary function calls and the target states they declare. Processing components help decide the granularity at which CocoIndex reruns work and synchronizes the result.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this example, we define the processing component at the &lt;strong&gt;per-slide level&lt;/strong&gt;, through the &lt;code&gt;process_file&lt;/code&gt; function. It takes a PDF file, renders each slide as an image, and mounts one &lt;code&gt;process_slide&lt;/code&gt; function for each slide.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_file(
    file: FileLike,
    table: lancedb.TableTarget[SlideRecord],
) -&amp;gt; None:
    slides = await pdf_to_slides(await file.read())
    await coco.mount_each(
        process_slide,
        ((slide.page_number, slide) for slide in slides),
        str(file.file_path.path),
        table,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;process_file&lt;/code&gt; is memoized too, so CocoIndex can skip reading and rendering an unchanged PDF. If the file does change, &lt;code&gt;process_slide&lt;/code&gt; provides the finer-grained memoization boundary: slide images with unchanged inputs reuse their prior results, while changed slides go through note generation, TTS, embedding, and the declaration of &lt;code&gt;SlideRecord&lt;/code&gt; in the LanceDB target based on its stable key.&lt;/p&gt;
&lt;p&gt;Changing a slide in a PDF still reruns page rendering for that entire file (still, PyMuPDF is a cheap CPU-based operation, so no VLM calls happen yet). Once the images are available, CocoIndex fingerprints files with a content-based hash, so it processes &lt;em&gt;only&lt;/em&gt; slide images whose contents changed from the previous state. This is the power of memoization and the reason we mount the component at the slide level.&lt;/p&gt;
&lt;p&gt;If a PDF is deleted from the source folder, CocoIndex unmounts its per-file component and recursively cleans up the target states it owns. In this pipeline, that means deleting the PDF&apos;s slide rows from LanceDB.&lt;/p&gt;
&lt;p&gt;Let&apos;s now go over each stage of the pipeline below.&lt;/p&gt;
&lt;h2&gt;Fan a PDF out into slides&lt;/h2&gt;
&lt;p&gt;The function &lt;code&gt;pdf_to_slides&lt;/code&gt; turns the PDF bytes into typed &lt;code&gt;SlidePage&lt;/code&gt; values, each carrying the page number and the rendered PNG that the vision model will inspect.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class SlidePage:
    page_number: int
    image: bytes

@coco.fn.as_async(runner=coco.GPU, memo=True)
def pdf_to_slides(content: bytes) -&amp;gt; list[SlidePage]:
    import pymupdf

    slides: list[SlidePage] = []
    doc = pymupdf.open(stream=content, filetype=&quot;pdf&quot;)
    for i, page in enumerate(doc):
        pix = page.get_pixmap(matrix=pymupdf.Matrix(2, 2))
        slides.append(
            SlidePage(page_number=i + 1, image=pix.tobytes(&quot;png&quot;))
        )
    doc.close()
    return slides
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PyMuPDF renders each page at 2× scale, so the vision model sees a legible PNG rather than extracted PDF text. The image is an intermediate input: Gemini uses it to generate notes, but the pipeline discards it afterward. If you want to keep the rendered image for reuse, you can add an &lt;code&gt;image&lt;/code&gt; field and store its bytes in the same LanceDB table.&lt;/p&gt;
&lt;h2&gt;Turn a slide image into narration&lt;/h2&gt;
&lt;p&gt;Extracting speaker notes from the slide images gives the &lt;a href=&quot;https://github.com/kyutai-labs/pocket-tts&quot;&gt;Pocket TTS&lt;/a&gt; model the text it needs to turn the script into an audio file that narrates the insights from that slide. Text embeddings are also generated from the speaker notes, so they can support semantic search, allowing you to easily find slides based on their meaning rather than just keywords in their titles or filenames.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class SlideNotes(dspy.Signature):
    &quot;&quot;&quot;Write natural spoken narration for a slide, as a presenter would say it aloud.&quot;&quot;&quot;

    slide: dspy.Image = dspy.InputField(desc=&quot;the rendered slide image&quot;)
    speaker_notes: str = dspy.OutputField(
        desc=&quot;a few sentences of spoken narration — no markdown or bullet symbols&quot;
    )

_speaker_notes = dspy.Predict(SlideNotes)

@functools.cache
def _get_lm(model: str) -&amp;gt; dspy.LM:
    return dspy.LM(model, max_tokens=8192)

@coco.fn(memo=True)
async def extract_speaker_notes(image: bytes) -&amp;gt; str:
    data_url = &quot;data:image/png;base64,&quot; + base64.b64encode(image).decode()
    with dspy.context(lm=_get_lm(coco.use_context(LLM_MODEL))):
        result = await _speaker_notes.acall(slide=dspy.Image(url=data_url))
    return result.speaker_notes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SlideNotes&lt;/code&gt; is a DSPy signature, a typed contract that tells the VLM what we need. When a slide image comes in, speaker notes come out. The CocoIndex function &lt;code&gt;extract_speaker_notes&lt;/code&gt; does the actual work of turning the PNG bytes into a text output.&lt;/p&gt;
&lt;p&gt;This extraction function is memoized because speaker-note generation is an expensive dependency: it calls a capable VLM to reason over the image. Its memoization key includes the rendered image and the change-detected &lt;code&gt;LLM_MODEL&lt;/code&gt; context, so an unchanged slide can reuse its notes if the image bytes and the model remain the same. If either of them changes, it&apos;s tracked by CocoIndex and the work is rerun for that slide.&lt;/p&gt;
&lt;h2&gt;Turn speaker notes into audio and embeddings&lt;/h2&gt;
&lt;p&gt;We can now inspect the function &lt;code&gt;process_slide&lt;/code&gt; and the work it does, by reading it top to bottom. The speaker notes must first exist, so they are computed and awaited via &lt;code&gt;extract_speaker_notes&lt;/code&gt;. Once they exist, audio synthesis and embedding have no dependency on each other, so we call &lt;code&gt;asyncio.gather&lt;/code&gt; to run them concurrently. CocoIndex commits them to the LanceDB table only after both are ready.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_slide(
    slide: SlidePage,
    filename: str,
    table: lancedb.TableTarget[SlideRecord],
) -&amp;gt; None:
    notes = await extract_speaker_notes(slide.image)
    voice, embedding = await asyncio.gather(
        text_to_speech(notes, coco.use_context(TTS_VOICE)),
        coco.use_context(EMBEDDER).embed(notes),
    )
    table.declare_row(
        row=SlideRecord(
            id=f&quot;{filename}#{slide.page_number}&quot;,
            filename=filename,
            page=slide.page_number,
            speaker_notes=notes,
            voice=voice,
            embedding=embedding,
        )
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The target state is declared as a &lt;code&gt;SlideRecord&lt;/code&gt; dataclass (per the LanceDB schema) keyed by its unique ID that combines the filename and page number. This is how CocoIndex reconciles the target state with the source files, updating or removing rows as needed.&lt;/p&gt;
&lt;p&gt;The expensive nested operations are memoized as well. If only the voice changes, CocoIndex can reuse the notes and embedding while regenerating the audio. If the embedding model changes, it can reuse the notes and audio while computing a new vector. This gives us reuse at the file, slide, and individual transformation levels.&lt;/p&gt;
&lt;h2&gt;Define the app&lt;/h2&gt;
&lt;p&gt;Apps are the top-level runnable unit in CocoIndex. They name your pipeline and bind a &lt;code&gt;main&lt;/code&gt; function with its parameters. When you call &lt;code&gt;app.update()&lt;/code&gt;, CocoIndex runs that main function as the root processing component that can mount processing components (like our &lt;code&gt;process_file&lt;/code&gt; and its child component &lt;code&gt;process_slide&lt;/code&gt;) that do work and declare target states.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;app_main&lt;/code&gt; mounts the &lt;code&gt;SlideRecord&lt;/code&gt; table we defined earlier, uses &lt;code&gt;localfs.walk_dir&lt;/code&gt; in live mode to discover PDFs, then calls the outer &lt;code&gt;mount_each&lt;/code&gt;. The app is configured with a name, the main function, and the source directory to watch for changes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def app_main(sourcedir: pathlib.Path) -&amp;gt; None:
    table = await lancedb.mount_table_target(
        LANCE_DB,
        table_name=&quot;slides_to_speech&quot;,
        table_schema=await lancedb.TableSchema.from_class(
            SlideRecord,
            primary_key=[&quot;id&quot;],
        ),
    )
    files = localfs.walk_dir(
        sourcedir,
        recursive=True,
        path_matcher=PatternFilePathMatcher(
            included_patterns=[&quot;**/*.pdf&quot;]
        ),
        live=True,
    )
    await coco.mount_each(process_file, files.items(), table)

app = coco.App(
    coco.AppConfig(name=&quot;SlidesToSpeech&quot;),
    app_main,
    sourcedir=pathlib.Path(&quot;./slides&quot;),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Run the pipeline and run semantic search&lt;/h2&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.11+&lt;/li&gt;
&lt;li&gt;A Gemini API key for the default vision model&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ffmpeg&lt;/code&gt; for MP3 export&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pocket TTS is open source and runs locally on the CPU. Its weights download on the first run, so it needs neither a TTS API key nor a GPU.&lt;/p&gt;
&lt;h3&gt;Quick start&lt;/h3&gt;
&lt;p&gt;From the example directory, copy the environment file, set &lt;code&gt;GEMINI_API_KEY&lt;/code&gt;, and ensure &lt;code&gt;LLM_MODEL=gemini/gemini-2.5-flash&lt;/code&gt; to match the example’s default configuration. Then install and run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cp .env.example .env
uv sync
cocoindex update main

# Keep watching slides/ for changes instead:
cocoindex update -L main
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Search the deck&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python main.py &quot;reducing latency and reliability&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The query helper embeds the question with the same sentence-transformer, then asks LanceDB for its nearest neighbors. The results already arrive ranked, so we can print the matching slide and its notes directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for row in table.search(vec).limit(5).to_list():
    print(f&quot;{row[&apos;filename&apos;]} slide {row[&apos;page&apos;]}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The slide deck we use here is an example &quot;quarterly-review&quot; deck, so the query about &quot;reducing latency and reliability&quot; brings the slide about &quot;Engineering Priorities&quot; to the top because its notes discuss &quot;reducing p95 latency and multi-region resilience&quot;.&lt;/p&gt;
&lt;p&gt;The returned row can also fetch the &lt;code&gt;voice&lt;/code&gt; bytes, so a downstream application can use those bytes as &lt;code&gt;audio/mpeg&lt;/code&gt;, write them to an MP3 file for playback, and play them on demand if needed.&lt;/p&gt;
&lt;p&gt;Here is the narration the pipeline generated for the sample deck, one MP3 per slide:&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;strong&gt;Slide 1&lt;/strong&gt;
    
      
      Your browser doesn&apos;t support MP3 playback.
    
  &lt;/div&gt;
  &lt;div&gt;
    &lt;strong&gt;Slide 2: Engineering Priorities&lt;/strong&gt;
    
      
      Your browser doesn&apos;t support MP3 playback.
    
  &lt;/div&gt;
  &lt;div&gt;
    &lt;strong&gt;Slide 3&lt;/strong&gt;
    
      
      Your browser doesn&apos;t support MP3 playback.
    
  &lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;How CocoIndex tracks changes&lt;/h2&gt;
&lt;p&gt;In this demo, CocoIndex watches the &lt;code&gt;./slides&lt;/code&gt; directory for changed PDFs. It then identifies the changed slides, and only those slides regenerate notes, audio, and embeddings. As the table above shows, it can track much more than file changes.&lt;/p&gt;
&lt;p&gt;However, source files changing is only one such scenario. The table below shows the other changes that CocoIndex tracks, and what they mean for other pipelines like this one.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;What it means in this pipeline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Memoized calls reuse their prior results and retain their rows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One slide changes inside a PDF&lt;/td&gt;
&lt;td&gt;The PDF renders again, then only the slide with different PNG bytes reruns notes, TTS, embedding, and its row declaration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A PDF is added or deleted&lt;/td&gt;
&lt;td&gt;CocoIndex creates or removes the file component and all of its owned slide rows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM model, embedder, or voice changes&lt;/td&gt;
&lt;td&gt;The change-detected context refreshes the functions that consume it; unrelated memoized calls can be reused.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decorated function logic changes&lt;/td&gt;
&lt;td&gt;A changed &lt;code&gt;@coco.fn&lt;/code&gt; fingerprint invalidates memoized calls that depend on that function, then CocoIndex reconciles the resulting rows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SlideRecord&lt;/code&gt; fields or primary key change&lt;/td&gt;
&lt;td&gt;The target contract has changed. Treat compatibility, backfill, and identity changes as a deliberate migration decision.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Bring your slides to life (and beyond)&lt;/h2&gt;
&lt;p&gt;The pipeline shown here is a concrete example of how to bring slide decks to life via narration. But don’t just think of organizational data as structured tables and flat text. A large amount of company intelligence is locked away in richer artifacts like PDF reports, dashboards, and video recordings.&lt;/p&gt;
&lt;p&gt;With CocoIndex and LanceDB, we can turn &lt;em&gt;all&lt;/em&gt; of that raw multimodal content into a living, breathing, contextually rich knowledge base that agents can retrieve from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unify multimodal content in one storage layer:&lt;/strong&gt; text, audio, vectors, and metadata in the same table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add structure that supports more questions:&lt;/strong&gt; speaker notes, extracted fields, embeddings, tags, and other derived signals.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep everything synchronized over time:&lt;/strong&gt; incremental recomputation when source files change, with updated rows in LanceDB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build user-facing experiences on top:&lt;/strong&gt; semantic search, narrated slide playback, assistants, and video-generation pipelines.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Try the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/slides_to_speech&quot;&gt;complete slides-to-speech example&lt;/a&gt; to bring your own deck to life! Then, star &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; and &lt;a href=&quot;https://github.com/lancedb/lancedb&quot;&gt;LanceDB&lt;/a&gt; on GitHub.&lt;/p&gt;
</content:encoded><category>examples</category><category>multimodal</category><category>lancedb</category><category>text-to-speech</category><author>Prashanth Rao, Linghua Jin</author></item><item><title>CocoIndex Changelog 0.3.11 - 0.3.26</title><link>https://cocoindex.io/blogs/changelog-0311-0326/</link><guid isPermaLink="true">https://cocoindex.io/blogs/changelog-0311-0326/</guid><description>CocoIndex updates: production-ready resilience, a structured error system, expanded integrations, and always-fresh context for agents.</description><pubDate>Sun, 18 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; is moving at insane speed! 🚀
Since the last release, CocoIndex shipped &lt;strong&gt;15 releases (0.3.11–0.3.26)&lt;/strong&gt; and crossed a major milestone: &lt;strong&gt;#1 GitHub Global Trending&lt;/strong&gt; across all languages and #1 on Rust. This cycle was focused on one clear goal: making &lt;strong&gt;fresh, structured, programmable context&lt;/strong&gt; reliable enough for &lt;strong&gt;agents running in production&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Agents are only as good as the data they reason over. Thinking about codebases, meeting notes, emails, relationships, commerce ... anything that may be subject to change. If you have ever done index-swap or had to keep multiple copies of the target to serve live agents on data or code change, you&apos;ll never have to do it again.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CocoIndex helps you ace the data for agents without heavy data engineering work.&lt;/strong&gt; Continuous fresh, structured and programmable context for AI, from PDFs, Codebase, Emails, Screenshots, Meeting Notes, ... Always up-to-date, at any scale, ultra-performant, with a smart incremental engine. ⭐ &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;Star the repo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Glad this project resonates with the community! 2026 will see more autonomous agents going into production, and we&apos;re excited to see &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; help in that frontier.&lt;/p&gt;
&lt;p&gt;Here&apos;s what we shipped since the last release. &lt;strong&gt;0.3.11–0.3.26 was about production trust&lt;/strong&gt;: resilient pipelines, structured errors, better observability, and always-fresh structured context for agents operating in the real world.&lt;/p&gt;
&lt;h2&gt;Core capability&lt;/h2&gt;
&lt;p&gt;CocoIndex just got a significant upgrade in runtime resilience and developer ergonomics. The core engine now also features a fully structured error system, enabling unified error tracing from Rust to Python so you can finally see human‑readable stack traces across boundaries. Combined with smarter runtime warnings for slow live updates, simplified progress bar rendering, and cleaner internal API boundaries, CocoIndex continues to evolve into the most transparent and trustworthy data‑processing core in its class.&lt;/p&gt;
&lt;h3&gt;Failure-tolerant target deletion&lt;/h3&gt;
&lt;p&gt;Target drop failures no longer block pipeline setup updates: an environment toggle &lt;strong&gt;&lt;code&gt;COCOINDEX_IGNORE_TARGET_DROP_FAILURES&lt;/code&gt;&lt;/strong&gt; lets you log and continue gracefully (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1454&quot;&gt;#1454&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Simplified progress experience&lt;/h3&gt;
&lt;p&gt;Streamlined progress bar updates for a more predictable runtime visualization experience (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1467&quot;&gt;#1467&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Structured error system overhaul&lt;/h3&gt;
&lt;p&gt;A new foundation for error definition and propagation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Introduced &lt;strong&gt;structured error framework&lt;/strong&gt; with host exception tunneling&lt;/li&gt;
&lt;li&gt;End-to-end &lt;strong&gt;Python stack trace propagation&lt;/strong&gt; now supported&lt;/li&gt;
&lt;li&gt;New unified &lt;strong&gt;error types&lt;/strong&gt; improve exception messaging and logging at both Rust and Python layers (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1380&quot;&gt;#1380&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1413&quot;&gt;#1413&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Smart runtime alerts&lt;/h3&gt;
&lt;p&gt;CocoIndex now warns when live updates exceed the configured refresh interval, ideal for maintaining freshness guarantees (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1408&quot;&gt;#1408&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Cleaner internal API boundaries&lt;/h3&gt;
&lt;p&gt;Refactored &lt;strong&gt;&lt;code&gt;datatype.py&lt;/code&gt;&lt;/strong&gt; with a dedicated &lt;strong&gt;&lt;code&gt;_internal&lt;/code&gt;&lt;/strong&gt; module, decluttering the public API surface (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1402&quot;&gt;#1402&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Force reprocessing option for updates&lt;/h3&gt;
&lt;p&gt;Added explicit control for full recomputation during incremental updates for safer state resets. Run &lt;code&gt;cocoindex update --full-reprocess ...&lt;/code&gt; to force a reprocessing (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1385&quot;&gt;#1385&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Flow correctness improvements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Fixed target diffing logic for user-managed setup states&lt;/li&gt;
&lt;li&gt;General FTS fixes for LanceDB (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1369&quot;&gt;#1369&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1403&quot;&gt;#1403&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Together, these changes strengthen the core runtime&apos;s fault tolerance, improve observability, and lay the groundwork for a richer developer debugging experience.&lt;/p&gt;
&lt;h2&gt;Integrations&lt;/h2&gt;
&lt;p&gt;This release massively expands what CocoIndex can plug into.&lt;/p&gt;
&lt;h3&gt;Sources &amp;amp; targets&lt;/h3&gt;
&lt;h4&gt;Qdrant&lt;/h4&gt;
&lt;p&gt;Full support for &lt;strong&gt;&lt;code&gt;VectorIndexMethod&lt;/code&gt;&lt;/strong&gt; HNSW configuration in the &lt;a href=&quot;https://cocoindex.io/docs/connectors/qdrant/&quot;&gt;Qdrant connector&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1410&quot;&gt;#1410&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Postgres&lt;/h4&gt;
&lt;p&gt;Native support for &lt;strong&gt;&lt;code&gt;pgvector&lt;/code&gt;&lt;/strong&gt; types in &lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;Postgres source&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1387&quot;&gt;#1387&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Local files&lt;/h4&gt;
&lt;p&gt;Automatic symlink resolution for easier graph ingestion (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1463&quot;&gt;#1463&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;LanceDB&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Added &lt;strong&gt;&lt;code&gt;optimize()&lt;/code&gt;&lt;/strong&gt; for post-ingest table compaction&lt;/li&gt;
&lt;li&gt;Extended full-text search integration in the &lt;a href=&quot;https://cocoindex.io/docs/connectors/lancedb/&quot;&gt;LanceDB connector&lt;/a&gt; (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1466&quot;&gt;#1466&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1358&quot;&gt;#1358&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Functions &amp;amp; LLM providers&lt;/h3&gt;
&lt;h4&gt;Unified OpenAI/Azure architecture&lt;/h4&gt;
&lt;p&gt;Shared logic between &lt;strong&gt;&lt;code&gt;openai.rs&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;azureopenai.rs&lt;/code&gt;&lt;/strong&gt; for cleaner code reuse (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1376&quot;&gt;#1376&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Azure OpenAI integration&lt;/h4&gt;
&lt;p&gt;Bring your Azure LLM accounts seamlessly into CocoIndex pipelines (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1362&quot;&gt;#1362&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;OpenRouter embeddings&lt;/h4&gt;
&lt;p&gt;Added embedding model support across OpenRouter&apos;s expanding provider network (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1359&quot;&gt;#1359&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Embedding dimension validation&lt;/h4&gt;
&lt;p&gt;Introduced &lt;strong&gt;&lt;code&gt;expected_output_dimension&lt;/code&gt;&lt;/strong&gt; param to enforce vector consistency (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1373&quot;&gt;#1373&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Direct JSON output in functions&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;GeneratedOutput&lt;/code&gt;&lt;/strong&gt; enum now supports structured JSON returns for downstream consumption (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1395&quot;&gt;#1395&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;Gemini API key fixes&lt;/h4&gt;
&lt;p&gt;Cleaner, more consistent propagation of credentials across function calls (&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1375&quot;&gt;#1375&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;These integrations make CocoIndex a more open, extensible, and multi-LLM-ready data processing cortex.&lt;/p&gt;
&lt;h2&gt;Build with CocoIndex&lt;/h2&gt;
&lt;p&gt;We are seeing more projects built with CocoIndex.&lt;/p&gt;
&lt;h3&gt;Build fresh data with LanceDB and CocoIndex&lt;/h3&gt;
&lt;p&gt;🚀 If agents are going to search, reason, and act over long periods of time, we need infrastructure that treats &lt;strong&gt;data freshness and multimodal as a first-class primitive&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Our friends at &lt;a href=&quot;https://lancedb.com&quot;&gt;LanceDB&lt;/a&gt; just published a full walkthrough with us showing how to build a &lt;strong&gt;live-updating multimodal search index&lt;/strong&gt; using LanceDB and CocoIndex, where text, images, embeddings, and LLM-derived features stay continuously in sync as data and logic evolve.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://lancedb.com/blog/keep-your-data-fresh-with-cocoindex-and-lancedb/&quot;&gt;tutorial&lt;/a&gt; for more details.&lt;/p&gt;
&lt;p&gt;🔥 &lt;strong&gt;What&apos;s cool about this example:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Incremental-by-design processing&lt;/strong&gt;: Only new or changed records are reprocessed. Add one recipe? Update one image? Fix one ingredient? CocoIndex touches only what changed, not the other 100k rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;True multimodal indexing&lt;/strong&gt;: Text, images, embeddings, and metadata live together in LanceDB, optimized for both search and analytics, without splitting data across systems.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema evolution without rewrites&lt;/strong&gt;: We add new LLM-extracted feature columns (vegetarian, dairy, category, etc.) after data is already live. CocoIndex updates the schema and backfills safely, with no table rebuilds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured LLM extraction (no brittle prompts)&lt;/strong&gt;: DSPY signatures define input/output types, so LLMs behave like programs, not JSON-generating slot machines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Upserts + deletes handled automatically&lt;/strong&gt;: Primary keys + incremental flows mean no duplicates or stale rows, and no &quot;did this record change?&quot; logic to maintain.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End-to-end lineage &amp;amp; observability&lt;/strong&gt;: With CocoInsight, you can see exactly why a record changed and which transformation produced each field.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live mode for agent-ready systems&lt;/strong&gt;: Run the pipeline continuously. As data arrives or logic changes, your search layer updates in near real time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think PDFs, slides, screenshots, images, videos, and support tickets across massive enterprise data lakes, all kept fresh, indexed, searchable, and generating real insights.&lt;/p&gt;
&lt;h3&gt;Building a real-time HackerNews trending topics detector with CocoIndex&lt;/h3&gt;
&lt;p&gt;In the age of information overload, understanding what&apos;s trending (and why) is crucial for developers, researchers, and data engineers. HackerNews is one of the most influential tech communities, but manually tracking emerging topics across thousands of threads and comments is practically impossible.&lt;/p&gt;
&lt;p&gt;In this post, we&apos;ll explore how to build a &lt;strong&gt;production-ready, real-time trending topics pipeline&lt;/strong&gt; on top of HackerNews using &lt;strong&gt;CocoIndex&lt;/strong&gt;. It shows how unstructured, high-volume community data can be incrementally ingested, enriched with LLMs, and queried as structured, up-to-date knowledge.&lt;/p&gt;
&lt;p&gt;Each HackerNews thread and comment is then processed through an &lt;strong&gt;LLM-powered extraction step&lt;/strong&gt; to identify normalized topics (technologies, products, companies, people, etc.). These topics, along with the original messages, are indexed into &lt;strong&gt;Postgres&lt;/strong&gt; as queryable tables. Weighted scoring distinguishes primary topics in threads from secondary mentions in comments, enabling higher-quality trend rankings.&lt;/p&gt;
&lt;p&gt;The result is a continuously updating system that turns raw HackerNews activity into &lt;strong&gt;structured, queryable intelligence&lt;/strong&gt;, ready for dashboards, analytics, or AI agents.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://cocoindex.io/blogs/hackernews-trending-topics&quot;&gt;blog&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h3&gt;Building a knowledge graph from meeting notes that automatically updates&lt;/h3&gt;
&lt;p&gt;Most companies sit on an ocean of meeting notes and treat them like static text files. But inside those documents are decisions, tasks, owners, and relationships: basically an untapped knowledge graph that is constantly changing. We just published a full walkthrough showing how to turn meeting notes in Drive into a &lt;strong&gt;live-updating knowledge graph&lt;/strong&gt; using &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; + LLM extraction.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://cocoindex.io/examples/meeting_notes_graph&quot;&gt;tutorial&lt;/a&gt; for more details.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&apos;s cool about this example:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Incremental processing&lt;/strong&gt;: Only changed documents get reprocessed. If you have thousands of meeting notes, but only 1% change each day, CocoIndex only touches that 1%, saving 99% of LLM cost and compute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured extraction with LLMs&lt;/strong&gt;: We use a typed Python dataclass as the schema, so the LLM returns real structured objects, not brittle JSON prompts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Graph-native export&lt;/strong&gt;: CocoIndex maps nodes (Meeting, Person, Task) and relationships (ATTENDED, DECIDED, ASSIGNED_TO) without writing Cypher, directly into &lt;a href=&quot;https://www.linkedin.com/company/neo4j/&quot;&gt;Neo4j&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-time updates&lt;/strong&gt;: If a meeting note changes (task reassigned, typo fixed, new discussion added), the graph updates automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Extracting structured data from patient intake forms with DSPy and CocoIndex&lt;/h3&gt;
&lt;p&gt;Patient intake forms are indeed a rich source of structured clinical data, but traditional OCR + regex pipelines fail to reliably capture their nested, conditional, and variable structure, leaving most of that value locked in unstructured text or manual entry.&lt;/p&gt;
&lt;p&gt;This project demonstrates a production-ready approach to extracting &lt;strong&gt;clean, validated, structured patient data directly from PDF intake forms&lt;/strong&gt; using &lt;strong&gt;DSPy&lt;/strong&gt; and &lt;strong&gt;CocoIndex&lt;/strong&gt;, without brittle OCR pipelines, regex rules, or Markdown intermediates.&lt;/p&gt;
&lt;p&gt;The result is a scalable, explainable, and compliant pipeline well-suited for healthcare and other regulated domains, turning unstructured intake forms into always-fresh, queryable patient models ready for downstream systems, analytics, or AI agents.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://cocoindex.io/examples/patient_form_extraction_dspy&quot;&gt;tutorial&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h2&gt;Thanks to the community 🤗🎉&lt;/h2&gt;
&lt;p&gt;Welcome new contributors to the CocoIndex community! We are so excited to have you!&lt;/p&gt;
&lt;h3&gt;@1fanwang&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/1fanwang&quot;&gt;@1fanwang&lt;/a&gt; for adding Azure OpenAI support as an LLM provider in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1362&quot;&gt;#1362&lt;/a&gt;, enabling enterprise users to leverage CocoIndex with their Azure deployments.&lt;/p&gt;
&lt;h3&gt;@vipulaSD&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/vipulaSD&quot;&gt;@vipulaSD&lt;/a&gt; for adding field-level descriptions for data classes in the Product Recommendation example in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1327&quot;&gt;#1327&lt;/a&gt;, making the example more informative and easier to understand.&lt;/p&gt;
&lt;h3&gt;@prrao87&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prrao87&quot;&gt;@prrao87&lt;/a&gt; for adding support for embedding models in OpenRouter in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1359&quot;&gt;#1359&lt;/a&gt;, expanding LLM provider options, and fixing typos and consistency issues in the docs in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1360&quot;&gt;#1360&lt;/a&gt;, making the documentation clearer and more polished.&lt;/p&gt;
&lt;h3&gt;@Haleshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Haleshot&quot;&gt;@Haleshot&lt;/a&gt; for improving the README note for better GitHub rendering in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1288&quot;&gt;#1288&lt;/a&gt;, shifting to the tracing crate for logging in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1363&quot;&gt;#1363&lt;/a&gt;, adding Postgres server reminder to quickstart docs in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1368&quot;&gt;#1368&lt;/a&gt;, fixing the broken workflow badge in README in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1370&quot;&gt;#1370&lt;/a&gt;, introducing a structured error system with host exception tunneling in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1380&quot;&gt;#1380&lt;/a&gt;, adding llms.txt file to docs in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1455&quot;&gt;#1455&lt;/a&gt;, adding short flag aliases for common CLI options in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1458&quot;&gt;#1458&lt;/a&gt;, and adding env file sourcing tip to meeting_notes_graph example in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1470&quot;&gt;#1470&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@thisisharsh7&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/thisisharsh7&quot;&gt;@thisisharsh7&lt;/a&gt; for making &lt;code&gt;openai.rs&lt;/code&gt; generic to share logic with &lt;code&gt;azureopenai.rs&lt;/code&gt; in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1376&quot;&gt;#1376&lt;/a&gt;, including mypy type checking for the examples directory in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1383&quot;&gt;#1383&lt;/a&gt;, and adding the &lt;code&gt;GeneratedOutput&lt;/code&gt; enum for direct JSON returns in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1395&quot;&gt;#1395&lt;/a&gt;, enhancing type safety and code reusability.&lt;/p&gt;
&lt;h3&gt;@prithvi-moonshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prithvi-moonshot&quot;&gt;@prithvi-moonshot&lt;/a&gt; for adding &lt;code&gt;onBrokenAnchors&lt;/code&gt; config to report documentation errors in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1325&quot;&gt;#1325&lt;/a&gt;, providing an option to force reprocessing during update in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1385&quot;&gt;#1385&lt;/a&gt;, and making target deletion (drop) tolerate failures in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1454&quot;&gt;#1454&lt;/a&gt;, improving robustness of the data pipeline.&lt;/p&gt;
&lt;h3&gt;@skprasadu&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/skprasadu&quot;&gt;@skprasadu&lt;/a&gt; for wrapping cargo test to avoid embedded Python stdlib discovery crash in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1398&quot;&gt;#1398&lt;/a&gt;, moving Query Support above Built-in Sources in the docs for better organization in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1397&quot;&gt;#1397&lt;/a&gt;, and adding a warning when live update pass exceeds &lt;code&gt;refresh_interval&lt;/code&gt; in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1408&quot;&gt;#1408&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;@Gohlub&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Gohlub&quot;&gt;@Gohlub&lt;/a&gt; for adding &lt;code&gt;with_context&lt;/code&gt; to user‑configured operations in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1275&quot;&gt;#1275&lt;/a&gt;, enabling more powerful context‑aware flows for sources, functions, and targets.&lt;/p&gt;
&lt;h3&gt;@wxh06&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/wxh06&quot;&gt;@wxh06&lt;/a&gt; for adding &lt;code&gt;expected_output_dimension&lt;/code&gt; parameter to &lt;code&gt;embed_text&lt;/code&gt; in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1373&quot;&gt;#1373&lt;/a&gt;, providing more control over embedding output dimensions.&lt;/p&gt;
&lt;h3&gt;@samojavo&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/samojavo&quot;&gt;@samojavo&lt;/a&gt; for fixing mypy errors in example entry points and adding a local type-check script in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1248&quot;&gt;#1248&lt;/a&gt;, improving code quality and developer experience.&lt;/p&gt;
&lt;h3&gt;@aryasoni98&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/aryasoni98&quot;&gt;@aryasoni98&lt;/a&gt; for enabling programmatic API key passing besides reading from environment variables in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1134&quot;&gt;#1134&lt;/a&gt;, providing more flexibility in configuration.&lt;/p&gt;
&lt;h3&gt;@shinobiwanshin&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/shinobiwanshin&quot;&gt;@shinobiwanshin&lt;/a&gt; for enhancing the Button component with hover effects and styling improvements in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1393&quot;&gt;#1393&lt;/a&gt;, making the UI more polished and interactive.&lt;/p&gt;
&lt;h3&gt;@majiayu000&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/majiayu000&quot;&gt;@majiayu000&lt;/a&gt; for adding &lt;code&gt;VectorIndexMethod&lt;/code&gt; support for HNSW configuration in Qdrant in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1410&quot;&gt;#1410&lt;/a&gt;, enabling more fine-grained control over vector indexing parameters.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; continues evolving into a &lt;strong&gt;resilient, intelligent data engine&lt;/strong&gt; for the modern AI stack.&lt;/p&gt;
&lt;p&gt;This cycle focuses on &lt;strong&gt;error transparency, LLM integration depth, and frictionless developer operations&lt;/strong&gt;, ensuring your pipelines stay alive, observable, and blazing fast even under adverse conditions.&lt;/p&gt;
&lt;p&gt;If you like this project, please support us with a star ⭐ at https://github.com/cocoindex-io/cocoindex !&lt;/p&gt;
&lt;p&gt;🔥🔥 We are cooking something big! v1 is coming soon. Stay tuned! If you read all the way here, you are a true fan!&lt;/p&gt;
</content:encoded><category>changelog</category><category>connectors</category><category>structured-extraction</category><category>knowledge-graph</category><author>George He</author></item><item><title>Extract patient intake forms with DSPy and CocoIndex</title><link>https://cocoindex.io/blogs/extraction-dspy/</link><guid isPermaLink="true">https://cocoindex.io/blogs/extraction-dspy/</guid><description>Extract Pydantic-typed structured data from patient intake forms using DSPy and CocoIndex: OCR vision models with incremental processing.</description><pubDate>Mon, 15 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Patient intake forms are indeed a rich source of structured clinical data, but traditional OCR + regex pipelines fail to reliably capture their nested, conditional, and variable structure, leaving most of that value locked in unstructured text or manual entry.&lt;/p&gt;
&lt;p&gt;In today&apos;s example, we are going to show how to extract clean, typed, Pydantic-validated structured data directly from PDFs, using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DSPy&lt;/strong&gt; (for multimodal structured extraction with Gemini 2.5 Flash vision)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CocoIndex&lt;/strong&gt; (for incremental processing, caching, and database storage)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No manual text extraction, no brittle markdown conversion: just connect to the source, transform the PDFs, and get validated patient models out, ready to go in production.&lt;/p&gt;
&lt;p&gt;The entire code is &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/patient_intake_extraction_dspy&quot;&gt;open sourced with Apache 2.0 license&lt;/a&gt;. To see more examples built with CocoIndex, you could refer to the &lt;a href=&quot;https://cocoindex.io/examples&quot;&gt;examples&lt;/a&gt; page.
&lt;strong&gt;⭐ Star&lt;/strong&gt; the project if you find it helpful! &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;&lt;img src=&quot;https://img.shields.io/github/stars/cocoindex-io/cocoindex?color=5B5BD6&quot; alt=&quot;GitHub&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Why DSPy + CocoIndex?&lt;/h2&gt;
&lt;p&gt;Before jumping in, here’s what each component contributes:&lt;/p&gt;
&lt;h3&gt;DSPy: A programming framework for LLMs&lt;/h3&gt;
&lt;p&gt;Traditional LLM apps rely on prompt engineering: you write a prompt with instructions, few‑shot examples, and formatting, then call the model and parse the raw text.  This approach is fragile:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Small changes in the prompt, model, or data can break the output format or quality.&lt;/li&gt;
&lt;li&gt;Logic is buried in strings, making it hard to test, compose, or version.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/stanfordnlp/dspy&quot;&gt;DSPy&lt;/a&gt; replaces this with a programming model: you define what each LLM step should do (inputs, outputs, constraints), and the framework figures out how to prompt the model to satisfy that spec.&lt;/p&gt;
&lt;h3&gt;CocoIndex: An ultra performant data processing engine for AI workloads&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; is an ultra performant compute framework for AI workloads, with &lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental processing&lt;/a&gt;. Users write simple in-memory computations in Python and coco runs them as a resilient, scalable data pipeline (with Rust Engine) – with fresh data always ready for serving, built on a small set of &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;core concepts&lt;/a&gt;.  The same flow definition you use in a notebook can be lifted easily into production.&lt;/p&gt;
&lt;p&gt;With CocoIndex,  changes in sources or transformation logic only trigger minimal recompute, cutting cold-start “backfill” latencies from hours to seconds while reducing GPU/API spend. In production, this manifests as always-fresh targets: you run in “live” mode with change data capture or polling, and CocoIndex keeps derived stores in sync with complex unstructured sources like codebases, PDFs, and multi-hop API compositions.&lt;/p&gt;
&lt;p&gt;Because every transformation step is observable with lineage, teams get auditability and explainability out of the box, which helps with regulated scenarios like healthcare extraction or financial workflows.&lt;/p&gt;
&lt;h3&gt;DSPy &amp;amp; CocoIndex synergy&lt;/h3&gt;
&lt;p&gt;The synergy shows up most clearly in end-to-end AI data products: DSPy defines robust, typed extractors or decision modules, and CocoIndex wires them into a resilient, incremental pipeline that can meet SLOs and compliance needs. Any change in documents, code, or business rules is reflected quickly and explainably in the targets and features those agents consume.&lt;/p&gt;
&lt;h2&gt;Flow overview&lt;/h2&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before getting started, make sure you have the following set up:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/getting_started/installation/&quot;&gt;Install Postgres&lt;/a&gt; if you don&apos;t have one, and ensure you can connect to it from your development environment.&lt;/li&gt;
&lt;li&gt;Python dependencies&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;pip install -U cocoindex dspy-ai pydantic pymupdf
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;.env&lt;/code&gt; file:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# Postgres database address for cocoindex
COCOINDEX_DATABASE_URL=postgres://cocoindex:cocoindex@localhost/cocoindex

# Gemini API key
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Pydantic models: Define the structured schema&lt;/h2&gt;
&lt;p&gt;We defined Pydantic-style classes (&lt;code&gt;Contact&lt;/code&gt;, &lt;code&gt;Address&lt;/code&gt;, &lt;code&gt;Insurance&lt;/code&gt;, etc.) to match a &lt;em&gt;FHIR-inspired patient schema&lt;/em&gt;, enabling structured and validated representations of patient data. Each model corresponds to a key aspect of a patient&apos;s record, ensuring both type safety and nested relationships.&lt;/p&gt;
&lt;h3&gt;1. Contact model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Contact(BaseModel):
    name: str
    phone: str
    relationship: str
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents an &lt;strong&gt;emergency or personal contact&lt;/strong&gt; for the patient.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: Contact&apos;s full name.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;phone&lt;/code&gt;: Contact phone number.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;relationship&lt;/code&gt;: Relation to the patient (e.g., parent, spouse, friend).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. Address model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Address(BaseModel):
    street: str
    city: str
    state: str
    zip_code: str
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents a &lt;strong&gt;postal address&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;street&lt;/code&gt;, &lt;code&gt;city&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;zip_code&lt;/code&gt;: Standard address fields.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. Pharmacy model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Pharmacy(BaseModel):
    name: str
    phone: str
    address: Address
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents the &lt;strong&gt;patient’s preferred pharmacy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: Pharmacy name.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;phone&lt;/code&gt;: Pharmacy contact number.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;address&lt;/code&gt;: Uses the &lt;code&gt;Address&lt;/code&gt; model for structured address information.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. Insurance model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Insurance(BaseModel):
    provider: str
    policy_number: str
    group_number: str | None = None
    policyholder_name: str
    relationship_to_patient: str
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents the patient’s &lt;strong&gt;insurance information&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;provider&lt;/code&gt;: Insurance company name.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;policy_number&lt;/code&gt;: Unique policy number.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;group_number&lt;/code&gt;: Optional group number.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;policyholder_name&lt;/code&gt;: Name of the person covered under the insurance.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;relationship_to_patient&lt;/code&gt;: Relationship to patient (e.g., self, parent).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5. Condition model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Condition(BaseModel):
    name: str
    diagnosed: bool
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents a &lt;strong&gt;medical condition&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: Condition name (e.g., Diabetes).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;diagnosed&lt;/code&gt;: Boolean indicating whether it has been officially diagnosed.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6. Medication model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Medication(BaseModel):
    name: str
    dosage: str
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents a &lt;strong&gt;current medication&lt;/strong&gt; the patient is taking.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: Medication name.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dosage&lt;/code&gt;: Dosage information (e.g., &quot;10mg daily&quot;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7. Allergy model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Allergy(BaseModel):
    name: str
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents a &lt;strong&gt;known allergy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: Name of the allergen (e.g., peanuts, penicillin).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;8. Surgery model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Surgery(BaseModel):
    name: str
    date: str
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents a &lt;strong&gt;surgery or procedure&lt;/strong&gt; the patient has undergone.&lt;/li&gt;
&lt;li&gt;Fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: Surgery name (e.g., Appendectomy).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;date&lt;/code&gt;: Surgery date (as a string, ideally ISO format).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9. Patient model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Patient(BaseModel):
    name: str
    dob: datetime.date
    gender: str
    address: Address
    phone: str
    email: str
    preferred_contact_method: str
    emergency_contact: Contact
    insurance: Insurance | None = None
    reason_for_visit: str
    symptoms_duration: str
    past_conditions: list[Condition] = Field(default_factory=list)
    current_medications: list[Medication] = Field(default_factory=list)
    allergies: list[Allergy] = Field(default_factory=list)
    surgeries: list[Surgery] = Field(default_factory=list)
    occupation: str | None = None
    pharmacy: Pharmacy | None = None
    consent_given: bool
    consent_date: str | None = None
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Represents a &lt;strong&gt;complete patient record&lt;/strong&gt; with personal, medical, and administrative information.&lt;/li&gt;
&lt;li&gt;Key fields:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;dob&lt;/code&gt;, &lt;code&gt;gender&lt;/code&gt;: Basic personal info.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;address&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;: Contact info.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preferred_contact_method&lt;/code&gt;: How the patient prefers to be reached.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;emergency_contact&lt;/code&gt;: Nested &lt;code&gt;Contact&lt;/code&gt; model.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insurance&lt;/code&gt;: Optional nested &lt;code&gt;Insurance&lt;/code&gt; model.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reason_for_visit&lt;/code&gt;, &lt;code&gt;symptoms_duration&lt;/code&gt;: Visit details.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;past_conditions&lt;/code&gt;, &lt;code&gt;current_medications&lt;/code&gt;, &lt;code&gt;allergies&lt;/code&gt;, &lt;code&gt;surgeries&lt;/code&gt;: Lists of nested models for comprehensive medical history.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;occupation&lt;/code&gt;: Optional job info.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pharmacy&lt;/code&gt;: Optional nested &lt;code&gt;Pharmacy&lt;/code&gt; model.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;consent_given&lt;/code&gt;, &lt;code&gt;consent_date&lt;/code&gt;: Legal/administrative consent info.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Why use Pydantic here?&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Validation:&lt;/strong&gt; Ensures all fields are the correct type (e.g., &lt;code&gt;dob&lt;/code&gt; is a &lt;code&gt;date&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured Nested Models:&lt;/strong&gt; Patient has nested objects like &lt;code&gt;Address&lt;/code&gt;, &lt;code&gt;Contact&lt;/code&gt;, and &lt;code&gt;Insurance&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default Values &amp;amp; Optional Fields:&lt;/strong&gt; Handles optional fields and defaults (&lt;code&gt;Field(default_factory=list)&lt;/code&gt; ensures empty lists if no data).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serialization:&lt;/strong&gt; Easily convert models to JSON for APIs or databases.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error Checking:&lt;/strong&gt; Automatically raises errors if invalid data is provided.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;DSPy vision extractor&lt;/h2&gt;
&lt;h3&gt;DSPy signature&lt;/h3&gt;
&lt;p&gt;Let’s define &lt;code&gt;PatientExtractionSignature&lt;/code&gt;.  A &lt;strong&gt;Signature&lt;/strong&gt; describes what data your module expects and what it will produce. Think of it as a &lt;strong&gt;schema for an AI task&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PatientExtractionSignature&lt;/code&gt; is a &lt;code&gt;dspy.Signature&lt;/code&gt;, which is DSPy&apos;s way of declaring &lt;em&gt;what&lt;/em&gt; the model should do, not &lt;em&gt;how&lt;/em&gt; it does it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# DSPy Signature for patient information extraction from images
class PatientExtractionSignature(dspy.Signature):
    &quot;&quot;&quot;Extract structured patient information from a medical intake form image.&quot;&quot;&quot;

    form_images: list[dspy.Image] = dspy.InputField(
        desc=&quot;Images of the patient intake form pages&quot;
    )
    patient: Patient = dspy.OutputField(
        desc=&quot;Extracted patient information with all available fields filled&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This signature defines &lt;strong&gt;the task contract&lt;/strong&gt; for patient information extraction.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Inputs: &lt;code&gt;form_images&lt;/code&gt; – a list of images of the intake form.&lt;/li&gt;
&lt;li&gt;Outputs: &lt;code&gt;patient&lt;/code&gt; – a structured &lt;code&gt;Patient&lt;/code&gt; object.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From DSPy&apos;s point of view, this Signature is a &quot;spec&quot;: a mapping from an image-based context to a structured, Pydantic-backed semantic object that can later be optimized, trained, and composed with other modules.&lt;/p&gt;
&lt;h3&gt;PatientExtractor module&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;PatientExtractor&lt;/code&gt; is a &lt;code&gt;dspy.Module&lt;/code&gt;, which in DSPy is a composable, potentially trainable building block that implements the Signature.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class PatientExtractor(dspy.Module):
    &quot;&quot;&quot;DSPy module for extracting patient information from intake form images.&quot;&quot;&quot;

    def __init__(self) -&amp;gt; None:
        super().__init__()
        self.extract = dspy.ChainOfThought(PatientExtractionSignature)

    def forward(self, form_images: list[dspy.Image]) -&amp;gt; Patient:
        &quot;&quot;&quot;Extract patient information from form images and return as a Pydantic model.&quot;&quot;&quot;
        result = self.extract(form_images=form_images)
        return result.patient  # type: ignore
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;In &lt;code&gt;__init__&lt;/code&gt;, &lt;code&gt;ChainOfThought&lt;/code&gt; is a DSPy &lt;em&gt;primitive module&lt;/em&gt; that knows how to call an LLM with reasoning-style prompting to satisfy the given Signature. In other words, it is a default &quot;strategy&quot; for solving the &quot;extract patient from images&quot; task.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;forward&lt;/code&gt; method is DSPy&apos;s standard interface for executing a module.  You pass &lt;code&gt;form_images&lt;/code&gt; into &lt;code&gt;self.extract()&lt;/code&gt;. DSPy then handles converting this call into an LLM interaction (or a trained program) that produces a &lt;code&gt;patient&lt;/code&gt; field as declared in the Signature.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Conceptually, &lt;code&gt;PatientExtractor&lt;/code&gt; is an &lt;em&gt;ETL operator&lt;/em&gt;: the Signature describes the input/output types, and the internal &lt;code&gt;ChainOfThought&lt;/code&gt; module is the function that fills that contract.&lt;/p&gt;
&lt;h3&gt;Single-step extraction&lt;/h3&gt;
&lt;p&gt;Now let’s wire the DSPy Module to extract from a single PDF. From a high level,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The extractor receives PDF bytes directly&lt;/li&gt;
&lt;li&gt;Internally converts PDF pages to DSPy Image objects using PyMuPDF&lt;/li&gt;
&lt;li&gt;Processes images with vision model&lt;/li&gt;
&lt;li&gt;Returns Pydantic model directly&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;@cocoindex.op.function(cache=True, behavior_version=1)
def extract_patient(pdf_content: bytes) -&amp;gt; Patient:
    &quot;&quot;&quot;Extract patient information from PDF content.&quot;&quot;&quot;

    # Convert PDF pages to DSPy Image objects
    pdf_doc = pymupdf.open(stream=pdf_content, filetype=&quot;pdf&quot;)

    form_images = []
    for page in pdf_doc:
        # Render page to pixmap (image) at 2x resolution for better quality
        pix = page.get_pixmap(matrix=pymupdf.Matrix(2, 2))
        # Convert to PNG bytes
        img_bytes = pix.tobytes(&quot;png&quot;)
        # Create DSPy Image from bytes
        form_images.append(dspy.Image(img_bytes))

    pdf_doc.close()

    # Extract patient information using DSPy with vision
    extractor = PatientExtractor()
    patient = extractor(form_images=form_images)

    return patient  # type: ignore
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This function is a &lt;strong&gt;CocoIndex &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;custom function&lt;/a&gt;&lt;/strong&gt; (decorated with &lt;code&gt;@cocoindex.op.function&lt;/code&gt;) that takes &lt;strong&gt;PDF bytes&lt;/strong&gt; as input and returns a fully structured &lt;code&gt;Patient&lt;/code&gt; Pydantic object.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cache=True&lt;/code&gt; allows repeated calls with the same PDF to &lt;strong&gt;reuse results&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;behavior_version=1&lt;/code&gt; ensures versioning of the function for reproducibility.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Create DSPy image objects&lt;/h3&gt;
&lt;p&gt;We open the PDF from bytes using &lt;strong&gt;PyMuPDF&lt;/strong&gt; (&lt;code&gt;pymupdf&lt;/code&gt;), then  we iterate over each page.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Useful trick: Render the page as a &lt;strong&gt;high-resolution image&lt;/strong&gt; (&lt;code&gt;2x&lt;/code&gt;) for better OCR/vision performance.&lt;/li&gt;
&lt;li&gt;Convert the rendered page to &lt;strong&gt;PNG bytes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Wrap the PNG bytes in a &lt;strong&gt;DSPy &lt;code&gt;Image&lt;/code&gt; object&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;DSPy extraction&lt;/h3&gt;
&lt;p&gt;The list of &lt;code&gt;form_images&lt;/code&gt; is passed to the DSPy module:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;ChainOfThought reasoning&lt;/strong&gt; interprets each image.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vision + NLP&lt;/strong&gt; extract relevant text fields.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Populate Pydantic &lt;code&gt;Patient&lt;/code&gt; object&lt;/strong&gt; with structured patient info.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;CocoIndex flow&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Loads PDFs from local directory as binary&lt;/li&gt;
&lt;li&gt;For each document, applies single transform: PDF bytes → Patient data&lt;/li&gt;
&lt;li&gt;Exports the results to a PostgreSQL table&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Declare flow&lt;/h3&gt;
&lt;p&gt;Declare a CocoIndex flow, connect to the source, add a data collector to collect processed data.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@cocoindex.flow_def(name=&quot;PatientIntakeExtractionDSPy&quot;)
def patient_intake_extraction_dspy_flow(
    flow_builder: cocoindex.FlowBuilder, data_scope: cocoindex.DataScope
) -&amp;gt; None:
    data_scope[&quot;documents&quot;] = flow_builder.add_source(
        cocoindex.sources.LocalFile(path=&quot;data/patient_forms&quot;, binary=True)
    )

    patients_index = data_scope.add_collector()
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@cocoindex.flow_def&lt;/code&gt; tells CocoIndex that this function is a flow definition, not regular runtime code.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;add_source()&lt;/code&gt; registers a &lt;a href=&quot;https://cocoindex.io/docs/connectors/localfs/&quot;&gt;&lt;code&gt;LocalFile&lt;/code&gt; source&lt;/a&gt; that traverses the &lt;code&gt;data/patient_forms&lt;/code&gt; directory and creates a logical table named &lt;code&gt;documents&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can connect to various sources, or even &lt;a href=&quot;https://cocoindex.io/blogs/custom-source&quot;&gt;custom sources&lt;/a&gt; with CocoIndex if native connectors are not available. CocoIndex is designed to keep your indexes synchronized with your data sources. This is achieved through a feature called &lt;strong&gt;live updates&lt;/strong&gt;, which automatically detects changes in your sources and updates your indexes accordingly. This ensures that your search results and data analysis are always based on the most current information.  You can read more in the &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/live_mode/&quot;&gt;live updates documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Process documents&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;with data_scope[&quot;documents&quot;].row() as doc:
    # Extract patient information directly from PDF using DSPy with vision
    # (PDF-&amp;gt;Image conversion happens inside the extractor)
    doc[&quot;patient_info&quot;] = doc[&quot;content&quot;].transform(extract_patient)

    # Collect the extracted patient information
    patients_index.collect(
        filename=doc[&quot;filename&quot;],
        patient_info=doc[&quot;patient_info&quot;],
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This iterates over each document. We transform &lt;code&gt;doc[&quot;content&quot;]&lt;/code&gt; (the bytes) by our &lt;code&gt;extract_patient&lt;/code&gt; function. The result is stored in a new field &lt;code&gt;patient_info&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then we collect a row with the &lt;code&gt;filename&lt;/code&gt; and extracted &lt;code&gt;patient_info&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Export to Postgres&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;patients_index.export(
    &quot;patients&quot;,
    cocoindex.storages.Postgres(table_name=&quot;patients_info_dspy&quot;),
    primary_key_fields=[&quot;filename&quot;],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We export the collected index to &lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;Postgres&lt;/a&gt;. This will create/maintain a table &lt;code&gt;patients&lt;/code&gt; keyed by filename, automatically deleting or updating rows if inputs change.&lt;/p&gt;
&lt;p&gt;Because CocoIndex tracks data lineage, it will handle updates/deletions of source files incrementally.&lt;/p&gt;
&lt;h3&gt;Configure CocoIndex settings&lt;/h3&gt;
&lt;p&gt;Define a &lt;strong&gt;CocoIndex settings function&lt;/strong&gt; that configures the AI model for DSPy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@cocoindex.settings
def cocoindex_settings() -&amp;gt; cocoindex.Settings:
    # Configure the model used in DSPy
    lm = dspy.LM(&quot;gemini/gemini-2.5-flash&quot;)
    dspy.configure(lm=lm)

    return cocoindex.Settings.from_env()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It returns a &lt;code&gt;cocoindex.Settings&lt;/code&gt; object initialized from environment variables, enabling the system to use the configured model and environment settings for all DSPy operations.&lt;/p&gt;
&lt;h2&gt;Running the pipeline&lt;/h2&gt;
&lt;p&gt;Update the index:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update main
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;CocoInsight&lt;/h3&gt;
&lt;p&gt;I used CocoInsight (Free beta now) to troubleshoot the index generation and understand the data lineage of the pipeline. It just connects to your local CocoIndex server, with zero pipeline data retention.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cocoindex server -ci main
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Scalable open ecosystem, not a closed box&lt;/h2&gt;
&lt;p&gt;CocoIndex is intentionally &quot;composable by default&quot;: it gives you a fast, incremental data engine and clean flow abstraction, but never locks you into a specific model, vector DB, processing module or orchestration stack.&lt;/p&gt;
&lt;p&gt;CocoIndex treats everything (sources, ops, and storages) as pluggable interfaces rather than proprietary primitives. You can read from local files, S3, APIs, or custom sources, call any data transformation logic (beyond SQL, DSPy modules, any complex Python transformations, generated parsers, etc.), and export to relational databases, vector databases, search engines, or custom sinks through its storage layer.&lt;/p&gt;
&lt;h3&gt;Why DSPy + CocoIndex fits this philosophy&lt;/h3&gt;
&lt;p&gt;DSPy is itself a compositional framework for LLMs: you define typed Signatures and Modules, and it learns how to implement them, making the LLM layer programmable, testable, and optimizable.&lt;/p&gt;
&lt;p&gt;CocoIndex treats these modules as first-class operators in the flow, so you get a clean separation of concerns: DSPy owns “how the model thinks,” while CocoIndex owns “how data moves, is cached, and is served” across changing PDFs, code, or APIs.&lt;/p&gt;
&lt;p&gt;This pairing is powerful because neither system tries to be the entire stack: CocoIndex does not prescribe a prompt framework, and DSPy does not prescribe a data pipeline engine. Instead, they interlock: DSPy modules become composable building blocks inside CocoIndex flows, and CocoIndex gives those modules a production context with retries, batching, caching, and live updates.&lt;/p&gt;
&lt;h2&gt;Comparison of DSPy vs BAML examples&lt;/h2&gt;
&lt;p&gt;For this DSPy and &lt;a href=&quot;https://cocoindex.io/examples/patient_form_extraction_baml&quot;&gt;BAML example&lt;/a&gt; with CocoIndex, there are some differences in the data flow and the schema enforcement.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;This tutorial (DSPy)&lt;/th&gt;
&lt;th&gt;Previous intake tutorials (OpenAI/BAML)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model interaction&lt;/td&gt;
&lt;td&gt;DSPy Signatures + Modules, trainable and optimizable.&lt;/td&gt;
&lt;td&gt;Direct prompts or BAML functions with generated clients.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input format&lt;/td&gt;
&lt;td&gt;PDF bytes → images → Gemini Vision (no Markdown step).&lt;/td&gt;
&lt;td&gt;PDF/DOCX → Markdown via parsers (e.g., MarkItDown) → text LLM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema enforcement&lt;/td&gt;
&lt;td&gt;Pydantic models used directly as DSPy output types.&lt;/td&gt;
&lt;td&gt;Dataclasses or BAML types mapped into CocoIndex collectors.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline engine&lt;/td&gt;
&lt;td&gt;CocoIndex incremental flow with LocalFile source + Postgres sink.&lt;/td&gt;
&lt;td&gt;Same engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;DSPy and BAML both help build LLM applications with structured outputs, but they emphasize different things: DSPy focuses on modular Python workflows and optimizer-driven prompt tuning, while BAML focuses on a typed DSL, schema guarantees, and multi-language client generation.&lt;/p&gt;
&lt;h2&gt;Support CocoIndex ❤️&lt;/h2&gt;
&lt;p&gt;If this example was helpful, the easiest way to support CocoIndex is to &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;give the project a ⭐ on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Your stars help us grow the community, stay motivated, and keep shipping better tools for real-time data ingestion and transformation.&lt;/p&gt;
</content:encoded><category>examples</category><category>tutorial</category><category>structured-extraction</category><category>multimodal</category><category>llm</category><author>Linghua Jin</author></item><item><title>A knowledge graph from meeting notes that auto-updates</title><link>https://cocoindex.io/blogs/meeting-notes-graph/</link><guid isPermaLink="true">https://cocoindex.io/blogs/meeting-notes-graph/</guid><description>Build a self-updating Neo4j knowledge graph from meeting notes with CocoIndex v1: LLM-extracted decisions, tasks, and owners, with embedding-based dedupe.</description><pubDate>Mon, 08 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Meeting notes capture decisions, action items, who was in the room, and how people and tasks connect. Most teams keep them as static documents, reachable only through text search. A knowledge graph lets you query them like a database instead: &lt;em&gt;&quot;Who attended the budget planning meetings?&quot;&lt;/em&gt; or &lt;em&gt;&quot;What tasks did Sarah get assigned across every meeting?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;To answer questions like these you extract structured information from unstructured notes and store it as a graph, which makes relationship queries direct instead of impossible.&lt;/p&gt;
&lt;p&gt;This post walks through the &lt;strong&gt;Meeting Notes Graph&lt;/strong&gt; example: a CocoIndex v1 pipeline that reads Markdown meeting notes from Google Drive, extracts structure with an LLM, deduplicates person names with embedding-based entity resolution, and builds a queryable graph in Neo4j.&lt;/p&gt;
&lt;p&gt;The project is open-sourced in the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/meeting_notes_graph_neo4j&quot;&gt;CocoIndex v1 repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Why a graph, and why incremental&lt;/h2&gt;
&lt;p&gt;Meeting notes are living documents. Attendee names get corrected, tasks get reassigned, and decisions get revised. A batch pipeline that rebuilds the whole graph on every edit is wasteful once you have more than a handful of files, and it makes near-real-time freshness expensive.&lt;/p&gt;
&lt;p&gt;CocoIndex processes &lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incrementally&lt;/a&gt;: it detects which files changed and reprocesses only those. Unchanged notes never hit the LLM, and the graph write is a no-op for anything that did not change. The heavy LLM extraction step is memoized, so as long as a section&apos;s text and the model are unchanged, the cached result is reused.&lt;/p&gt;
&lt;h2&gt;The graph model&lt;/h2&gt;
&lt;p&gt;We build a property graph with three node types and three edge types:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nodes&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Meeting&lt;/code&gt;&lt;/strong&gt;: one per meeting section, with its date and notes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Person&lt;/code&gt;&lt;/strong&gt;: a canonical individual (organizer, participant, or task assignee)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Task&lt;/code&gt;&lt;/strong&gt;: an action item decided in a meeting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Edges&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ATTENDED&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;Person → Meeting&lt;/code&gt;, carrying an &lt;code&gt;is_organizer&lt;/code&gt; flag&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;DECIDED&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;Meeting → Task&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ASSIGNED_TO&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;Person → Task&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To learn more about property graphs, see CocoIndex&apos;s &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/target_state/&quot;&gt;Property Graph Targets&lt;/a&gt; documentation.&lt;/p&gt;
&lt;h2&gt;Define the pipeline&lt;/h2&gt;
&lt;p&gt;CocoIndex v1 is declarative and Python-native: you write plain async Python and the Rust engine handles incremental processing. The whole example lives in one &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/blob/main/examples/meeting_notes_graph_neo4j/main.py&quot;&gt;&lt;code&gt;main.py&lt;/code&gt;&lt;/a&gt;, wrapped in a &lt;code&gt;coco.App&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import cocoindex as coco
from cocoindex.connectors import google_drive, neo4j
from cocoindex.ops.entity_resolution import ResolvedEntities, resolve_entities
from cocoindex.ops.entity_resolution.llm_resolver import LlmPairResolver
from cocoindex.ops.sentence_transformers import SentenceTransformerEmbedder
from cocoindex.resources.id import IdGenerator

app = coco.App(coco.AppConfig(name=&quot;MeetingNotesGraphNeo4j&quot;), app_main)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Shared resources&lt;/h3&gt;
&lt;p&gt;The Neo4j connection, the two LLM models, and the embedder are provided once in the app lifespan and retrieved anywhere with &lt;code&gt;coco.use_context&lt;/code&gt;. The &lt;code&gt;neo4j.ConnectionFactory&lt;/code&gt; reads standard connection settings from the environment, defaulting to a local Neo4j:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;KG_DB = coco.ContextKey[neo4j.ConnectionFactory](&quot;kg_db&quot;)
LLM_MODEL = coco.ContextKey[str](&quot;llm_model&quot;, detect_change=True)
RESOLUTION_LLM_MODEL = coco.ContextKey[str](&quot;resolution_llm_model&quot;, detect_change=True)
EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder](&quot;embedder&quot;, detect_change=True)

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -&amp;gt; AsyncIterator[None]:
    builder.provide(
        KG_DB,
        neo4j.ConnectionFactory(
            uri=os.environ.get(&quot;NEO4J_URI&quot;, &quot;bolt://localhost:7687&quot;),
            auth=(
                os.environ.get(&quot;NEO4J_USER&quot;, &quot;neo4j&quot;),
                os.environ.get(&quot;NEO4J_PASSWORD&quot;, &quot;cocoindex&quot;),
            ),
            database=os.environ.get(&quot;NEO4J_DATABASE&quot;, &quot;neo4j&quot;),
        ),
    )
    builder.provide(LLM_MODEL, os.environ.get(&quot;LLM_MODEL&quot;, &quot;openai/gpt-5-mini&quot;))
    builder.provide(
        RESOLUTION_LLM_MODEL,
        os.environ.get(&quot;RESOLUTION_LLM_MODEL&quot;, &quot;openai/gpt-5-mini&quot;),
    )
    builder.provide(
        EMBEDDER,
        SentenceTransformerEmbedder(&quot;Snowflake/snowflake-arctic-embed-xs&quot;),
    )
    yield
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;detect_change=True&lt;/code&gt; means swapping a model or the embedder re-runs only the affected steps.&lt;/p&gt;
&lt;h3&gt;Node and edge schemas&lt;/h3&gt;
&lt;p&gt;Node rows are plain dataclasses. The primary key of each becomes the node&apos;s identity in Neo4j, so re-runs reconcile existing nodes in place instead of duplicating them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class Meeting:
    id: int
    note_file: str
    time: datetime.date
    note: str

@dataclass
class Person:
    name: str  # canonical

@dataclass
class Task:
    description: str

@dataclass
class AttendedRel:
    is_organizer: bool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AttendedRel&lt;/code&gt; is the edge payload for &lt;code&gt;ATTENDED&lt;/code&gt;. &lt;code&gt;DECIDED&lt;/code&gt; and &lt;code&gt;ASSIGNED_TO&lt;/code&gt; carry no payload, so they are declared without a record. Relation primary keys are auto-derived by the Neo4j connector from &lt;code&gt;(from_id, to_id)&lt;/code&gt;, giving exactly one edge per endpoint pair.&lt;/p&gt;
&lt;p&gt;Each dataclass maps directly to the graph: a record becomes a node keyed by its primary key, and a declared relation becomes an edge between two endpoint keys.&lt;/p&gt;
&lt;h3&gt;The extraction schema&lt;/h3&gt;
&lt;p&gt;The LLM extraction target is a set of Pydantic models. &lt;code&gt;instructor&lt;/code&gt; uses these as the response schema, so the model returns structured data that matches the Python types instead of free-form text:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ExtractedPerson(pydantic.BaseModel):
    name: str = pydantic.Field(
        description=&quot;Full name of the person, as written in the note.&quot;
    )

class ExtractedTask(pydantic.BaseModel):
    description: str = pydantic.Field(
        description=&quot;Concise, standalone description of the task or action item.&quot;
    )
    assigned_to: list[ExtractedPerson] = pydantic.Field(default_factory=list)

class ExtractedMeeting(pydantic.BaseModel):
    time: datetime.date
    note: str
    organizer: ExtractedPerson
    participants: list[ExtractedPerson] = pydantic.Field(default_factory=list)
    tasks: list[ExtractedTask] = pydantic.Field(default_factory=list)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Split, then extract&lt;/h3&gt;
&lt;p&gt;A single note file often holds several meetings. &lt;code&gt;_split_meetings&lt;/code&gt; breaks the text at Markdown headings (&lt;code&gt;#&lt;/code&gt; or &lt;code&gt;##&lt;/code&gt;) so each section becomes one meeting:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_HEADING_RE = re.compile(r&quot;\n\n##?\s+&quot;)

def _split_meetings(text: str) -&amp;gt; list[str]:
    parts = _HEADING_RE.split(&quot;\n\n&quot; + text)
    return [p.strip() for p in parts if p.strip()]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each section goes to &lt;code&gt;extract_meeting&lt;/code&gt;, which calls the model through LiteLLM and &lt;code&gt;instructor&lt;/code&gt;. The model comes from the &lt;code&gt;LLM_MODEL&lt;/code&gt; context (default &lt;code&gt;openai/gpt-5-mini&lt;/code&gt;), and &lt;code&gt;memo=True&lt;/code&gt; caches the result so an unchanged section is never re-extracted:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def extract_meeting(section_text: str) -&amp;gt; ExtractedMeeting:
    client = instructor.from_litellm(litellm.acompletion, mode=instructor.Mode.JSON)
    result = await client.chat.completions.create(
        model=coco.use_context(LLM_MODEL),
        response_model=ExtractedMeeting,
        messages=[
            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: EXTRACT_PROMPT},
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: section_text},
        ],
    )
    return ExtractedMeeting.model_validate(result.model_dump())
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Three phases&lt;/h2&gt;
&lt;p&gt;The pipeline runs in three phases. Phase 1 extracts each file and declares the nodes and edges it can build on its own. Phase 2 resolves person names across the whole corpus. Phase 3 declares the canonical &lt;code&gt;Person&lt;/code&gt; nodes and the edges that touch people.&lt;/p&gt;
&lt;h3&gt;Phase 1: per-file extraction&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;process_file&lt;/code&gt; reads a Drive file, splits it, and for each meeting declares a &lt;code&gt;Meeting&lt;/code&gt; node and its &lt;code&gt;Task&lt;/code&gt; nodes plus &lt;code&gt;DECIDED&lt;/code&gt; edges. Meeting IDs come from an &lt;code&gt;IdGenerator&lt;/code&gt; keyed on the meeting date. Person names are not resolved yet, so the raw names are collected into a &lt;code&gt;MeetingExtraction&lt;/code&gt; and returned for later phases:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def process_file(
    file: google_drive.DriveFile,
    meeting_table: neo4j.TableTarget[Meeting],
    task_table: neo4j.TableTarget[Task],
    decided_rel: neo4j.RelationTarget[Any],
) -&amp;gt; list[MeetingExtraction]:
    text = await file.read_text()
    note_file = file.file_path.path.as_posix()
    id_generator = IdGenerator()
    extractions = []
    for section in _split_meetings(text):
        extracted = await extract_meeting(section)
        meeting_id = await id_generator.next_id(extracted.time)

        meeting_table.declare_record(
            row=Meeting(
                id=meeting_id,
                note_file=note_file,
                time=extracted.time,
                note=extracted.note,
            )
        )

        for task in extracted.tasks:
            task_table.declare_record(row=Task(description=task.description))
            decided_rel.declare_relation(from_id=meeting_id, to_id=task.description)

        extractions.append(
            MeetingExtraction(
                meeting_id=meeting_id,
                organizer=extracted.organizer.name,
                participants=[p.name for p in extracted.participants],
                task_assignees=[
                    (t.description, [a.name for a in t.assigned_to])
                    for t in extracted.tasks
                ],
            )
        )
    return extractions
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Phase 2: person entity resolution&lt;/h3&gt;
&lt;p&gt;The same person shows up under different names across files: &quot;Sarah&quot; in one note, &quot;Sarah Chen&quot; in another. If you create a &lt;code&gt;Person&lt;/code&gt; node per raw string, the graph fragments and relationship queries miss edges.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_resolve_persons&lt;/code&gt; deduplicates raw names with embedding-based entity resolution. Candidate matches are found by embedding similarity, then confirmed pairwise by an LLM. The result maps every raw name to a canonical one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def _resolve_persons(raw_persons: set[str]) -&amp;gt; ResolvedEntities:
    return await resolve_entities(
        entities=raw_persons,
        embedder=coco.use_context(EMBEDDER),
        resolve_pair=LlmPairResolver(model=coco.use_context(RESOLUTION_LLM_MODEL)),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ResolvedEntities&lt;/code&gt; exposes &lt;code&gt;.canonicals()&lt;/code&gt; for the deduplicated set and &lt;code&gt;.canonical_of(raw_name)&lt;/code&gt; to look up which canonical a raw name folds into. So &quot;Sarah&quot; and &quot;Sarah Chen&quot; collapse to a single &lt;code&gt;Person&lt;/code&gt; node.&lt;/p&gt;
&lt;h3&gt;Phase 3: canonical people and their edges&lt;/h3&gt;
&lt;p&gt;With names resolved, &lt;code&gt;create_person_relations&lt;/code&gt; declares one &lt;code&gt;Person&lt;/code&gt; node per canonical name, then the person-touching edges. &lt;code&gt;ATTENDED&lt;/code&gt; aggregates organizer and participants so a person listed as both gets a single edge with &lt;code&gt;is_organizer=True&lt;/code&gt;. &lt;code&gt;ASSIGNED_TO&lt;/code&gt; dedupes per canonical person and task:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def create_person_relations(
    meetings: list[MeetingExtraction],
    persons: ResolvedEntities,
    person_table: neo4j.TableTarget[Person],
    attended_rel: neo4j.RelationTarget[Any],
    assigned_rel: neo4j.RelationTarget[Any],
) -&amp;gt; None:
    for canonical_name in persons.canonicals():
        person_table.declare_record(row=Person(name=canonical_name))

    for m in meetings:
        attendees: dict[str, bool] = {persons.canonical_of(m.organizer): True}
        for p in m.participants:
            attendees.setdefault(persons.canonical_of(p), False)

        for canonical, is_organizer in attendees.items():
            attended_rel.declare_relation(
                from_id=canonical,
                to_id=m.meeting_id,
                record=AttendedRel(is_organizer=is_organizer),
            )

        for task_desc, assignees in m.task_assignees:
            seen: set[str] = set()
            for raw in assignees:
                canonical = persons.canonical_of(raw)
                if canonical in seen:
                    continue
                seen.add(canonical)
                assigned_rel.declare_relation(from_id=canonical, to_id=task_desc)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Wiring it together&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;app_main&lt;/code&gt; mounts the three node tables and three relation targets, then runs the phases. Node tables take a &lt;code&gt;TableSchema&lt;/code&gt; and a primary key; relations mount without a schema so the connector derives their PK from the endpoints. The Google Drive source is iterated with &lt;code&gt;async for&lt;/code&gt;, and each file is processed under its own component path so change detection is per-file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def app_main() -&amp;gt; None:
    meeting_table = await neo4j.mount_table_target(
        KG_DB, &quot;Meeting&quot;,
        await neo4j.TableSchema.from_class(Meeting, primary_key=&quot;id&quot;),
        primary_key=&quot;id&quot;,
    )
    person_table = await neo4j.mount_table_target(
        KG_DB, &quot;Person&quot;,
        await neo4j.TableSchema.from_class(Person, primary_key=&quot;name&quot;),
        primary_key=&quot;name&quot;,
    )
    task_table = await neo4j.mount_table_target(
        KG_DB, &quot;Task&quot;,
        await neo4j.TableSchema.from_class(Task, primary_key=&quot;description&quot;),
        primary_key=&quot;description&quot;,
    )

    attended_rel = await neo4j.mount_relation_target(
        KG_DB, &quot;ATTENDED&quot;, person_table, meeting_table
    )
    decided_rel = await neo4j.mount_relation_target(
        KG_DB, &quot;DECIDED&quot;, meeting_table, task_table
    )
    assigned_rel = await neo4j.mount_relation_target(
        KG_DB, &quot;ASSIGNED_TO&quot;, person_table, task_table
    )

    # Phase 1: per-file extraction
    source = google_drive.GoogleDriveSource(
        service_account_credential_path=os.environ[&quot;GOOGLE_SERVICE_ACCOUNT_CREDENTIAL&quot;],
        root_folder_ids=[
            f.strip()
            for f in os.environ[&quot;GOOGLE_DRIVE_ROOT_FOLDER_IDS&quot;].split(&quot;,&quot;)
            if f.strip()
        ],
    )
    file_coros = []
    async for path_key, file in source.items():
        file_coros.append(
            coco.use_mount(
                coco.component_subpath(&quot;file&quot;, path_key),
                process_file, file, meeting_table, task_table, decided_rel,
            )
        )
    per_file = list(await asyncio.gather(*file_coros))
    all_meetings = [m for ms in per_file for m in ms]

    # Phase 2: person entity resolution
    raw_persons: set[str] = set()
    for m in all_meetings:
        raw_persons.add(m.organizer)
        raw_persons.update(m.participants)
        for _task_desc, assignees in m.task_assignees:
            raw_persons.update(assignees)
    persons = await coco.use_mount(
        coco.component_subpath(&quot;resolve_persons&quot;), _resolve_persons, raw_persons
    )

    # Phase 3: declare Person nodes + person-touching relations
    await coco.mount(
        coco.component_subpath(&quot;person_relations&quot;),
        create_person_relations,
        all_meetings, persons, person_table, attended_rel, assigned_rel,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a component finishes, CocoIndex compares its declared nodes and edges against the previous run and applies only the differences: new items are inserted, changed ones updated, and items whose source file disappeared are removed. Nothing that stayed the same is rewritten.&lt;/p&gt;
&lt;h2&gt;Run&lt;/h2&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/connectors/neo4j/&quot;&gt;Neo4j&lt;/a&gt; running locally. Default connection &lt;code&gt;bolt://localhost:7687&lt;/code&gt;, browser at &lt;a href=&quot;http://localhost:7474/&quot;&gt;http://localhost:7474&lt;/a&gt;, username &lt;code&gt;neo4j&lt;/code&gt;, password &lt;code&gt;cocoindex&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;a href=&quot;https://cocoindex.io/docs/ops/litellm/&quot;&gt;OpenAI API key&lt;/a&gt; (&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;). The default models are &lt;code&gt;openai/gpt-5-mini&lt;/code&gt; for both extraction and resolution.&lt;/li&gt;
&lt;li&gt;Google Drive service-account credentials. Create a Google Cloud service account, download its JSON key, share the source folders with the service-account email, and collect the folder IDs. See &lt;a href=&quot;https://cocoindex.io/docs/connectors/google_drive/&quot;&gt;Setup for Google Drive&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Set the environment variables:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export OPENAI_API_KEY=sk-...
export GOOGLE_SERVICE_ACCOUNT_CREDENTIAL=/absolute/path/to/service_account.json
export GOOGLE_DRIVE_ROOT_FOLDER_IDS=folderId1,folderId2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GOOGLE_DRIVE_ROOT_FOLDER_IDS&lt;/code&gt; accepts a comma-separated list.&lt;/p&gt;
&lt;h3&gt;Build the graph&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;pip install -e .
cocoindex update main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use &lt;code&gt;cocoindex update -L main&lt;/code&gt; for live mode, which keeps watching Drive and updates the graph as notes change.&lt;/p&gt;
&lt;h3&gt;Browse the graph&lt;/h3&gt;
&lt;p&gt;Open Neo4j Browser at &lt;a href=&quot;http://localhost:7474/&quot;&gt;http://localhost:7474&lt;/a&gt; and run Cypher:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// All relationships
MATCH p=()--&amp;gt;() RETURN p

// Who attended which meetings (including organizer)
MATCH (p:Person)-[:ATTENDED]-&amp;gt;(m:Meeting)
RETURN p, m

// Tasks decided in meetings
MATCH (m:Meeting)-[:DECIDED]-&amp;gt;(t:Task)
RETURN m, t

// Task assignments
MATCH (p:Person)-[:ASSIGNED_TO]-&amp;gt;(t:Task)
RETURN p, t
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Where else this applies&lt;/h2&gt;
&lt;p&gt;The same pattern (ingest → split → extract → resolve entities → build graph) fits any text-heavy domain where the entities and their relationships matter more than the raw text:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Research papers&lt;/strong&gt;: build a graph of concepts, authors, and citations across a repository, and keep it current as papers are added or revised.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Support tickets&lt;/strong&gt;: link issues, solutions, customers, and recurring patterns across a large, frequently edited ticket base.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance documents&lt;/strong&gt;: extract requirements from policies and trace how a policy change cascades through related obligations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Competitive intelligence&lt;/strong&gt;: connect companies, products, and market moves extracted from public filings and news.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;This example turns unstructured meeting notes into a queryable Neo4j graph: an LLM extracts structure, embedding-based entity resolution keeps one node per real person, and CocoIndex&apos;s incremental processing keeps the graph in sync as notes change without reprocessing the whole corpus. The same source → split → extract → resolve → graph template carries over to any domain where relationships are the thing you want to query.&lt;/p&gt;
&lt;h2&gt;Support CocoIndex ❤️&lt;/h2&gt;
&lt;p&gt;If this example was helpful, the easiest way to support CocoIndex is to &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;give the project a ⭐ on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Your stars help us grow the community, stay motivated, and keep shipping better tools for real-time data ingestion and transformation.&lt;/p&gt;
</content:encoded><category>examples</category><category>connectors</category><category>knowledge-graph</category><category>llm</category><category>incremental-processing</category><author>Linghua Jin</author></item><item><title>What HackerNews is talking about: trending topics with an LLM</title><link>https://cocoindex.io/blogs/hackernews-trending-topics/</link><guid isPermaLink="true">https://cocoindex.io/blogs/hackernews-trending-topics/</guid><description>Rank trending HackerNews topics in Postgres with an incremental CocoIndex v1 pipeline: scrape threads and comments, extract topics with an LLM.</description><pubDate>Tue, 02 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The tech community&apos;s attention is hiding in plain sight, scattered across story titles and thousands of comments. This post builds a pipeline that reads recent HackerNews threads and their comments, asks an LLM to pull the topics out of every message, and ranks what&apos;s trending in Postgres, rewritten for CocoIndex v1.&lt;/p&gt;
&lt;p&gt;Two pieces do the work: the LLM extraction that turns free-form text into normalized topics, and a SQL score that turns those topics into a leaderboard. This post focuses on both. The source here is a custom one (two async functions over the &lt;a href=&quot;https://hn.algolia.com/api&quot;&gt;Algolia HN API&lt;/a&gt;); the &lt;a href=&quot;https://cocoindex.io/blogs/custom-source-hackernews&quot;&gt;custom-source walkthrough&lt;/a&gt; covers that side in detail, so we won&apos;t repeat it here.&lt;/p&gt;
&lt;p&gt;The full example is in the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/hn_trending_topics&quot;&gt;CocoIndex v1 repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;How it works&lt;/h2&gt;
&lt;p&gt;Each recent story becomes its own &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;processing component&lt;/a&gt;. The component fetches the story and its comments, extracts topics from each message with an LLM, and declares rows into two Postgres tables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;hn_messages&lt;/code&gt;: one row per message (the story and each comment), for full-text search&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hn_topics&lt;/code&gt;: one row per topic mention, keyed on &lt;code&gt;(topic, message_id)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the same topic mentioned in many messages becomes many &lt;code&gt;hn_topics&lt;/code&gt; rows, ranking becomes a simple SQL aggregate. Re-running catches up on new stories, and because per-message extraction is memoized, only content CocoIndex hasn&apos;t seen hits the LLM.&lt;/p&gt;
&lt;h2&gt;The topic extraction is the product&lt;/h2&gt;
&lt;p&gt;The interesting part is not calling an LLM; it&apos;s getting topics back in a shape you can rank. Raw model output would give you &quot;postgres&quot; in one comment and &quot;PostgreSQL&quot; in another, or a whole phrase like &quot;books for autistic kids&quot; as a single blob. Those never aggregate.&lt;/p&gt;
&lt;p&gt;The fix lives entirely in the prompt, expressed as the description of a Pydantic field. The model normalizes three ways: it splits combined phrases into parts, keeps proper nouns canonical, and emits aliases for the same thing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TopicsResponse(BaseModel):
    &quot;&quot;&quot;Response containing a list of extracted topics.&quot;&quot;&quot;

    topics: list[str] = Field(
        description=&quot;&quot;&quot;List of extracted topics.

Each topic can be a product name, technology, model, people, company name, business domain, etc.
Capitalize for proper nouns and acronyms only.
Use the form that is clear alone.

For topics that are a phrase combining multiple things, normalize into multiple topics. Examples:
- &quot;books for autistic kids&quot; -&amp;gt; &quot;book&quot;, &quot;autistic&quot;, &quot;autistic kids&quot;

When there&apos;re multiple common ways to refer to the same thing, use multiple topics. Examples:
- &quot;John Kennedy&quot;, &quot;JFK&quot;
&quot;&quot;&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That normalization is what makes the trending ranking meaningful later. Get it wrong and &quot;PostgreSQL&quot;, &quot;postgres&quot;, and &quot;psql&quot; split their votes three ways.&lt;/p&gt;
&lt;p&gt;Extraction itself is one CocoIndex function: text in, a list of topics out, through &lt;a href=&quot;https://cocoindex.io/docs/ops/litellm/&quot;&gt;litellm&lt;/a&gt; so any provider works. &lt;code&gt;memo=True&lt;/code&gt; caches the result, so an unchanged message is never sent to the model twice:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn(memo=True)
async def extract_topics(text: str | None) -&amp;gt; list[str]:
    if not text or not text.strip():
        return []

    response = await acompletion(
        model=LLM_MODEL,
        messages=[
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Extract topics from the following text:\n\n{text[:4000]}&quot;}
        ],
        response_format=TopicsResponse,
    )
    content = response.choices[0].message.content
    return TopicsResponse.model_validate_json(content).topics
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Declare a row per message and per topic&lt;/h2&gt;
&lt;p&gt;The two target tables are plain dataclasses. A thread and each of its comments both become an &lt;code&gt;HnMessage&lt;/code&gt; (distinguished by &lt;code&gt;content_type&lt;/code&gt;), and every extracted topic becomes an &lt;code&gt;HnTopic&lt;/code&gt; keyed on &lt;code&gt;(topic, message_id)&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class HnMessage:
    id: str
    thread_id: str
    content_type: str  # &quot;thread&quot; or &quot;comment&quot;
    author: str | None
    text: str | None
    url: str | None
    created_at: datetime | None

@dataclass
class HnTopic:
    topic: str
    message_id: str
    thread_id: str
    content_type: str
    created_at: datetime | None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;process_thread&lt;/code&gt; fetches one story, extracts topics for the story and every comment, and declares the rows. You declare what should exist; CocoIndex works out the inserts, updates, and deletes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def process_thread(thread_id: str, targets: TableTargets) -&amp;gt; None:
    async with aiohttp.ClientSession() as session:
        thread = await fetch_thread(session, thread_id)
    thread_topics = await extract_topics(thread.text)

    targets.messages.declare_row(row=HnMessage(
        id=thread.id, thread_id=thread.id, content_type=&quot;thread&quot;,
        author=thread.author, text=thread.text, url=thread.url, created_at=thread.created_at,
    ))
    for topic in thread_topics:
        targets.topics.declare_row(row=HnTopic(
            topic=topic, message_id=thread.id, thread_id=thread.id,
            content_type=&quot;thread&quot;, created_at=thread.created_at,
        ))

    for comment in thread.comments:
        comment_topics = await extract_topics(comment.text)
        targets.messages.declare_row(row=HnMessage(..., content_type=&quot;comment&quot;, ...))
        for topic in comment_topics:
            targets.topics.declare_row(row=HnTopic(..., content_type=&quot;comment&quot;, ...))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;app_main&lt;/code&gt; mounts the two Postgres tables and fans out one &lt;code&gt;process_thread&lt;/code&gt; per recent story with &lt;code&gt;coco.mount_each&lt;/code&gt;. The two async fetch functions that make up the custom source are covered in the &lt;a href=&quot;https://cocoindex.io/blogs/custom-source-hackernews&quot;&gt;custom-source post&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Ranking what&apos;s trending&lt;/h2&gt;
&lt;p&gt;Trending is a SQL query over &lt;code&gt;hn_topics&lt;/code&gt;. Each row contributes a weight to its topic&apos;s score: a thread-level mention counts for more than a comment-level one, because a topic in the story title is the subject, while a topic in a comment is supporting discussion. Group by topic, order by score:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
    topic,
    SUM(CASE WHEN content_type = &apos;thread&apos; THEN 5 ELSE 1 END) AS score,
    MAX(created_at) AS latest_mention,
    COUNT(DISTINCT thread_id) AS thread_count
FROM coco_examples.hn_topics
GROUP BY topic
ORDER BY score DESC, latest_mention DESC
LIMIT 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Weighting thread mentions 5 to 1 over comment mentions is what makes the ranking read as &quot;what HackerNews is about&quot; rather than &quot;what people mentioned in passing.&quot; The same score, filtered to one topic and grouped by &lt;code&gt;thread_id&lt;/code&gt;, ranks which threads are driving a given topic.&lt;/p&gt;
&lt;h2&gt;Run it&lt;/h2&gt;
&lt;p&gt;Install the example and build the index:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -e .
cocoindex update main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This fetches the most recent stories, runs topic extraction on every message, and writes &lt;code&gt;coco_examples.hn_messages&lt;/code&gt; and &lt;code&gt;coco_examples.hn_topics&lt;/code&gt;. This source is a one-shot catch-up per run; re-run &lt;code&gt;cocoindex update main&lt;/code&gt; anytime to pick up new stories, and only new messages hit the LLM.&lt;/p&gt;
&lt;p&gt;Then explore from the command line, top trending topics or a single topic:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python main.py            # top 20 trending topics + interactive search
python main.py &quot;rust&quot;     # search content for one topic
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What you can build on top&lt;/h2&gt;
&lt;p&gt;Once topics are structured and ranked, the pipeline is a foundation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Trending dashboards.&lt;/strong&gt; Serve the leaderboard and per-topic threads to a live page that shows what&apos;s hot and which discussions are driving it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sentiment-aware trends.&lt;/strong&gt; Add a second extraction step for sentiment to track not just what&apos;s trending, but how people feel about it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contributor tracking.&lt;/strong&gt; Every message stores its &lt;code&gt;author&lt;/code&gt;, so you can find who drives conversation around a given topic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A knowledge graph.&lt;/strong&gt; Feed the topics into a &lt;a href=&quot;https://cocoindex.io/blogs/knowledge-graph-for-docs&quot;&gt;graph&lt;/a&gt; linking companies, products, and people mentioned across HackerNews.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these is a new transform or target on the same declared source, not a rewrite. The extraction and the ranking, the two pieces this post focused on, stay exactly as they are.&lt;/p&gt;
&lt;h2&gt;Support us ❤️&lt;/h2&gt;
&lt;p&gt;If this was useful, a &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;star on GitHub&lt;/a&gt; helps more developers find CocoIndex and keeps the project growing.&lt;/p&gt;
</content:encoded><category>examples</category><category>llm</category><category>structured-extraction</category><category>incremental-processing</category><category>postgres</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 0.2.21 - 0.3.10</title><link>https://cocoindex.io/blogs/changelog-0310/</link><guid isPermaLink="true">https://cocoindex.io/blogs/changelog-0310/</guid><description>Featuring batching support for CocoIndex functions, execution robustness, schema &amp; type system improvements, custom source support, and more.</description><pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt; is moving at insane speed! 🚀
This release is packed with some of our biggest upgrades yet, from automatic batching, to robust async execution, schema and type-system upgrades, and the long-awaited launch of Custom Sources.&lt;/p&gt;
&lt;p&gt;CocoIndex is rapidly becoming the best framework for &lt;strong&gt;persistent-state–driven data processing&lt;/strong&gt;, where it continuously transforms and updates the target data (e.g. AI context, feature stores, knowledge graphs and beyond) upon source change with zero manual orchestration.&lt;/p&gt;
&lt;p&gt;We’re incredibly excited to keep building in the open with our community. Together, we’re redefining how AI systems maintain, evolve, and reason over long-lived state. Onward! 🎉&lt;/p&gt;
&lt;h2&gt;Core capability&lt;/h2&gt;
&lt;h3&gt;Batching support for CocoIndex functions&lt;/h3&gt;
&lt;p&gt;CocoIndex now supports &lt;a href=&quot;https://cocoindex.io/blogs/batching&quot;&gt;&lt;strong&gt;automatic batching&lt;/strong&gt;&lt;/a&gt; for all CocoIndex functions. When embedding the CocoIndex codebase with &lt;em&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/em&gt;,
batching delivered &lt;a href=&quot;https://cocoindex.io/blogs/batching#performance-evaluation&quot;&gt;&lt;strong&gt;~5× higher throughput&lt;/strong&gt; (≈80% lower runtime)&lt;/a&gt; compared to processing items one-by-one.&lt;/p&gt;
&lt;h4&gt;Why it’s fast&lt;/h4&gt;
&lt;p&gt;Each call normally pays:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fixed overhead&lt;/strong&gt; (GPU kernel launches, Python↔C transitions, memory allocator work)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data-dependent compute&lt;/strong&gt; (FLOPs, bytes moved, tokens processed)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Batching amortizes the fixed cost and lets GPUs run larger, denser GEMMs with fewer data transfers, dramatically increasing utilization.&lt;/p&gt;
&lt;h4&gt;What’s new&lt;/h4&gt;
&lt;p&gt;CocoIndex introduces &lt;strong&gt;adaptive, knob-free batching&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Requests queue while the GPU is busy&lt;/li&gt;
&lt;li&gt;As soon as a batch completes, all queued requests are flushed as the next batch&lt;/li&gt;
&lt;li&gt;No timers, target batch sizes, or tuning: batching automatically adapts to workload traffic&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Batching significantly enhances processing speed by amortizing fixed overhead across multiple items, enabling more efficient GPU operations, and reducing data transfer.
CocoIndex simplifies this by offering automatic batching for several built-in functions and an easy &lt;code&gt;batching=True&lt;/code&gt; parameter for &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;custom functions&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can read more about &lt;strong&gt;batching and the benchmark&lt;/strong&gt; in the &lt;a href=&quot;https://cocoindex.io/blogs/batching&quot;&gt;blog post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1229&quot;&gt;#1229&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1230&quot;&gt;#1230&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1232&quot;&gt;#1232&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1233&quot;&gt;#1233&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1236&quot;&gt;#1236&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1238&quot;&gt;#1238&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1261&quot;&gt;#1261&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Custom source support&lt;/h2&gt;
&lt;p&gt;We&apos;re thrilled to &lt;a href=&quot;https://cocoindex.io/blogs/custom-source&quot;&gt;introduce&lt;/a&gt; &lt;strong&gt;Custom Sources&lt;/strong&gt; in CocoIndex, a feature that lets you pull data from &lt;strong&gt;any system&lt;/strong&gt;: APIs, databases, file systems, cloud storage, or other services. CocoIndex now ingests data incrementally, tracks changes efficiently, and integrates seamlessly into your workflows.&lt;/p&gt;
&lt;p&gt;With Custom Sources, you&apos;re no longer limited by prebuilt connectors or targets. Use CocoIndex for &lt;strong&gt;anything&lt;/strong&gt;, leveraging its robust incremental computing to continuously build fresh knowledge for AI.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;custom source&lt;/strong&gt; defines how CocoIndex reads data from an external system.&lt;/p&gt;
&lt;p&gt;Custom sources are defined by two components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;source spec&lt;/strong&gt; that configures the behavior and connection parameters for the source.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class CustomSource(cocoindex.op.SourceSpec):
    &quot;&quot;&quot;
    Custom source for my external system.
    &quot;&quot;&quot;
    param1: str
    param2: int | None = None
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;source connector&lt;/strong&gt; that handles the actual data reading operations. It provides the following required methods:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;create()&lt;/code&gt;: Create a connector instance from the source spec.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;list()&lt;/code&gt;: List all available data items. Return keys.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;get_value()&lt;/code&gt;: Get the full content for a specific data item by a given key.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;@cocoindex.op.source_connector(
    spec_cls=CustomSource,
    key_type=DataKeyType,
    value_type=DataValueType
)
class CustomSourceConnector:
    @staticmethod
    async def create(spec: CustomSource) -&amp;gt; &quot;CustomSourceConnector&quot;:
        &quot;&quot;&quot;Initialize connection, authenticate, and return connector instance.&quot;&quot;&quot;
        ...

    async def list(self, options: SourceReadOptions) -&amp;gt; AsyncIterator[PartialSourceRow[DataKeyType, DataValueType]]:
        &quot;&quot;&quot;List available data items with optional metadata (ordinal, content).&quot;&quot;&quot;
        ...

    async def get_value(self, key: DataKeyType, options: SourceReadOptions) -&amp;gt; PartialSourceRowData[DataValueType]:
        &quot;&quot;&quot;Retrieve full content for a specific data item.&quot;&quot;&quot;
        ...

    def provides_ordinal(self) -&amp;gt; bool:
        &quot;&quot;&quot;Optional: Return True if the source provides timestamps or version numbers.&quot;&quot;&quot;
        return False

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check out the article on &lt;a href=&quot;https://cocoindex.io/blogs/custom-source&quot;&gt;Bring your own data: Index any data with Custom Sources&lt;/a&gt; for more details.
Get started and read the &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/core_concepts/&quot;&gt;documentation&lt;/a&gt; now.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1195&quot;&gt;#1195&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1197&quot;&gt;#1197&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1198&quot;&gt;#1198&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1199&quot;&gt;#1199&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1201&quot;&gt;#1201&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Execution robustness / debugability enhancement&lt;/h2&gt;
&lt;p&gt;Robust async runtime, better error propagation and observability for HTTP calls, function‑level timeouts and contextual execution in CocoIndex.&lt;/p&gt;
&lt;h3&gt;Runtime and async safety&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;The runtime now detects misuse of the sync API from async code and guards it explicitly. Sync entrypoints now safely handle calls from different event loops, avoiding deadlocks.&lt;/li&gt;
&lt;li&gt;Async cancellation is now propagated correctly through CocoIndex&apos;s async contexts, so task cancellations (for example, from higher‑level orchestration or timeouts) reliably unwind work instead of silently hanging.&lt;/li&gt;
&lt;li&gt;Async custom functions are fully supported and wired into the runtime so user‑defined async operations behave like first‑class citizens, including proper scheduling and error handling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://www.notion.so/CocoIndex-Release-Notes-v0-2-21-v0-3-10-2b627b71aee78076b2b3cf08d64bcbeb?pvs=21&quot;&gt;#1224&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1228&quot;&gt;#1228&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1255&quot;&gt;#1255&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;HTTP utility improvements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A new &lt;strong&gt;&lt;code&gt;http::request()&lt;/code&gt;&lt;/strong&gt; utility centralizes HTTP calls, providing:
&lt;ul&gt;
&lt;li&gt;Better, more actionable error messages for network and protocol failures.&lt;/li&gt;
&lt;li&gt;Built‑in retry behavior so transient errors are handled consistently across integrations.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;This utility is intended as the recommended path for HTTP interactions in sources, functions and sinks, improving both debuggability and resilience.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1235&quot;&gt;#1235&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Function timeout support&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;User‑configured operations (sources, functions, etc.) now support a function timeout to prevent unbounded or long‑running executions.&lt;/li&gt;
&lt;li&gt;The timeout is fully wired through the batching executor so batched executions respect per‑function limits instead of letting a single slow item stall the entire batch.&lt;/li&gt;
&lt;li&gt;Timeouts integrate with the new cancellation propagation so timed‑out work is properly cancelled and cleaned up, rather than left in an indeterminate state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1241&quot;&gt;#1241&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1284&quot;&gt;#1284&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Clear context in error messages&lt;/h3&gt;
&lt;p&gt;CocoIndex now attaches clear context information (like source, function and target names) with error messages.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1275&quot;&gt;#1275&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Schema &amp;amp; type system&lt;/h2&gt;
&lt;p&gt;This set of changes improves how CocoIndex handles JSON Schemas, type metadata, and schema merging, with a focus on configurability, correctness, and compatibility across tools and providers.&lt;/p&gt;
&lt;h3&gt;Collector schema alignment&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Collectors now automatically merge and align schemas when &lt;code&gt;collect()&lt;/code&gt; is called multiple times with different shapes, producing a unified schema instead of requiring manual pre‑alignment.&lt;/li&gt;
&lt;li&gt;The new collector implementation has been simplified in a follow‑up change while preserving the automatic merge‑and‑align behavior for easier maintenance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1153&quot;&gt;#1153&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1265&quot;&gt;#1265&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Improved schema support&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;additionalProperties&lt;/code&gt; is now configurable via &lt;code&gt;supports_additional_properties&lt;/code&gt; on &lt;code&gt;ToJsonSchemaOptions&lt;/code&gt;, so providers that support it (OpenAI, Anthropic, Bedrock, Ollama, etc.) still get strict schemas while Gemini (AI Studio, Vertex) receives schemas without this keyword, removing the previous Gemini‑specific strip workaround.&lt;/li&gt;
&lt;li&gt;Forward‑referenced field types are now resolved before export, improving compatibility with BAML and other tooling that expects fully concrete types, and type descriptions are correctly encoded so documentation survives serialization.&lt;/li&gt;
&lt;li&gt;A previously incorrect assertion condition in the type/schema logic has been fixed to avoid spurious failures in valid configurations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1254&quot;&gt;#1254&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1272&quot;&gt;#1272&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1294&quot;&gt;#1294&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1312&quot;&gt;#1312&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;More accurate logic change detection&lt;/h2&gt;
&lt;p&gt;CocoIndex now detects logic changes with much finer granularity, so incremental runs only reprocess data that is truly affected.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New per‑source &lt;code&gt;SourceLogicFingerprint&lt;/code&gt; and field‑level &lt;code&gt;FieldDefFingerprint&lt;/code&gt; track exactly which sources and operations each export/field depends on.&lt;/li&gt;
&lt;li&gt;Execution and indexing now use these fingerprints (including target schema IDs) to avoid unnecessary recomputation while still catching real logic changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1292&quot;&gt;#1292&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Building blocks&lt;/h2&gt;
&lt;h3&gt;Builtin sources&lt;/h3&gt;
&lt;h4&gt;File size management across sources&lt;/h4&gt;
&lt;p&gt;CocoIndex’s built-in file-based sources now support consistent file size limits to protect pipelines from unexpectedly large inputs.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;max_file_size&lt;/code&gt; is now supported on Azure Blob, Amazon S3, LocalFile, and Google Drive sources.&lt;/li&gt;
&lt;li&gt;Files larger than the configured &lt;code&gt;max_file_size&lt;/code&gt; are treated as non-existent in both &lt;code&gt;list()&lt;/code&gt; and &lt;code&gt;get_value()&lt;/code&gt; calls, letting you cap resource usage and skip oversized blobs uniformly across all these sources.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1257&quot;&gt;#1257&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1259&quot;&gt;#1259&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1260&quot;&gt;#1260&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1269&quot;&gt;#1269&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;GoogleDrive source enhancements&lt;/h4&gt;
&lt;p&gt;GoogleDrive now supports &lt;code&gt;included_patterns&lt;/code&gt; and &lt;code&gt;excluded_patterns&lt;/code&gt;, using glob-style patterns to decide exactly which Drive files to index. This makes it easy to focus on specific file types or paths (for example, only &lt;code&gt;*.md&lt;/code&gt; docs) while excluding noisy locations like temp folders or logs.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1263&quot;&gt;#1263&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;S3 configuration enhancements&lt;/h4&gt;
&lt;p&gt;Amazon S3 sources gain a &lt;code&gt;force_path_style&lt;/code&gt; configuration flag, improving compatibility with S3-compatible object stores and strict networking setups that require path-style URLs instead of virtual-hosted–style.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1290&quot;&gt;#1290&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;S3 event notifications via Redis queue&lt;/h4&gt;
&lt;p&gt;CocoIndex can now consume S3 event notifications via a Redis queue, enabling push-based, near–real-time updates from S3 buckets into your indexing flows. This reduces the need for frequent full scans or tight polling loops while still keeping downstream indexes fresh.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1189&quot;&gt;#1189&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;Postgres source stability&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;The Postgres source’s change‑listening connection is now more stable, reducing disconnects and flaky behavior when capturing updates via &lt;strong&gt;&lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;This makes long‑running live update pipelines against Postgres more resilient, especially under network hiccups or database restarts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1264&quot;&gt;#1264&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;UTF-16/UTF-32 file support&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;File decoding now auto‑detects BOMs and supports UTF‑16 and UTF‑32 in addition to UTF‑8, so mixed‑encoding corpora can be indexed without pre‑normalizing everything.&lt;/li&gt;
&lt;li&gt;The internal &lt;code&gt;bytes_to_string()&lt;/code&gt; helper was optimized to return a &lt;code&gt;Cow&lt;/code&gt; instead of always allocating, avoiding unnecessary copies when data is already valid UTF‑8.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1185&quot;&gt;#1185&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1186&quot;&gt;#1186&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Builtin functions&lt;/h3&gt;
&lt;h4&gt;Performance optimization for SentenceTransformerEmbed&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;SentenceTransformerEmbed&lt;/code&gt;&lt;/strong&gt; now sorts inputs by length before batching, which reduces padding, improves GPU utilization, and lowers per-batch compute cost for sentence-transformer models. This change is transparent to users but can significantly improve throughput for large embedding workloads.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1245&quot;&gt;#1245&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;Ollama embedding support&lt;/h4&gt;
&lt;p&gt;The Ollama embedding integration now correctly parses the nested embeddings array returned by the &lt;strong&gt;&lt;code&gt;/api/embed&lt;/code&gt;&lt;/strong&gt; endpoint. This fixes shape/format mismatches so multi-input embedding calls against Ollama work reliably in CocoIndex.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1227&quot;&gt;#1227&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Operations &amp;amp; monitoring&lt;/h2&gt;
&lt;p&gt;CocoIndex now exposes basic server health checks and clearer runtime progress reporting.&lt;/p&gt;
&lt;h3&gt;HTTP server &amp;amp; health checks&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A new &lt;strong&gt;&lt;code&gt;/healthz&lt;/code&gt;&lt;/strong&gt; endpoint on the CocoIndex HTTP server returns a lightweight JSON payload with status and version, making it easy to plug the server into Kubernetes, load balancers, and external monitoring.&lt;/li&gt;
&lt;li&gt;Documentation has been added for the HTTP server, including how to start it via CLI or Python and how to wire it up with CocoInsight or custom frontends.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1270&quot;&gt;#1270&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1271&quot;&gt;#1271&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Statistics &amp;amp; progress reporting&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Processing commands now report elapsed time, so you can quickly see how long an update or run took.&lt;/li&gt;
&lt;li&gt;Progress reporting has been reworked: there is a consolidated, clearer progress bar, continuous stats updates during batch operations, and better handling of errors so counts and statuses remain accurate throughout the run.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1204&quot;&gt;#1204&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1223&quot;&gt;#1223&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1231&quot;&gt;#1231&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1240&quot;&gt;#1240&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1247&quot;&gt;#1247&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;CLI simplification&lt;/h2&gt;
&lt;p&gt;CocoIndex simplifies the CLI around flow setup by making setup the default and deprecating the extra flag.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;--setup&lt;/code&gt; flag on &lt;code&gt;cocoindex update&lt;/code&gt; and &lt;code&gt;cocoindex server&lt;/code&gt; is now enabled by default and marked as deprecated, since these commands will automatically perform any required setup before running.&lt;/li&gt;
&lt;li&gt;When &lt;code&gt;--setup&lt;/code&gt; is used explicitly, the CLI shows a deprecation warning, and docs have been updated to remove references to manually passing this flag in common workflows.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Changes:&lt;/em&gt; &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1212&quot;&gt;#1212&lt;/a&gt;, &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1237&quot;&gt;#1237&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Claude skills integration&lt;/h2&gt;
&lt;p&gt;CocoIndex now ships a dedicated Claude Code skill so developers can work with CocoIndex flows directly from their IDE-like Claude environment.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A CocoIndex Claude skill has been introduced and moved into its own &lt;code&gt;cocoindex-claude&lt;/code&gt; repository, giving Claude richer, structured knowledge of CocoIndex concepts, data types, and workflows.&lt;/li&gt;
&lt;li&gt;Documentation was added showing how to install and enable the CocoIndex skill in Claude Code, so Claude can help scaffold flows, edit pipelines, and navigate CocoIndex projects more effectively.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;New tutorials&lt;/h2&gt;
&lt;h3&gt;Index PDF elements&lt;/h3&gt;
&lt;p&gt;PDFs are rich with both text and visual content, from descriptive paragraphs to illustrations and tables. This example builds an end-to-end flow that parses, embeds, and indexes both, with full traceability to the original page.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://cocoindex.io/blogs/pdf-elements&quot;&gt;blog&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h3&gt;Extracting intake forms with BAML and CocoIndex&lt;/h3&gt;
&lt;p&gt;This tutorial shows how to use BAML together with CocoIndex to build a data pipeline that extracts structured patient information from PDF intake forms. The BAML definitions describe the desired output schema and prompt logic, while CocoIndex orchestrates file input, transformation, and incremental indexing.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://cocoindex.io/blogs/extraction-baml&quot;&gt;blog&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h2&gt;Special edition&lt;/h2&gt;
&lt;p&gt;CocoIndex recently hit &lt;strong&gt;3K GitHub stars&lt;/strong&gt; and became the &lt;strong&gt;#1 trending Rust repo globally&lt;/strong&gt;!&lt;/p&gt;
&lt;p&gt;🎉 We shared an article reflecting on why we built CocoIndex, the journey so far, and some fresh thoughts:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/blogs/data-for-ai&quot;&gt;Data for AI (CocoIndex Blog)&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;For a look back at when we reached 1K stars, see:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/blogs/cocoindex-1k&quot;&gt;CocoIndex at 1K (Reflection)&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We&apos;re just getting started and can&apos;t wait to see what comes next on our way to &lt;strong&gt;5K stars&lt;/strong&gt;!&lt;/p&gt;
&lt;h2&gt;Thanks to the community&lt;/h2&gt;
&lt;p&gt;Welcome new contributors to the CocoIndex community! We are so excited to have you!&lt;/p&gt;
&lt;h3&gt;@Haleshot&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Haleshot&quot;&gt;@Haleshot&lt;/a&gt; for making &lt;code&gt;additionalProperties&lt;/code&gt; configurable in JSON schemas in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1312&quot;&gt;#1312&lt;/a&gt;, improving compatibility with Gemini while keeping richer schema support for other providers, and for improving the README note for better GitHub rendering in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1288&quot;&gt;#1288&lt;/a&gt;, making the docs clearer and more readable.&lt;/p&gt;
&lt;h3&gt;@Gohlub&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/Gohlub&quot;&gt;@Gohlub&lt;/a&gt; for adding &lt;code&gt;with_context&lt;/code&gt; to user‑configured operations in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1275&quot;&gt;#1275&lt;/a&gt;, enabling more powerful context‑aware flows, and for adding &lt;code&gt;included_patterns&lt;/code&gt; and &lt;code&gt;excluded_patterns&lt;/code&gt; to the GoogleDrive source in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1263&quot;&gt;#1263&lt;/a&gt;, making it easier to control which files are indexed.&lt;/p&gt;
&lt;h3&gt;@dcbark01&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/dcbark01&quot;&gt;@dcbark01&lt;/a&gt; for adding the &lt;code&gt;force_path_style&lt;/code&gt; parameter to S3 config in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1290&quot;&gt;#1290&lt;/a&gt;, improving support for S3‑compatible storage.&lt;/p&gt;
&lt;h3&gt;@prabhath004&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/prabhath004&quot;&gt;@prabhath004&lt;/a&gt; for adding &lt;code&gt;max_file_size&lt;/code&gt; support to the AzureBlob source in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1259&quot;&gt;#1259&lt;/a&gt;, LocalFile source in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1260&quot;&gt;#1260&lt;/a&gt;, and AmazonS3 source in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1257&quot;&gt;#1257&lt;/a&gt;, making large‑file handling safer and more configurable.&lt;/p&gt;
&lt;h3&gt;@xuzijan&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/xuzijan&quot;&gt;@xuzijan&lt;/a&gt; for adding a README with CocoIndex project examples in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1273&quot;&gt;#1273&lt;/a&gt;, giving users concrete starting points for building projects.&lt;/p&gt;
&lt;h3&gt;@AdwitaSingh1711&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/AdwitaSingh1711&quot;&gt;@AdwitaSingh1711&lt;/a&gt; for adding function timeout support in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1241&quot;&gt;#1241&lt;/a&gt;, preventing long‑running functions from blocking pipelines.&lt;/p&gt;
&lt;h3&gt;@skalwaghe-56&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/skalwaghe-56&quot;&gt;@skalwaghe-56&lt;/a&gt; for teaching the collector to automatically merge and align multiple &lt;code&gt;collect()&lt;/code&gt; calls with different schemas in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1153&quot;&gt;#1153&lt;/a&gt;, simplifying complex aggregation flows.&lt;/p&gt;
&lt;h3&gt;@ansu86d&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/ansu86d&quot;&gt;@ansu86d&lt;/a&gt; for enhancing &lt;code&gt;UpdateStats&lt;/code&gt; with a progress bar and better error handling in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1223&quot;&gt;#1223&lt;/a&gt;, making batch updates more transparent.&lt;/p&gt;
&lt;h3&gt;@samojavo&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/samojavo&quot;&gt;@samojavo&lt;/a&gt; for guarding sync APIs inside async contexts in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1224&quot;&gt;#1224&lt;/a&gt;, improving runtime safety.&lt;/p&gt;
&lt;h3&gt;@CAPsMANyo&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/CAPsMANyo&quot;&gt;@CAPsMANyo&lt;/a&gt; for correctly parsing nested embeddings arrays from the Ollama &lt;code&gt;/api/embed&lt;/code&gt; endpoint in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1227&quot;&gt;#1227&lt;/a&gt;, ensuring robust embedding ingestion.&lt;/p&gt;
&lt;h3&gt;@belloibrahv&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/belloibrahv&quot;&gt;@belloibrahv&lt;/a&gt; for adding Redis queue support for S3 event notifications in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1189&quot;&gt;#1189&lt;/a&gt; and improving CLI &lt;code&gt;--help&lt;/code&gt;/Markdown docstring formatting in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1210&quot;&gt;#1210&lt;/a&gt;, making event‑driven updates and CLI usage smoother.&lt;/p&gt;
&lt;h3&gt;@GooglyBlox&lt;/h3&gt;
&lt;p&gt;Thanks &lt;a href=&quot;https://github.com/GooglyBlox&quot;&gt;@GooglyBlox&lt;/a&gt; for deprecating the &lt;code&gt;setup&lt;/code&gt; flag in &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1212&quot;&gt;#1212&lt;/a&gt;, simplifying the CLI surface and guiding users toward the preferred setup flow.&lt;/p&gt;
</content:encoded><category>changelog</category><category>feature</category><category>performance</category><category>custom-source</category><category>connectors</category><author>George He</author></item><item><title>Extract HackerNews into Postgres with a custom source</title><link>https://cocoindex.io/blogs/custom-source-hackernews/</link><guid isPermaLink="true">https://cocoindex.io/blogs/custom-source-hackernews/</guid><description>Build a custom CocoIndex source for the HackerNews API: fetch threads and comments in async Python, extract topics with an LLM, keep Postgres in sync.</description><pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/blogs/custom-source&quot;&gt;Custom sources&lt;/a&gt; are one of the most useful ideas in CocoIndex. They let you turn any API, internal or external, into a first-class incremental data stream that the framework can diff, track, and sync.&lt;/p&gt;
&lt;p&gt;Think of it as React for data flows: you declare what your target should look like as a function of the source, and CocoIndex handles &lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental updates&lt;/a&gt;, state persistence, and downstream sync. In CocoIndex v1 this needs no special connector API. A custom source is ordinary async Python that fetches data, plus &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/processing_component/&quot;&gt;processing components&lt;/a&gt; that declare the rows that should exist downstream.&lt;/p&gt;
&lt;p&gt;This post walks through a custom source for HackerNews built that way. It fetches recent stories and their nested comments through the &lt;a href=&quot;https://hn.algolia.com/api&quot;&gt;Algolia HN API&lt;/a&gt;, extracts topics from every message with an LLM, and keeps two &lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;Postgres&lt;/a&gt; tables in sync for search and analytics.&lt;/p&gt;
&lt;p&gt;The project is open-sourced in the &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/hn_trending_topics&quot;&gt;CocoIndex v1 repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Why use a custom source?&lt;/h2&gt;
&lt;p&gt;In many scenarios, pipelines don&apos;t just read from clean tables. They depend on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Internal REST services&lt;/li&gt;
&lt;li&gt;Partner APIs&lt;/li&gt;
&lt;li&gt;Legacy systems&lt;/li&gt;
&lt;li&gt;Non-standard data models that don&apos;t fit traditional connectors&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CocoIndex makes these integrations declarative and incremental without a plugin interface. You fetch data with plain &lt;code&gt;async def&lt;/code&gt; functions, each item runs as its own processing component, and the component declares the target rows it owns. The engine takes it from there: change detection, syncing to the target, and cleanup when an item disappears from the source.&lt;/p&gt;
&lt;h2&gt;Project walkthrough: building a HackerNews index&lt;/h2&gt;
&lt;h3&gt;Goals&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Call the HackerNews search API&lt;/li&gt;
&lt;li&gt;Fetch nested comments for each story&lt;/li&gt;
&lt;li&gt;Extract topics from every message with an LLM&lt;/li&gt;
&lt;li&gt;Store messages and topics in Postgres&lt;/li&gt;
&lt;li&gt;Search the index by topic&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CocoIndex handles change detection, idempotency, and state sync automatically.&lt;/p&gt;
&lt;h3&gt;Overview&lt;/h3&gt;
&lt;p&gt;The pipeline consists of three parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Two async functions over the Algolia HN API
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fetch_thread_list&lt;/code&gt; lists recent story IDs&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetch_thread&lt;/code&gt; pulls one story with its full comment tree&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;One processing component per thread
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;process_thread&lt;/code&gt; extracts topics from the story and every comment&lt;/li&gt;
&lt;li&gt;Declares rows into two Postgres tables (&lt;code&gt;hn_messages&lt;/code&gt;, &lt;code&gt;hn_topics&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Query utilities
&lt;ul&gt;
&lt;li&gt;Rank trending topics in SQL&lt;/li&gt;
&lt;li&gt;Search messages by topic&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each &lt;code&gt;cocoindex update&lt;/code&gt; catches up on the latest stories and only does the work it hasn&apos;t seen before.&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;Postgres&lt;/a&gt; running locally. The example repo ships a compose file: &lt;code&gt;docker compose -f dev/postgres.yaml up -d&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An LLM API key. The default model is &lt;code&gt;gemini/gemini-2.5-flash&lt;/code&gt; through &lt;a href=&quot;https://cocoindex.io/docs/ops/litellm/&quot;&gt;litellm&lt;/a&gt;; set &lt;code&gt;GEMINI_API_KEY&lt;/code&gt;, or set &lt;code&gt;LLM_MODEL&lt;/code&gt; to any litellm model id and the matching provider key.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Define the data models&lt;/h2&gt;
&lt;p&gt;Everything the pipeline handles is a plain dataclass. &lt;code&gt;Thread&lt;/code&gt; and &lt;code&gt;Comment&lt;/code&gt; model what we scrape from the API:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class Comment:
    id: str
    author: str | None
    text: str | None
    created_at: datetime | None

@dataclass
class Thread:
    id: str
    author: str | None
    text: str
    url: str | None
    created_at: datetime | None
    comments: list[Comment]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each source item is one thread: a story plus all of its comments, flattened out of HackerNews&apos; nested reply tree into a single list.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HnMessage&lt;/code&gt; and &lt;code&gt;HnTopic&lt;/code&gt; are the schemas of the two target tables:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class HnMessage:
    &quot;&quot;&quot;Schema for hn_messages table.&quot;&quot;&quot;

    id: str
    thread_id: str
    content_type: str
    author: str | None
    text: str | None
    url: str | None
    created_at: datetime | None

@dataclass
class HnTopic:
    &quot;&quot;&quot;Schema for hn_topics table.&quot;&quot;&quot;

    topic: str
    message_id: str
    thread_id: str
    content_type: str
    created_at: datetime | None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A thread and each of its comments both become an &lt;code&gt;HnMessage&lt;/code&gt; row, distinguished by &lt;code&gt;content_type&lt;/code&gt;. Every extracted topic becomes an &lt;code&gt;HnTopic&lt;/code&gt; row keyed on &lt;code&gt;(topic, message_id)&lt;/code&gt;, so the same topic mentioned in many messages shows up many times, which is exactly what makes ranking meaningful.&lt;/p&gt;
&lt;p&gt;Primary keys are the identity of a row, the same role the key type played for a v0 custom source: they must be stable over time so re-runs reconcile rows in place instead of duplicating them.&lt;/p&gt;
&lt;h2&gt;Fetch from the HackerNews API&lt;/h2&gt;
&lt;p&gt;The source itself is two small async functions over the Algolia HN API. No decorators, no connector class.&lt;/p&gt;
&lt;h3&gt;Listing recent threads&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fetch_thread_list&lt;/code&gt; returns the most recent story IDs. CocoIndex uses this list to know which threads exist right now:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def fetch_thread_list(
    session: aiohttp.ClientSession, max_results: int = MAX_THREADS
) -&amp;gt; list[str]:
    &quot;&quot;&quot;Fetch list of recent thread IDs from HackerNews.&quot;&quot;&quot;
    search_url = &quot;https://hn.algolia.com/api/v1/search_by_date&quot;
    params: dict[str, str | int] = {&quot;tags&quot;: &quot;story&quot;, &quot;hitsPerPage&quot;: max_results}

    async with session.get(search_url, params=params) as response:
        response.raise_for_status()
        data = await response.json()
        thread_ids = [
            hit[&quot;objectID&quot;] for hit in data.get(&quot;hits&quot;, []) if hit.get(&quot;objectID&quot;)
        ]
        return thread_ids
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Fetching a full thread&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fetch_thread&lt;/code&gt; pulls one story with all its comments and parses the raw JSON into the structured &lt;code&gt;Thread&lt;/code&gt;. The inner &lt;code&gt;parse_comments&lt;/code&gt; walks the reply tree recursively and flattens it; the story title and body are combined into the thread text:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def fetch_thread(session: aiohttp.ClientSession, thread_id: str) -&amp;gt; Thread:
    &quot;&quot;&quot;Fetch a single thread with all its comments.&quot;&quot;&quot;
    item_url = f&quot;https://hn.algolia.com/api/v1/items/{thread_id}&quot;

    async with session.get(item_url) as response:
        response.raise_for_status()
        data = await response.json()

        comments: list[Comment] = []

        # Parse comments recursively
        def parse_comments(parent: dict[str, Any]) -&amp;gt; None:
            for child in parent.get(&quot;children&quot;, []):
                if comment_id := child.get(&quot;id&quot;):
                    ctime = child.get(&quot;created_at&quot;)
                    comments.append(
                        Comment(
                            id=str(comment_id),
                            author=child.get(&quot;author&quot;),
                            text=child.get(&quot;text&quot;),
                            created_at=datetime.fromisoformat(ctime) if ctime else None,
                        )
                    )
                parse_comments(child)

        parse_comments(data)

        ctime = data.get(&quot;created_at&quot;)
        text = data.get(&quot;title&quot;, &quot;&quot;)
        if more_text := data.get(&quot;text&quot;):
            text += &quot;\n\n&quot; + more_text

        return Thread(
            id=thread_id,
            author=data.get(&quot;author&quot;),
            text=text,
            url=data.get(&quot;url&quot;),
            created_at=datetime.fromisoformat(ctime) if ctime else None,
            comments=comments,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are ordinary &lt;code&gt;async def&lt;/code&gt; functions. Any HTTP API, message queue, or third-party SDK can be a source the same way: fetch the data in plain Python and hand it to the pipeline.&lt;/p&gt;
&lt;h2&gt;Extract topics with an LLM&lt;/h2&gt;
&lt;p&gt;The core transformation is a single &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;CocoIndex function&lt;/a&gt;: text in, a list of topics out. The response schema is a small Pydantic model, so the LLM returns structured data instead of free-form text:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class TopicsResponse(BaseModel):
    &quot;&quot;&quot;Response containing a list of extracted topics.&quot;&quot;&quot;

    topics: list[str] = Field(
        description=&quot;&quot;&quot;List of extracted topics.

Each topic can be a product name, technology, model, people, company name, business domain, etc.
Capitalize for proper nouns and acronyms only.
Use the form that is clear alone.
...&quot;&quot;&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def extract_topics(text: str | None) -&amp;gt; list[str]:
    &quot;&quot;&quot;Extract topics from text using LLM.&quot;&quot;&quot;
    if not text or not text.strip():
        return []

    response = await acompletion(
        model=LLM_MODEL,
        messages=[
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: f&quot;Extract topics from the following text:\n\n{text[:4000]}&quot;,
            }
        ],
        response_format=TopicsResponse,
    )

    content = response.choices[0].message.content
    return TopicsResponse.model_validate_json(content).topics
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The prompt in &lt;code&gt;TopicsResponse&lt;/code&gt; does the heavy lifting: it normalizes phrases into separate topics (&quot;books for autistic kids&quot; → &quot;book&quot;, &quot;autistic&quot;, &quot;autistic kids&quot;), keeps proper nouns canonical (&quot;PostgreSQL&quot;, &quot;Claude&quot;), and emits aliases for the same thing (&quot;JFK&quot;, &quot;John Kennedy&quot;).&lt;/p&gt;
&lt;h2&gt;Process each thread&lt;/h2&gt;
&lt;p&gt;Each thread runs as its own processing component. &lt;code&gt;process_thread&lt;/code&gt; fetches the thread, extracts topics for the story and every comment, and declares the resulting rows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@coco.fn
async def process_thread(
    thread_id: str,
    targets: TableTargets,
) -&amp;gt; None:
    &quot;&quot;&quot;Fetch and process a single thread and its comments.&quot;&quot;&quot;
    async with aiohttp.ClientSession() as session:
        thread = await fetch_thread(session, thread_id)
    thread_topics = await extract_topics(thread.text)

    # Declare thread message row
    targets.messages.declare_row(
        row=HnMessage(
            id=thread.id,
            thread_id=thread.id,
            content_type=&quot;thread&quot;,
            author=thread.author,
            text=thread.text,
            url=thread.url,
            created_at=thread.created_at,
        ),
    )
    # Declare thread topic rows
    for topic in thread_topics:
        targets.topics.declare_row(
            row=HnTopic(
                topic=topic,
                message_id=thread.id,
                thread_id=thread.id,
                content_type=&quot;thread&quot;,
                created_at=thread.created_at,
            ),
        )
    # Process comments
    for comment in thread.comments:
        comment_topics = await extract_topics(comment.text)

        # Declare comment message row
        targets.messages.declare_row(
            row=HnMessage(
                id=comment.id,
                thread_id=thread.id,
                content_type=&quot;comment&quot;,
                author=comment.author,
                text=comment.text,
                url=&quot;&quot;,
                created_at=comment.created_at,
            ),
        )
        # Declare comment topic rows
        for topic in comment_topics:
            targets.topics.declare_row(
                row=HnTopic(
                    topic=topic,
                    message_id=comment.id,
                    thread_id=thread.id,
                    content_type=&quot;comment&quot;,
                    created_at=comment.created_at,
                ),
            )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You declare what rows should exist; you don&apos;t write inserts or deletes. When the component finishes, CocoIndex diffs the declared rows against the previous run at the same component path and applies only the creates, updates, and deletes needed. If a thread drops out of the feed, the rows it owned are cleaned up automatically.&lt;/p&gt;
&lt;p&gt;Why a component per thread? A processing component groups one thread&apos;s work together with the target rows it owns. Each component runs independently and in parallel, and its rows are committed to Postgres as soon as that thread is done, without waiting for the rest of the batch.&lt;/p&gt;
&lt;h2&gt;Wire up the app&lt;/h2&gt;
&lt;p&gt;The Postgres pool is provided once in the app lifespan and referenced by a context key:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PG_DB = coco.ContextKey[asyncpg.Pool](&quot;hn_db&quot;)

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -&amp;gt; AsyncIterator[None]:
    builder.settings.db_path = pathlib.Path(&quot;./cocoindex.db&quot;)
    async with asyncpg.create_pool(DATABASE_URL) as pool:
        builder.provide(PG_DB, pool)
        yield
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;app_main&lt;/code&gt; mounts the two table targets, fetches the recent thread IDs, and fans out one &lt;code&gt;process_thread&lt;/code&gt; component per thread with &lt;code&gt;coco.mount_each&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class TableTargets:
    &quot;&quot;&quot;Container for table targets.&quot;&quot;&quot;

    messages: postgres.TableTarget[HnMessage]
    topics: postgres.TableTarget[HnTopic]

@coco.fn
async def app_main() -&amp;gt; None:
    &quot;&quot;&quot;Main pipeline function.&quot;&quot;&quot;
    # Set up table targets
    messages_table = await postgres.mount_table_target(
        PG_DB,
        table_name=&quot;hn_messages&quot;,
        table_schema=await postgres.TableSchema.from_class(
            HnMessage, primary_key=[&quot;id&quot;]
        ),
        pg_schema_name=&quot;coco_examples&quot;,
    )
    topics_table = await postgres.mount_table_target(
        PG_DB,
        table_name=&quot;hn_topics&quot;,
        table_schema=await postgres.TableSchema.from_class(
            HnTopic, primary_key=[&quot;topic&quot;, &quot;message_id&quot;]
        ),
        pg_schema_name=&quot;coco_examples&quot;,
    )
    targets = TableTargets(messages=messages_table, topics=topics_table)

    # Fetch thread IDs from HackerNews
    async with aiohttp.ClientSession() as session:
        thread_ids = await fetch_thread_list(session)

    # Process threads (each component fetches its own thread data)
    await coco.mount_each(process_thread, ((tid, tid) for tid in thread_ids), targets)

app = coco.App(
    coco.AppConfig(name=&quot;HNTrendingTopics&quot;),
    app_main,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mount_each&lt;/code&gt; takes one &lt;code&gt;(component_key, *args)&lt;/code&gt; tuple per item, so each thread gets a stable component path keyed on its ID. The &lt;code&gt;TableSchema.from_class&lt;/code&gt; calls derive the SQL columns straight from the dataclasses. That&apos;s the whole &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/app/&quot;&gt;app&lt;/a&gt;: plain Python on top, the Rust engine handling incremental sync underneath.&lt;/p&gt;
&lt;h2&gt;Query and search the index&lt;/h2&gt;
&lt;p&gt;The index is two plain Postgres tables, so you can query them with any library or framework. The example ships two query helpers over asyncpg. Trending topics are ranked in SQL, weighting a thread-level mention higher than a comment-level one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
    topic,
    SUM(CASE WHEN content_type = &apos;thread&apos; THEN 5 ELSE 1 END) AS score,
    MAX(created_at) AS latest_mention,
    COUNT(DISTINCT thread_id) AS thread_count
FROM coco_examples.hn_topics
GROUP BY topic
ORDER BY score DESC, latest_mention DESC
LIMIT 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And topic search joins the two tables to return the underlying messages:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT m.id, m.thread_id, m.author, m.content_type, m.text, m.created_at, t.topic
FROM coco_examples.hn_topics t
JOIN coco_examples.hn_messages m ON t.message_id = m.id
WHERE LOWER(t.topic) LIKE LOWER($1)
ORDER BY m.created_at DESC
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Run it&lt;/h2&gt;
&lt;p&gt;Install the project in editable mode from the example directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -e .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then build the index:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This fetches the most recent &lt;code&gt;MAX_THREADS&lt;/code&gt; (default 10) stories, runs LLM topic extraction on every message, and writes &lt;code&gt;coco_examples.hn_messages&lt;/code&gt; and &lt;code&gt;coco_examples.hn_topics&lt;/code&gt;. This source is a one-shot catch-up per run: each &lt;code&gt;cocoindex update&lt;/code&gt; syncs the tables to the current feed.&lt;/p&gt;
&lt;p&gt;Re-running is where the incremental model pays off:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New threads in the feed get processed; threads already in the database reuse their committed rows, so the expensive LLM calls only run for content CocoIndex hasn&apos;t seen.&lt;/li&gt;
&lt;li&gt;Change the extraction prompt or switch &lt;code&gt;LLM_MODEL&lt;/code&gt;, and the next update re-extracts to keep &lt;code&gt;hn_topics&lt;/code&gt; consistent with the new logic. No manual migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Explore the results from the command line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python main.py            # top 20 trending topics + interactive search
python main.py &quot;rust&quot;     # search content for one topic
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What you can build next&lt;/h2&gt;
&lt;p&gt;This example opens the door to a lot more:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Run LLM summarization on top of indexed threads&lt;/li&gt;
&lt;li&gt;Add embeddings and vector search&lt;/li&gt;
&lt;li&gt;Mirror HN into your internal data warehouse&lt;/li&gt;
&lt;li&gt;Build a real-time HN dashboard&lt;/li&gt;
&lt;li&gt;Extend to other news sources (Reddit, Lobsters, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the whole pipeline is declarative and incremental, extending it is a matter of declaring more rows.&lt;/p&gt;
&lt;p&gt;Since a custom source is just Python code that fetches data, the best use cases are usually &quot;hard-to-reach&quot; data: systems that don&apos;t have standard database connectors, have complex nesting, or require heavy pre-processing.&lt;/p&gt;
&lt;h3&gt;The knowledge aggregator for LLM context&lt;/h3&gt;
&lt;p&gt;Building a context engine for an AI bot often requires pulling from non-standard documentation sources: wikis, ticket systems, internal APIs. Each becomes a fetch function and a component, and the index stays current as the sources change.&lt;/p&gt;
&lt;h3&gt;The composite entity (data stitching)&lt;/h3&gt;
&lt;p&gt;Most companies have user data fragmented across multiple microservices. A custom source can act as a virtual join before the data ever hits your index. For example, a component:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fetches a user ID from an auth service (Okta, Auth0)&lt;/li&gt;
&lt;li&gt;Uses that ID to fetch billing status from the Stripe API&lt;/li&gt;
&lt;li&gt;Uses that ID to fetch usage logs from an internal Redis&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Instead of managing ETL joins downstream, the component declares a single &lt;code&gt;User360&lt;/code&gt; row. CocoIndex tracks the state of that composite object; if the user upgrades in Stripe or changes their email in Auth0, the index updates automatically.&lt;/p&gt;
&lt;h3&gt;The legacy wrapper (modernization layer)&lt;/h3&gt;
&lt;p&gt;Enterprises often have valuable data locked in systems that are painful to query (SOAP, XML, mainframes). Wrap the legacy read path in a fetch function and you get a modern, queryable SQL interface via the CocoIndex target, without rewriting the legacy system itself.&lt;/p&gt;
&lt;h3&gt;Public data monitor (competitive intelligence)&lt;/h3&gt;
&lt;p&gt;Tracking changes on public websites or APIs that don&apos;t offer webhooks: competitor pricing pages, regulatory feeds, market data APIs. Because CocoIndex diffs declared rows against the previous run, downstream tables only change when the data actually changes, rather than filling up with identical polling results.&lt;/p&gt;
&lt;h2&gt;Why this matters&lt;/h2&gt;
&lt;p&gt;Custom sources extend the same declarative model to any API: internal, external, legacy, or real-time.&lt;/p&gt;
&lt;p&gt;This unlocks a simple pattern:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you can fetch it, CocoIndex can index it, diff it, and sync it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Whether you&apos;re indexing HackerNews or stitching together dozens of enterprise services, the framework gives you a stable backbone: persistent state, deterministic incremental updates, automatic cleanup, and flexible target exports with minimal infrastructure overhead.&lt;/p&gt;
&lt;h2&gt;Support CocoIndex ❤️&lt;/h2&gt;
&lt;p&gt;If this example was helpful, the easiest way to support CocoIndex is to &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;give the project a ⭐ on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Your stars help us grow the community, stay motivated, and keep shipping better tools for real-time data ingestion and transformation.&lt;/p&gt;
</content:encoded><category>examples</category><category>custom-source</category><category>postgres</category><category>llm</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>Extracting Intake Forms with BAML and CocoIndex</title><link>https://cocoindex.io/blogs/extraction-baml/</link><guid isPermaLink="true">https://cocoindex.io/blogs/extraction-baml/</guid><description>How to use BAML and CocoIndex to extract structured data from patient intake forms in PDF/Word with LLMs continuously for production.</description><pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This tutorial shows how to use &lt;strong&gt;BAML&lt;/strong&gt; together with &lt;strong&gt;CocoIndex&lt;/strong&gt; to build a data pipeline that extracts &lt;a href=&quot;https://cocoindex.io/docs/ops/litellm/&quot;&gt;structured information with an LLM&lt;/a&gt; from PDF intake forms.  The BAML definitions describe the desired output schema and prompt logic, while CocoIndex orchestrates file input, transformation, and incremental indexing.&lt;/p&gt;
&lt;p&gt;We’ll walk through setup, defining the BAML schema, generating the Python client, writing the CocoIndex flow, and running the pipeline. Throughout, we follow best practices (e.g. caching heavy steps) and cite documentation for key concepts.&lt;/p&gt;
&lt;p&gt;The full project is open sourced &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/tree/main/examples/patient_intake_extraction_baml&quot;&gt;here&lt;/a&gt; ⭐. To see more examples built with CocoIndex, you could refer to the &lt;a href=&quot;https://cocoindex.io/examples&quot;&gt;examples&lt;/a&gt; page.&lt;/p&gt;
&lt;h2&gt;BAML&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/BoundaryML/baml&quot;&gt;BAML&lt;/a&gt;&lt;/strong&gt;, created by BoundaryML, is a typed prompt engineering language that makes LLM workflows predictable, testable, and production-safe. Instead of treating prompts as fragile strings, BAML lets developers define clear input parameters, output schemas, and model configurations, transforming prompts into strongly typed functions.&lt;/p&gt;
&lt;h2&gt;CocoIndex&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/cocoindex-io/cocoindex&quot;&gt;CocoIndex&lt;/a&gt;&lt;/strong&gt; is a unified data processing engine built for AI-native applications. It lets you define transformations in one declarative workflow, then keeps everything continuously up to date with real-time, &lt;a href=&quot;https://cocoindex.io/blogs/incremental-processing&quot;&gt;incremental processing&lt;/a&gt;. Designed for reliability and scale, CocoIndex ensures that every derived artifact (embeddings, metadata, extractions, models) always reflects the latest source data, making it the foundation for fast, consistent RAG, analytics, and automation pipelines.&lt;/p&gt;
&lt;h2&gt;Flow overview&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Read PDF files from a directory.&lt;/li&gt;
&lt;li&gt;For each file, call the BAML function to get a structured &lt;code&gt;Patient&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Collect results and export to Postgres.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://cocoindex.io/docs/getting_started/installation/&quot;&gt;Install Postgres&lt;/a&gt; if you don&apos;t have one.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Install dependencies&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install -U cocoindex baml-py
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file. You can copy it from &lt;code&gt;.env.example&lt;/code&gt; first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cp .env.example .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then edit the file to fill in your &lt;code&gt;GEMINI_API_KEY&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Structured extraction component with BAML&lt;/h2&gt;
&lt;p&gt;Create a &lt;code&gt;baml_src/&lt;/code&gt; directory for your BAML definitions. We’ll define a schema for patient intake data (nested classes) and a function that prompts Gemini to extract those fields from a PDF. Save this as &lt;code&gt;baml_src/patient.baml&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;Define patient schema&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Classes&lt;/strong&gt;: We defined Pydantic-style classes (&lt;code&gt;Contact&lt;/code&gt;, &lt;code&gt;Address&lt;/code&gt;, &lt;code&gt;Insurance&lt;/code&gt;, etc.) to match the FHIR-inspired patient schema. These become typed output models. Required fields are non-nullable; optional fields use &lt;code&gt;?&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Contact {
  name string
  phone string
  relationship string
}

class Address {
  street string
  city string
  state string
  zip_code string
}

class Pharmacy {
  name string
  phone string
  address Address
}

class Insurance {
  provider string
  policy_number string
  group_number string?
  policyholder_name string
  relationship_to_patient string
}

class Condition {
  name string
  diagnosed bool
}

class Medication {
  name string
  dosage string
}

class Allergy {
  name string
}

class Surgery {
  name string
  date string
}

class Patient {
  name string
  dob string
  gender string
  address Address
  phone string
  email string
  preferred_contact_method string
  emergency_contact Contact
  insurance Insurance?
  reason_for_visit string
  symptoms_duration string
  past_conditions Condition[]
  current_medications Medication[]
  allergies Allergy[]
  surgeries Surgery[]
  occupation string?
  pharmacy Pharmacy?
  consent_given bool
  consent_date string?
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Define the BAML function to extract patient info from a PDF&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;function ExtractPatientInfo(intake_form: pdf) -&amp;gt; Patient {
  client Gemini
  prompt #&quot;
    Extract all patient information from the following intake form document.
    Please be thorough and extract all available information accurately.
    {{ _.role(&quot;user&quot;) }}
    {{ intake_form }}

    Fill in with &quot;N/A&quot; for required fields if the information is not available.

    {{ ctx.output_format }}
  &quot;#
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We specify &lt;code&gt;client Gemini&lt;/code&gt; and a prompt template. The special variable &lt;code&gt;{{ intake_form }}&lt;/code&gt; injects the PDF, and &lt;code&gt;{{ ctx.output_format }}&lt;/code&gt; tells BAML to expect the structured format defined by the return type. The prompt explicitly asks Gemini to extract all fields, filling “N/A” if missing.&lt;/p&gt;
&lt;p&gt;:::tip[BAML PDF Extraction: Crucial Prompt Role Gotcha]
When using BAML to extract structured data (like a Patient record) from PDFs, it is absolutely critical to ensure the PDF content is injected as part of the user message in the prompt.
Specifically, you need to include &lt;code&gt;{{ _.role(&quot;user&quot;) }}&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; you insert your file data with &lt;code&gt;{{ intake_form }}&lt;/code&gt;:
:::&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why role(&quot;user&quot;) matters&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;OpenAI models (e.g., GPT-4, GPT-4o)&lt;/strong&gt;, if the file&apos;s content is &lt;em&gt;not&lt;/em&gt; presented in the user message, the model won&apos;t &quot;see&quot; the PDF at all; your extraction will fail or be empty.&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;Gemini and Anthropic&lt;/strong&gt;, it&apos;s more forgiving and can sometimes work anyway, which makes this confusing to debug across providers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We only discovered this after a &lt;a href=&quot;https://github.com/BoundaryML/baml/issues/2737&quot;&gt;discussion on the BAML repo&lt;/a&gt; and our own &lt;a href=&quot;https://github.com/cocoindex-io/cocoindex/pull/1315/files&quot;&gt;investigations&lt;/a&gt;.
If you skip the explicit &lt;code&gt;role(&quot;user&quot;)&lt;/code&gt;, you might waste hours debugging inconsistent extractions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt;&lt;br /&gt;
When building extraction flows with BAML, &lt;strong&gt;always set the role to &lt;code&gt;&quot;user&quot;&lt;/code&gt; before adding file content to your prompt&lt;/strong&gt;. That makes your workflow robust and portable across LLM providers.&lt;/p&gt;
&lt;p&gt;Thanks to Deepu and Prashanth from our Discord community for working with us on this issue. You can see a real-world debugging journey in our &lt;a href=&quot;https://discord.com/channels/1314801574169673738/1442051753863286794&quot;&gt;Discord thread&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Configure the LLM client to use Google’s Gemini model&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;client&amp;lt;llm&amp;gt; Gemini {
  provider google-ai
  options {
    model gemini-2.5-flash
    api_key env.GEMINI_API_KEY
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Configure BAML generator&lt;/h3&gt;
&lt;p&gt;In &lt;code&gt;baml_src&lt;/code&gt; folder add &lt;code&gt;generator.baml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;generator python_client {
  output_type python/pydantic
  output_dir &quot;../&quot;
  version &quot;0.213.0&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;generator&lt;/code&gt; block tells &lt;code&gt;baml-cli&lt;/code&gt; to create a Python client with Pydantic models in the parent directory.&lt;/p&gt;
&lt;p&gt;When we run &lt;code&gt;baml-cli generate&lt;/code&gt;, this will compile the &lt;code&gt;.baml&lt;/code&gt; definitions into a &lt;code&gt;baml_client/&lt;/code&gt; Python package in your project root. It contains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;baml_client/types.py&lt;/code&gt; with Pydantic classes (&lt;code&gt;Patient&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;baml_client/sync_client.py&lt;/code&gt; and &lt;code&gt;async_client.py&lt;/code&gt; with a callable &lt;code&gt;b&lt;/code&gt; object. For example, &lt;code&gt;b.ExtractPatientInfo(pdf)&lt;/code&gt; will return a &lt;code&gt;Patient&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Continuous data transformation flow with incremental processing&lt;/h2&gt;
&lt;p&gt;Next we will define a data transformation flow with CocoIndex. Once you declare the state and transformation logic,  CocoIndex will take care of all the state change for you from source to target.&lt;/p&gt;
&lt;h3&gt;CocoIndex flow&lt;/h3&gt;
&lt;h4&gt;Declare flow&lt;/h4&gt;
&lt;p&gt;Declare a CocoIndex flow, connect to the &lt;a href=&quot;https://cocoindex.io/docs/connectors/localfs/&quot;&gt;LocalFile source&lt;/a&gt;, add a data collector to collect processed data.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@cocoindex.flow_def(name=&quot;PatientIntakeExtractionBaml&quot;)
def patient_intake_extraction_flow(
    flow_builder: cocoindex.FlowBuilder, data_scope: cocoindex.DataScope
) -&amp;gt; None:
    data_scope[&quot;documents&quot;] = flow_builder.add_source(
        cocoindex.sources.LocalFile(
            path=os.path.join(&quot;data&quot;, &quot;patient_forms&quot;), binary=True
        )
    )
    
    patients_index = data_scope.add_collector()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This iterates over each document. We transform &lt;code&gt;doc[&quot;content&quot;]&lt;/code&gt; (the bytes) by our &lt;code&gt;extract_patient_info&lt;/code&gt; function. The result is stored in a new field &lt;code&gt;patient_info&lt;/code&gt;. Then we collect a row with the filename and extracted patient info.&lt;/p&gt;
&lt;h4&gt;Define a custom function to use BAML extraction to transform a PDF&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;@cocoindex.op.function(cache=True, behavior_version=1)
async def extract_patient_info(content: bytes) -&amp;gt; Patient:
    pdf = baml_py.Pdf.from_base64(base64.b64encode(content).decode(&quot;utf-8&quot;))
    return await b.ExtractPatientInfo(pdf)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;extract_patient_info&lt;/code&gt; function is a &lt;a href=&quot;https://cocoindex.io/docs/programming_guide/function/&quot;&gt;custom function&lt;/a&gt; decorated with &lt;code&gt;@cocoindex.op.function(cache=True, behavior_version=1)&lt;/code&gt;. Setting &lt;code&gt;cache=True&lt;/code&gt; causes CocoIndex to cache outputs of this function for incremental runs (so unchanged inputs skip rerunning the LLM). We increase &lt;code&gt;behavior_version&lt;/code&gt; (start at 1) so that any prompt or logic changes will force a refresh.&lt;/li&gt;
&lt;li&gt;Inside the function, we convert &lt;code&gt;bytes&lt;/code&gt; to a BAML &lt;code&gt;Pdf&lt;/code&gt; (via base64) and then call &lt;code&gt;await b.ExtractPatientInfo(pdf)&lt;/code&gt;. This returns a &lt;code&gt;Patient&lt;/code&gt; dataclass instance (mapped from the BAML output).&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Process each document&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Transform each doc with BAML&lt;/li&gt;
&lt;li&gt;Collect the structured output&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;with data_scope[&quot;documents&quot;].row() as doc:
    doc[&quot;patient_info&quot;] = doc[&quot;content&quot;].transform(extract_patient_info)

    patients_index.collect(
        filename=doc[&quot;filename&quot;],
        patient_info=doc[&quot;patient_info&quot;],
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is common to have heavily nested data, and CocoIndex is natively designed to handle heavily nested &lt;a href=&quot;https://cocoindex.io/docs/common_resources/data_types/&quot;&gt;data structures&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Export to Postgres&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;patients_index.export(
    &quot;patients&quot;,
    cocoindex.storages.Postgres(),
    primary_key_fields=[&quot;filename&quot;],
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We export the collected index to &lt;a href=&quot;https://cocoindex.io/docs/connectors/postgres/&quot;&gt;Postgres&lt;/a&gt;.  This will create/maintain a table &lt;code&gt;patients&lt;/code&gt; keyed by filename, automatically deleting or updating rows if inputs change. Because CocoIndex tracks &lt;a href=&quot;https://cocoindex.io/blogs/cocoinsight&quot;&gt;data lineage&lt;/a&gt;, it will handle updates/deletions of source files incrementally.&lt;/p&gt;
&lt;h2&gt;Running the pipeline&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Generate BAML client code&lt;/strong&gt; (required step, in case you didn’t do it earlier).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;baml generate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This generates the &lt;code&gt;baml_client/&lt;/code&gt; directory with Python code to call your BAML functions.&lt;/p&gt;
&lt;p&gt;Update the index:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cocoindex update main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;CocoInsight&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I used CocoInsight (Free beta now) to troubleshoot the index generation and understand the data lineage of the pipeline. It just connects to your local CocoIndex server, with zero pipeline data retention.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cocoindex server -ci main
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Composable by default: use the best components for your use case&lt;/h2&gt;
&lt;p&gt;While CocoIndex provides a rich set of building blocks for building LLM pipelines, it is fundamentally designed as an open system. Developers can bring in their preferred transformation components tailored to their domain, from document parsers to structured extractors like BAML.&lt;/p&gt;
&lt;p&gt;This flexibility enables deep composability with other open ecosystems. The synergy between CocoIndex and BAML highlights this philosophy: BAML brings powerful prompt-driven schema extraction, while CocoIndex orchestrates and maintains the flow at scale. There’s no lock-in: developers and enterprises experimenting at the frontier can adapt, extend, and integrate freely.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;By combining BAML and CocoIndex, we get a robust, schema-driven workflow: BAML ensures the prompt-to-schema mapping is correct and type-safe, while CocoIndex handles data ingestion, transformation, and incremental storage. This example extracted patient intake information (names, insurance, medications, etc.) from PDFs, but the pattern applies to any structured data extraction task.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For more details on CocoIndex flows, see its &lt;a href=&quot;https://cocoindex.io/docs/&quot;&gt;documentation&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;For BAML usage and prompt engineering, refer to the &lt;a href=&quot;https://docs.boundaryml.com/home&quot;&gt;BoundaryML docs&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>examples</category><category>tutorial</category><category>structured-extraction</category><category>llm</category><author>Linghua Jin</author></item><item><title>Adaptive Batching - 5x throughput on your data pipelines</title><link>https://cocoindex.io/blogs/batching/</link><guid isPermaLink="true">https://cocoindex.io/blogs/batching/</guid><description>CocoIndex now batches GPU and ML workloads automatically: 5x throughput on text embeddings and AI ops, with zero configuration required.</description><pubDate>Mon, 10 Nov 2025 00:00:00 GMT</pubDate><category>feature</category><category>performance</category><category>embeddings</category><category>best-practices</category><author>George He</author></item><item><title>AI-Native Data Pipeline - Why We Made It</title><link>https://cocoindex.io/blogs/data-for-ai/</link><guid isPermaLink="true">https://cocoindex.io/blogs/data-for-ai/</guid><description>Why the next wave of AI needs open-source, scalable, AI-native data infrastructure, and how CocoIndex is building the foundation for intelligent data pipelines.</description><pubDate>Wed, 29 Oct 2025 00:00:00 GMT</pubDate><category>insight</category><category>architecture</category><category>ai-agents</category><category>data-indexing</category><author>Linghua Jin</author></item><item><title>Index PDF elements with mixed embedding models</title><link>https://cocoindex.io/blogs/pdf-elements/</link><guid isPermaLink="true">https://cocoindex.io/blogs/pdf-elements/</guid><description>Extract, embed, and store multimodal PDF elements (text with SentenceTransformers, images with CLIP) for unified semantic search with traceable metadata.</description><pubDate>Mon, 27 Oct 2025 00:00:00 GMT</pubDate><category>examples</category><category>feature</category><category>multimodal</category><category>embeddings</category><category>vector-search</category><author>Linghua Jin</author></item><item><title>Bring your own data: Index any data with Custom Sources</title><link>https://cocoindex.io/blogs/custom-source/</link><guid isPermaLink="true">https://cocoindex.io/blogs/custom-source/</guid><description>Read data from any system with CocoIndex: in v1 a custom source is plain async Python plus processing components. No connector API to implement.</description><pubDate>Tue, 21 Oct 2025 00:00:00 GMT</pubDate><category>feature</category><category>custom-source</category><category>connectors</category><category>data-indexing</category><category>architecture</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 2025-10-19</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-10-19/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-10-19/</guid><description>Production-ready upgrades: durable execution, faster incremental processing over large datasets, GPU isolation, and richer native building blocks.</description><pubDate>Sun, 19 Oct 2025 00:00:00 GMT</pubDate><category>changelog</category><category>incremental-processing</category><category>postgres</category><category>structured-extraction</category><category>connectors</category><author>George He</author></item><item><title>Automated invoice processing with AI and Snowflake</title><link>https://cocoindex.io/blogs/etl-to-snowflake/</link><guid isPermaLink="true">https://cocoindex.io/blogs/etl-to-snowflake/</guid><description>Incremental ETL from Azure Blob Storage to Snowflake with CocoIndex v1: LLM invoice extraction from PDFs, processing only new or changed files.</description><pubDate>Sat, 11 Oct 2025 00:00:00 GMT</pubDate><category>examples</category><category>tutorial</category><category>structured-extraction</category><category>connectors</category><category>incremental-processing</category><author>Dhilip Subramanian</author></item><item><title>Thinking in Rust: Ownership, Access, and Memory Safety</title><link>https://cocoindex.io/blogs/rust-ownership-access/</link><guid isPermaLink="true">https://cocoindex.io/blogs/rust-ownership-access/</guid><description>A mental framework for Rust&apos;s memory safety concepts. Think systematically about ownership, references, Send, Sync, and Rc, Arc, RefCell, Mutex, etc.</description><pubDate>Fri, 10 Oct 2025 00:00:00 GMT</pubDate><category>insight</category><category>architecture</category><category>best-practices</category><author>George He</author></item><item><title>Trace search results back to source data</title><link>https://cocoindex.io/blogs/query-support/</link><guid isPermaLink="true">https://cocoindex.io/blogs/query-support/</guid><description>Define query handlers in CocoIndex and trace search results back to source data in CocoInsight to close the loop on indexing strategy.</description><pubDate>Sun, 21 Sep 2025 00:00:00 GMT</pubDate><category>examples</category><category>feature</category><category>rag</category><category>vector-search</category><author>Linghua Jin</author></item><item><title>Turn a Postgres table into a semantic index</title><link>https://cocoindex.io/blogs/postgres-source/</link><guid isPermaLink="true">https://cocoindex.io/blogs/postgres-source/</guid><description>Use an existing PostgreSQL table as a CocoIndex source: derive fields, embed each row, and store the vectors in Postgres with pgvector, incrementally.</description><pubDate>Mon, 01 Sep 2025 00:00:00 GMT</pubDate><category>examples</category><category>postgres</category><category>incremental-processing</category><category>embeddings</category><category>vector-search</category><author>Linghua Jin</author></item><item><title>Index PDFs, images, and slides with ColPali, no OCR</title><link>https://cocoindex.io/blogs/multi-format-indexing/</link><guid isPermaLink="true">https://cocoindex.io/blogs/multi-format-indexing/</guid><description>Build a unified visual document index from multiple file formats (including PDFs, images, and slides) using CocoIndex and ColPali. No OCR needed.</description><pubDate>Wed, 20 Aug 2025 00:00:00 GMT</pubDate><category>examples</category><category>multimodal</category><category>embeddings</category><category>vector-search</category><category>rag</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 2025-08-18</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-08-18/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-08-18/</guid><description>CocoIndex updates: production readiness, scalability, and reliability, plus more customization, native integrations, and multi-modal pipeline features.</description><pubDate>Mon, 18 Aug 2025 00:00:00 GMT</pubDate><category>changelog</category><category>performance</category><category>multimodal</category><category>connectors</category><category>vector-search</category><author>Linghua Jin</author></item><item><title>Control Processing Concurrency in CocoIndex</title><link>https://cocoindex.io/blogs/flow-control/</link><guid isPermaLink="true">https://cocoindex.io/blogs/flow-control/</guid><description>How CocoIndex&apos;s layered concurrency controls optimize data-processing performance, prevent system overload, and keep pipelines stable and efficient at scale.</description><pubDate>Wed, 13 Aug 2025 00:00:00 GMT</pubDate><category>feature</category><category>performance</category><category>best-practices</category><category>architecture</category><author>George He</author></item><item><title>Index Images with ColPali: multi-vector visual search</title><link>https://cocoindex.io/blogs/colpali/</link><guid isPermaLink="true">https://cocoindex.io/blogs/colpali/</guid><description>Index images with ColPali multi-vector patch embeddings and a Qdrant MaxSim collection using CocoIndex v1: incremental, live, in plain async Python.</description><pubDate>Tue, 12 Aug 2025 00:00:00 GMT</pubDate><category>examples</category><category>feature</category><category>multimodal</category><category>embeddings</category><category>vector-search</category><author>Linghua Jin</author></item><item><title>Multi-Dimensional Vector Support in CocoIndex</title><link>https://cocoindex.io/blogs/multi-vector/</link><guid isPermaLink="true">https://cocoindex.io/blogs/multi-vector/</guid><description>CocoIndex natively handles typed multi-dimensional vectors, from simple arrays to multi-vector embeddings, unlocking multimodal AI pipelines at scale.</description><pubDate>Sun, 10 Aug 2025 00:00:00 GMT</pubDate><category>feature</category><category>embeddings</category><category>vector-search</category><category>multimodal</category><author>Linghua Jin</author></item><item><title>Custom Targets: export your data anywhere</title><link>https://cocoindex.io/blogs/custom-targets/</link><guid isPermaLink="true">https://cocoindex.io/blogs/custom-targets/</guid><description>Export CocoIndex data anywhere: implement a TargetHandler with a tracking record and an action sink; the engine handles diffing, syncing, and cleanup.</description><pubDate>Sun, 03 Aug 2025 00:00:00 GMT</pubDate><category>examples</category><category>feature</category><category>connectors</category><category>incremental-processing</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>Index faces for visual search: your own Google Photos</title><link>https://cocoindex.io/blogs/face-detection/</link><guid isPermaLink="true">https://cocoindex.io/blogs/face-detection/</guid><description>Build a scalable face detection and recognition pipeline with CocoIndex: embed faces, structure for search, and export to a vector DB.</description><pubDate>Thu, 24 Jul 2025 00:00:00 GMT</pubDate><category>examples</category><category>tutorial</category><category>multimodal</category><category>embeddings</category><category>vector-search</category><author>Linghua Jin</author></item><item><title>Index academic papers and extract metadata for AI agents</title><link>https://cocoindex.io/blogs/academic-papers-indexing/</link><guid isPermaLink="true">https://cocoindex.io/blogs/academic-papers-indexing/</guid><description>Index academic papers with CocoIndex: read the first page of each PDF, extract title, authors, and abstract with an LLM, and embed them for semantic search.</description><pubDate>Wed, 09 Jul 2025 00:00:00 GMT</pubDate><category>examples</category><category>structured-extraction</category><category>embeddings</category><category>rag</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 2025-07-07</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-07-07/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-07-07/</guid><description>CocoIndex updates: in-process setup/drop API, EmbedText building block, SplitRecursively improvements, union/NumPy types, and the Kuzu graph target.</description><pubDate>Mon, 07 Jul 2025 00:00:00 GMT</pubDate><category>changelog</category><category>embeddings</category><category>llm</category><category>knowledge-graph</category><category>incremental-processing</category><author>CocoIndex Team</author></item><item><title>Introducing CocoInsight</title><link>https://cocoindex.io/blogs/cocoinsight/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoinsight/</guid><description>Introducing CocoInsight, a data lineage and observability tool that lets you inspect, trace, and debug every step of a CocoIndex pipeline in real time.</description><pubDate>Tue, 24 Jun 2025 00:00:00 GMT</pubDate><category>feature</category><category>announcement</category><category>insight</category><category>architecture</category><author>Linghua Jin</author></item><item><title>Flow-based schema inference for Qdrant</title><link>https://cocoindex.io/blogs/schema-inference-for-qdrant/</link><guid isPermaLink="true">https://cocoindex.io/blogs/schema-inference-for-qdrant/</guid><description>CocoIndex sets up Qdrant collections automatically by inferring the target schema from your indexing flow: no manual config, vector sizes kept in sync.</description><pubDate>Sun, 08 Jun 2025 00:00:00 GMT</pubDate><category>feature</category><category>vector-search</category><category>connectors</category><category>data-indexing</category><author>Linghua Jin</author></item><item><title>CocoIndex + Kuzu: Real-time knowledge graph with Kuzu</title><link>https://cocoindex.io/blogs/kuzu-integration/</link><guid isPermaLink="true">https://cocoindex.io/blogs/kuzu-integration/</guid><description>Build a real-time knowledge graph with Kuzu as a native CocoIndex target: incremental updates, high-performance graph queries.</description><pubDate>Tue, 03 Jun 2025 00:00:00 GMT</pubDate><category>examples</category><category>feature</category><category>knowledge-graph</category><category>connectors</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 2025-05-31</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-05-31/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-05-31/</guid><description>CocoIndex updates: Amazon S3 as a data source, improved query handling, a standalone runtime mode, and more connector and performance improvements.</description><pubDate>Sat, 31 May 2025 00:00:00 GMT</pubDate><category>changelog</category><category>connectors</category><category>incremental-processing</category><category>embeddings</category><category>vector-search</category><author>CocoIndex Team</author></item><item><title>Incremental ETL on Amazon S3 with CocoIndex</title><link>https://cocoindex.io/blogs/s3-incremental-etl/</link><guid isPermaLink="true">https://cocoindex.io/blogs/s3-incremental-etl/</guid><description>Index Markdown from Amazon S3 into Postgres pgvector with CocoIndex v1: incremental processing re-embeds only the files and chunks that changed.</description><pubDate>Thu, 29 May 2025 00:00:00 GMT</pubDate><category>examples</category><category>incremental-processing</category><category>connectors</category><category>data-indexing</category><author>Linghua Jin</author></item><item><title>Image search in natural language with CLIP</title><link>https://cocoindex.io/blogs/live-image-search/</link><guid isPermaLink="true">https://cocoindex.io/blogs/live-image-search/</guid><description>Search a folder of photos by meaning with CocoIndex v1: CLIP embeds images and text into one vector space, the index runs live inside a FastAPI app, and vectors live in Qdrant.</description><pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate><category>examples</category><category>multimodal</category><category>embeddings</category><category>vector-search</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>How to build an index with text embeddings</title><link>https://cocoindex.io/blogs/text-embeddings-101/</link><guid isPermaLink="true">https://cocoindex.io/blogs/text-embeddings-101/</guid><description>Text embeddings 101: what they are, why you chunk and embed, and how to build a semantic search index with CocoIndex v1 and Postgres pgvector.</description><pubDate>Mon, 19 May 2025 00:00:00 GMT</pubDate><category>examples</category><category>embeddings</category><category>vector-search</category><category>rag</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>Story of CocoIndex, at 1k stars 🎉</title><link>https://cocoindex.io/blogs/cocoindex-1k/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-1k/</guid><description>The story of CocoIndex at 1,000 GitHub stars: the open-source engine that combines custom transformation logic with incremental processing for data indexing.</description><pubDate>Thu, 08 May 2025 00:00:00 GMT</pubDate><category>announcement</category><category>changelog</category><category>data-indexing</category><category>incremental-processing</category><category>architecture</category><author>Linghua Jin</author></item><item><title>Build a product recommendation engine with LLM + Neo4j</title><link>https://cocoindex.io/blogs/product-recommendation/</link><guid isPermaLink="true">https://cocoindex.io/blogs/product-recommendation/</guid><description>An LLM extracts each product&apos;s taxonomy and complements; CocoIndex turns the labels into a Neo4j knowledge graph that answers &apos;bought this, also need…&apos;.</description><pubDate>Wed, 07 May 2025 00:00:00 GMT</pubDate><category>examples</category><category>knowledge-graph</category><category>llm</category><category>structured-extraction</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 2025-04-30</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-04-30/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-04-30/</guid><description>CocoIndex updates: knowledge graph support, Qdrant and Supabase targets, KTable and LTable data types, additional LLM providers, and more.</description><pubDate>Wed, 30 Apr 2025 00:00:00 GMT</pubDate><category>changelog</category><category>knowledge-graph</category><category>connectors</category><category>vector-search</category><category>llm</category><author>CocoIndex Team</author></item><item><title>Build Real-Time Knowledge Graph For Documents with LLM</title><link>https://cocoindex.io/blogs/knowledge-graph-for-docs/</link><guid isPermaLink="true">https://cocoindex.io/blogs/knowledge-graph-for-docs/</guid><description>Turn a folder of Markdown docs into a Neo4j knowledge graph: an LLM extracts subject-predicate-object triples, and CocoIndex keeps the graph in sync as the docs change.</description><pubDate>Tue, 29 Apr 2025 00:00:00 GMT</pubDate><category>examples</category><category>knowledge-graph</category><category>llm</category><category>structured-extraction</category><category>tutorial</category><author>Linghua Jin</author></item><item><title>CocoIndex Changelog 2025-04-07</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-04-07/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-04-07/</guid><description>CocoIndex updates: incremental live update mode, evaluation utilities, date/time types, a Google Drive source, and core performance improvements.</description><pubDate>Mon, 07 Apr 2025 00:00:00 GMT</pubDate><category>changelog</category><category>incremental-processing</category><category>connectors</category><category>structured-extraction</category><author>CocoIndex Team</author></item><item><title>Keep derived data in sync with changing sources</title><link>https://cocoindex.io/blogs/continuous-updates/</link><guid isPermaLink="true">https://cocoindex.io/blogs/continuous-updates/</guid><description>CocoIndex continuously watches source changes and applies incremental updates to keep derived data in sync, with low latency and no full reindexing.</description><pubDate>Mon, 07 Apr 2025 00:00:00 GMT</pubDate><category>feature</category><category>incremental-processing</category><category>data-indexing</category><category>connectors</category><author>CocoIndex Team</author></item><item><title>Incremental Processing with CocoIndex</title><link>https://cocoindex.io/blogs/incremental-processing/</link><guid isPermaLink="true">https://cocoindex.io/blogs/incremental-processing/</guid><description>What incremental processing is, who needs it, and how CocoIndex keeps an index in sync with source changes through caching, lineage tracking, and change data capture.</description><pubDate>Sun, 06 Apr 2025 00:00:00 GMT</pubDate><category>feature</category><category>incremental-processing</category><category>performance</category><category>architecture</category><author>Linghua Jin</author></item><item><title>Structured Extraction from Patient Intake Form with LLM</title><link>https://cocoindex.io/blogs/patient-intake-form-extraction-with-llm/</link><guid isPermaLink="true">https://cocoindex.io/blogs/patient-intake-form-extraction-with-llm/</guid><description>Extract typed Patient records from PDF and DOCX intake forms with an LLM and CocoIndex v1: the nested schema is the whole prompt; results land in Postgres.</description><pubDate>Wed, 26 Mar 2025 00:00:00 GMT</pubDate><category>examples</category><category>structured-extraction</category><category>llm</category><category>multimodal</category><author>CocoIndex Team</author></item><item><title>Search your Google Drive by meaning</title><link>https://cocoindex.io/blogs/text-embedding-from-google-drive/</link><guid isPermaLink="true">https://cocoindex.io/blogs/text-embedding-from-google-drive/</guid><description>Index the documents in a shared Google Drive folder as text embeddings in Postgres with CocoIndex v1, then search them by meaning instead of by filename.</description><pubDate>Sun, 23 Mar 2025 00:00:00 GMT</pubDate><category>examples</category><category>embeddings</category><category>vector-search</category><category>connectors</category><category>data-indexing</category><author>CocoIndex Team</author></item><item><title>CocoIndex Changelog 2025-03-20</title><link>https://cocoindex.io/blogs/cocoindex-changelog-2025-03-20/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-changelog-2025-03-20/</guid><description>First release of CocoIndex Changelog: LLM support, codebase indexing, custom functions, and assorted core/performance improvements</description><pubDate>Thu, 20 Mar 2025 00:00:00 GMT</pubDate><category>changelog</category><category>llm</category><category>structured-extraction</category><category>rag</category><author>CocoIndex Team</author></item><item><title>Build Real-Time Codebase Indexing for AI Code Generation</title><link>https://cocoindex.io/blogs/index-code-base-for-rag/</link><guid isPermaLink="true">https://cocoindex.io/blogs/index-code-base-for-rag/</guid><description>Indexing codebase for RAG with CocoIndex and Tree-sitter in real-time: chunking, embedding, semantic search, and build vector index for efficient retrieval.</description><pubDate>Tue, 18 Mar 2025 00:00:00 GMT</pubDate><category>examples</category><category>rag</category><category>embeddings</category><category>vector-search</category><category>tutorial</category><author>CocoIndex Team</author></item><item><title>On-premise structured extraction from PDFs with Ollama</title><link>https://cocoindex.io/blogs/cocoindex-ollama-structured-extraction-from-pdf/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-ollama-structured-extraction-from-pdf/</guid><description>Extract structured data from PDF manuals locally with Ollama and CocoIndex: docling converts PDFs to Markdown, a local LLM fills typed Postgres rows.</description><pubDate>Mon, 17 Mar 2025 00:00:00 GMT</pubDate><category>examples</category><category>tutorial</category><category>structured-extraction</category><category>llm</category><category>postgres</category><author>CocoIndex Team</author></item><item><title>We are officially open sourced! 🎉</title><link>https://cocoindex.io/blogs/cocoindex-open-source/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-open-source/</guid><description>CocoIndex is now open source: the first engine to combine custom transformation logic with incremental processing built specifically for data indexing.</description><pubDate>Mon, 03 Mar 2025 00:00:00 GMT</pubDate><category>announcement</category><category>changelog</category><category>incremental-processing</category><category>data-indexing</category><category>rag</category><author>CocoIndex Team</author></item><item><title>Customizable Data Indexing Pipelines</title><link>https://cocoindex.io/blogs/data-indexing-custom-logic/</link><guid isPermaLink="true">https://cocoindex.io/blogs/data-indexing-custom-logic/</guid><description>What customizable data indexing pipelines are and why custom transformation logic matters, with practical CocoIndex examples.</description><pubDate>Thu, 20 Feb 2025 00:00:00 GMT</pubDate><category>data-indexing</category><category>insight</category><category>rag</category><category>embeddings</category><category>vector-search</category><author>CocoIndex Team</author></item><item><title>How indexing pipelines differ from other data pipelines</title><link>https://cocoindex.io/blogs/what-makes-indexing-pipelines-different/</link><guid isPermaLink="true">https://cocoindex.io/blogs/what-makes-indexing-pipelines-different/</guid><description>What makes indexing pipelines different from other data systems, and why they need special handling for incremental processing and persistence.</description><pubDate>Thu, 30 Jan 2025 00:00:00 GMT</pubDate><category>insight</category><category>incremental-processing</category><category>architecture</category><category>data-indexing</category><author>CocoIndex Team</author></item><item><title>System updates and automatic schema inference</title><link>https://cocoindex.io/blogs/handle-system-update-for-indexing-flow/</link><guid isPermaLink="true">https://cocoindex.io/blogs/handle-system-update-for-indexing-flow/</guid><description>How CocoIndex handles system updates in indexing flows: automatic schema inference and managing data + logic evolution without downtime.</description><pubDate>Mon, 20 Jan 2025 00:00:00 GMT</pubDate><category>data-indexing</category><category>best-practices</category><category>feature</category><category>architecture</category><author>CocoIndex Team</author></item><item><title>Processing Large Files in Data Indexing Systems</title><link>https://cocoindex.io/blogs/indexing-for-single-large-file/</link><guid isPermaLink="true">https://cocoindex.io/blogs/indexing-for-single-large-file/</guid><description>Handle large files in data indexing: processing granularity, fan-in/fan-out, and memory pressure, walked through a patent XML example in CocoIndex.</description><pubDate>Fri, 10 Jan 2025 00:00:00 GMT</pubDate><category>data-indexing</category><category>best-practices</category><category>performance</category><category>architecture</category><author>CocoIndex Team</author></item><item><title>Data Consistency in Indexing Pipelines</title><link>https://cocoindex.io/blogs/indexing-data-consistency/</link><guid isPermaLink="true">https://cocoindex.io/blogs/indexing-data-consistency/</guid><description>Data consistency in indexing pipelines: concurrent updates, exposure risks, and how CocoIndex&apos;s data-driven approach keeps indexes converging.</description><pubDate>Mon, 06 Jan 2025 00:00:00 GMT</pubDate><category>data-indexing</category><category>best-practices</category><category>insight</category><category>architecture</category><author>CocoIndex Team</author></item><item><title>Data Indexing and Common Challenges</title><link>https://cocoindex.io/blogs/data-indexing-and-common-challenges/</link><guid isPermaLink="true">https://cocoindex.io/blogs/data-indexing-and-common-challenges/</guid><description>Fundamentals of data indexing pipelines for RAG: what makes a good one, common production pitfalls, and how CocoIndex addresses them.</description><pubDate>Sun, 05 Jan 2025 00:00:00 GMT</pubDate><category>data-indexing</category><category>best-practices</category><category>insight</category><author>CocoIndex Team</author></item><item><title>CocoIndex - A Data Indexing Platform for AI Applications</title><link>https://cocoindex.io/blogs/cocoindex-data-indexing-platform/</link><guid isPermaLink="true">https://cocoindex.io/blogs/cocoindex-data-indexing-platform/</guid><description>CocoIndex is a data indexing platform for AI: ingestion, chunking, embedding, and pipeline management for RAG, semantic search, and knowledge graphs.</description><pubDate>Sat, 04 Jan 2025 00:00:00 GMT</pubDate><category>data-indexing</category><category>rag</category><category>embeddings</category><category>vector-search</category><category>knowledge-graph</category><author>CocoIndex Team</author></item><item><title>Welcome to CocoIndex</title><link>https://cocoindex.io/blogs/welcome/</link><guid isPermaLink="true">https://cocoindex.io/blogs/welcome/</guid><description>Welcome to the official CocoIndex blog! We&apos;re excited to share our journey in building high-performance indexing infrastructure for AI applications.</description><pubDate>Thu, 02 Jan 2025 00:00:00 GMT</pubDate><category>announcement</category><category>community</category><category>data-indexing</category><author>Linghua Jin</author></item></channel></rss>