Knowledge Graph RAG
An internal assistant that answers questions about a company’s own documents — meeting minutes, financial reports, contracts — by reading both the text and the relationships between the entities inside it. Built at Lintasarta, with models served through the in-house DekaLLM gateway.

- Cold pipeline
- ~30min → ~4min
- Warm
- 10–15s
- Retrieval
- Milvus + FalkorDB
- OCR
- docling layout + vision LLM
- Entities
- 13 types
Getting documents into a graph
Ingestion is a LangGraph flow. A scan arrives from S3, a layout model (docling) splits each page into text regions, tables, and figures; a vision LLM reads the text regions, TableFormer extracts the tables, and a VLM describes the figures. Named-entity recognition then pulls out 13 entity types per page, and a multi-stage entity-resolution subgraph deduplicates them, identifies the document’s primary organization, and extracts the relationships between entities. The result is written in parallel to FalkorDB (the graph) and Milvus (the text). Redis holds the organization alias map, so the same company written five different ways across five documents resolves to one node rather than five.
The stage that decides whether any of this is usable is entity resolution. Skip it and the graph technically exists but answers nothing, because the entity you asked about is scattered across duplicates that never join up.
Resolving entities with a human in the loop
Automatic resolution handles the easy cases: within a document, entities are normalized and merged on name or alias; across documents, a primary organization is canonicalized through the Redis alias map. The hard cases go to a review workflow. Candidate duplicates are generated by three channels (acronym match, fuzzy string similarity, and shared token prefix), scored by an LLM in batches, and stored in Postgres for a reviewer to accept or reject. An accepted merge rewrites every relationship onto the surviving node in one atomic statement before deleting the duplicate.
The current gap: identity is still keyed on the name string, so a merge doesn’t survive re-ingesting the same document. The fix is stable IDs with an alias table, which is the next piece of work.
Choosing the graph store by measuring
FalkorDB or Neo4j was a real decision, so I benchmarked both on the same extracted output rather than arguing from reputation:
| FalkorDB | Neo4j | |
|---|---|---|
| Connect | 13.9 ms | 34.2 ms |
| Node ingest (42) | 90.1 ms | 30.0 ms |
| Relationship ingest (171) | 412.7 ms | 1,325.9 ms |
| 1-hop retrieval (54 rels) | 7.69 ms | 18.50 ms |
Neo4j ingests nodes three times faster. FalkorDB wins relationship ingest by 3.2x and one-hop retrieval by 2.4x. This workload is relationship-dense — 171 relationships against 42 nodes for a single document — and every user question is a read, so FalkorDB was the right trade. Worth stating plainly: this is one document’s extracted output, not a scale test. It was enough to settle the question it was asked.
Retrieval
A question seeds dense retrieval in Milvus, and the entities in those hits anchor a one-hop expansion through FalkorDB, so an answer can pull in a document that never matched the query text but is connected to something that did. The graph neighbors’ evidence is embedded and filtered down to the few most relevant to the question, then merged with the original hits into a bounded context. The answer is generated from that context, and the sources it was built from are streamed to the user alongside it.

The trace is shown to the user, not hidden: which store was hit, how many hits came back, how long each took, and the subgraph the answer was built from.
The two stores, from the inside
“Cross-store” is easy to claim, so here are both halves of it. The graph carries the structure the extraction pipeline found, and the vector index carries the text it was found in.


Making it fast enough to use
The pipeline started at roughly 30 minutes per document and got down to about 4 minutes cold, 10–15 seconds warm. Most of that came from content-addressed caching in Redis — SHA-256 keyed on input so identical content was never re-processed, and fail-open so a cache outage degraded speed instead of availability — plus async parallelization and eliminating self-pair comparisons in entity resolution. A determinism check guaranteed the same document yielded the same graph across runs, which is what made the cache safe to trust in the first place. That cache was removed in a later refactor of the ER stage; the numbers above are from when it was live.
Serving it
A FastAPI service fronts retrieval and answering. Authentication is JWT with HS256 over Postgres-backed accounts, with an admin panel for dataset and user management. The interface is Indonesian throughout, because the people using it work in Indonesian.
The front end is a React app with a chat view, a source panel for each answer, and an interactive graph view of the subgraph the answer came from.
Registration doesn’t self-approve: a new account is inert until an admin approves it.

Stack
Python, LangGraph, FastAPI, Milvus, FalkorDB, Redis, Postgres, S3, Docker, docling, React. Go for the telemetry collector.