Ryan Rochmanofenna

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 on the in-house DekaLLM platform.

Sign-in screen showing a knowledge graph of company entities
Corpus
500k+ PDFs
Cold pipeline
~30min → ~4min
Warm
10–15s
Retrieval
Milvus + FalkorDB

Getting documents into a graph

Ingestion is a four-stage LangGraph flow: OCR, then named-entity recognition, then entity-relation extraction, then graph ingestion. A document arrives from S3 as a scan, and leaves as nodes and edges — organizations, people, assets, and the relations connecting them. 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.

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:

FalkorDBNeo4j
Connect13.9 ms34.2 ms
Node ingest (42)90.1 ms30.0 ms
Relationship ingest (171)412.7 ms1,325.9 ms
1-hop retrieval (54 rels)7.69 ms18.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, and refusing to make things up

A question seeds dense retrieval in Milvus, and those hits expand one hop through FalkorDB across shared entity keys — so an answer can pull in a document that never matched the query text but is connected to something that did. A cross-encoder reranks the merged set, with a fallback cascade down to seed-only if graph expansion returns nothing useful.

Every citation then passes three-state validation before the answer ships, which exists because the failure that actually matters here isn’t a wrong answer — it’s a confident answer citing a page that doesn’t exist. In a company’s legal archive, a fabricated reference is worse than no answer.

Query trace: Milvus text search returning 10 documents in 165ms, then FalkorDB graph expansion returning 50 relations in 47ms, with the resulting subgraph

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.

FalkorDB browser showing 271 nodes, 802 edges, and the extracted relationship vocabulary
271 nodes and 802 edges from one corpus. The relationship list on the left is the interesting part — REPORTS_ASSET_VALUE, SUBSIDIARY_OF, PAID_TAX_AMOUNT — because nobody wrote that schema. The extraction pipeline produced it from the documents. The property keys tell the rest of the story: aliases is where entity resolution lands, and evidence_pages is what makes citations checkable.
Milvus collection browser showing 359 embedded chunks with their vectors
The same corpus as 359 embedded chunks. Retrieval starts here and expands into the graph above through shared entity keys. Document text is cropped out of this view deliberately — it’s the client’s.

Making it fast enough to use

The pipeline started at roughly 30 minutes per document and now runs 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 never gets re-processed, and fail-open so a cache outage degrades speed instead of availability — plus async parallelization and eliminating self-pair comparisons in entity resolution. A determinism check guarantees the same document yields the same graph across runs, which is what makes the cache safe to trust in the first place.

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.

Registration doesn’t self-approve: a new account is inert until an admin approves it, and every auth event lands in an append-only activity log. In a system whose whole job is answering questions about internal documents, who asked is as much a record as what was answered.

Postgres query output showing the users table with roles, and an activity log of registration, approval, and login events
Roles, and the audit trail: REGISTER APPROVE_USER LOGIN_FAILED LOGIN_SUCCESS. Email addresses and IPs are redacted.

Stack

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