How to Ingest Markdown into LangChain with EnConvert

Ingest Markdown into LangChain with heading-aware chunks and full breadcrumb metadata preserved on every chunk. Free tier, 500 ops per month.

Get API key

Markdown looks like the easy ingestion case until you split it. LangChain's MarkdownHeaderTextSplitter preserves one level of heading context, but the moment a document nests three or four levels deep the parent hierarchy drops out of the metadata, a chunk from API Reference > Async Jobs > Configuration becomes indistinguishable from a chunk under Billing > Refunds > Configuration. Character-based splitters make it worse by cutting mid-section and leaving the resulting chunk with no heading context at all. When two sections share a subtitle, retrieval returns both and the model has no structural signal to tell them apart. The headings were there in the source file. They just never made it into the chunk metadata.

Example

python
# pip install requests langchain-core langchain-openai numpy
import os
import json
import time

import requests
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings

ENCONVERT_API_KEY = os.environ["ENCONVERT_API_KEY"]
MARKDOWN_PATH = "/path/to/your/document.md"
HEADERS = {"X-API-Key": ENCONVERT_API_KEY}

# Step 1: Upload the Markdown file (the multipart field is named "files")
with open(MARKDOWN_PATH, "rb") as markdown_file:
    response = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={"files": markdown_file},
        timeout=60,
    )
response.raise_for_status()
job_id = response.json()["job_id"]

# Step 2: Poll until the job reaches a final status
while True:
    poll = requests.get(
        f"https://api.enconvert.com/v2/ingest/{job_id}",
        headers=HEADERS,
        timeout=60,
    )
    poll.raise_for_status()
    job = poll.json()
    if job["status"] in ("completed", "failed", "canceled"):
        break
    time.sleep(5)

if job["status"] != "completed":
    raise RuntimeError(
        f"Ingest job {job_id} {job['status']}: {job.get('error_message')}"
    )

# Step 3: Download the JSONL output. output_url is a signed URL (valid
# 15 minutes), so it is fetched with a plain GET and no API key.
jsonl_response = requests.get(job["output_url"], timeout=60)
jsonl_response.raise_for_status()

# Step 4: Turn each chunk into a LangChain Document. "content" becomes
# page_content and the metadata (source_url, title, headings_path, section,
# word_count, chunk_index) is kept as-is, so every Document carries its
# full heading breadcrumb as a list of strings.
documents = []
for line in jsonl_response.iter_lines():
    if not line:
        continue
    chunk = json.loads(line)
    documents.append(
        Document(
            id=chunk["id"],
            page_content=chunk["content"],
            metadata=chunk["metadata"],
        )
    )

# Step 5: Embed the chunks and index them in an in-memory vector store
vectorstore = InMemoryVectorStore.from_documents(
    documents,
    OpenAIEmbeddings(
        model="text-embedding-3-small",
        api_key=os.environ["OPENAI_API_KEY"],
    ),
)
print(f"Loaded {len(documents)} chunks into LangChain")

# Step 6: Search, then print the full heading path of each result
# (replace the question with one about your own document)
for result in vectorstore.similarity_search("How do I configure async jobs?", k=3):
    metadata = result.metadata
    breadcrumb = " > ".join(metadata["headings_path"]) or metadata["title"]
    print(f"{breadcrumb}\n{result.page_content[:200]}\n")

What you get back

Splitting Markdown with LangChain's built-in splitters gives you chunks that carry only the immediate heading and lose every parent level above it, versus EnConvert returning chunks where every Document includes the full headings_path breadcrumb from root to the chunk's section so nested retrieval stays unambiguous.

Get free API key — 500 ops per month, no card required.