Integrations should not own document text: shrink the side-car to ids + metadata
Summary
The four integration wrappers persist a JSON side-car holding the full document/node text alongside the .tvim index. turbovec should own indexing and search; the frameworks should own documents. The side-car should shrink to ids + metadata only, and the text should go.
This is the structural fix behind #425 (side-car 5.5x larger than the index at 100k docs) — text is what makes it large.
Why this is safe: all four frameworks support resolving text separately
Validated empirically, not from documentation. Wheel built with maturin build --release, installed non-editable into a fresh Python 3.11.12 venv; framework versions langchain-core 1.5.3 / langchain-classic 1.0.8, llama-index-core 0.14.23, haystack-ai 3.0.0, agno 2.8.6.
| Framework | Resolution step | Status |
|---|---|---|
| LangChain | MultiVectorRetriever takes a docstore and calls docstore.mget(ids) on our hits |
first-class, works today |
| LlamaIndex | StorageContext.docstore owns text; SimpleVectorStore itself sets stores_text = False |
first-class |
| Haystack | AutoMergingRetriever / SentenceWindowRetriever accept a different document_store than the one that searched |
first-class |
| Agno | none — user subclasses and overrides search |
hand-rolled |
Confirmed running: MultiVectorRetriever(vectorstore=TurboQuantVectorStore(...), docstore=InMemoryStore(), id_key="doc_id") retrieves documents that provably came from the docstore; LocalFileStore likewise. AutoMergingRetriever(document_store=parent_store).run(documents=turbovec_hits) returned the parent's full text while every document held in turbovec had content=None.
What has to change
Deleting the JSON writer alone is not enough — three read paths reconstruct results from the side-car, so the in-memory duplication would simply remain undisk'd:
LlamaIndex —
query()hard-requires its own copy of the node:metadata_dict_to_node(data["node_dict"])atturbovec-python/python/turbovec/llama_index.py:544, called from:848. Blanking the payload raisesValueError: Node content not found in metadata dict.Needsadd()to stop storing node payloads andquery()to returnVectorStoreQueryResult(nodes=None, ids=..., similarities=...), matchingSimpleVectorStore. A ~40-line prototype overturbovec.IdMapIndexwas verified end to end: no vector-store JSON written at all, retrieval returns full text with the correct top hit.- Accepted cost:
VectorStoreIndex.from_vector_store(vs)raisesValueError: Cannot initialize from a vector store that does not store text.This is by design and is whatSimpleVectorStoredoes too. - Note the shipped class already accepts
stores_text=Falseas a constructor arg, but still writesdefault__vector_store.nodes.json(2862 B) containing the text — so text is duplicated on disk today even when the caller opts out.
- Accepted cost:
Agno — correctness bug, independent of this work.
TurboQuantVectorDb._dedup_by_contenthashesmd5(doc.content). With blank content asearch(limit=3)silently returns 1 result. Should dedup on id, not content.LangChain — no vector-in door.
add_textsembeds exactly what it stores, andadd_embeddingsdoes not exist (sorted(m for m in dir(vs) if m.startswith("add")) == ['add_documents', 'add_texts']). Passingpage_content=""does not raise — it goes semantically dead: all three test documents embedded identically, every score exactly0.2286243885755539, wrong document ranked first. Addingadd_embeddings(embeddings, ids, metadatas)makes text-free a supported path rather than a trick.Haystack — no change required.
write_documentsacceptscontent=None, andembedding_retrievalranked correctly with all content blank (p11.004 vsp20.1243).
What stays
Metadata stays in the side-car. LangChain's dict and callable filters and Haystack's filter_documents both walk the local document table, so metadata cannot leave without breaking filtering. Metadata is small, and it is arguably index-adjacent anyway.
Also worth capturing when this is done: turbovec derives its own ids (_derive_doc_id = md5(f"{base_id}_{content_hash}")), so a caller-supplied id like p1 becomes 225df170ad3940d474744dbc0de92068. Any external store must key on doc.name or metadata rather than the id the user passed in — that needs documenting. Separately, Agno's content_hash_exists() is in-memory only, so with no durable turbovec state the skip-if-already-ingested check always reports "not present" and re-ingests every run; that state needs a home.
Scope
Breaking only for callers that rely on turbovec handing back text it stored. Not a major version.
Source: RyanCodrai/turbovec