How to Ingest HTML Files into LangChain with EnConvert

Use EnConvert to ingest HTML files into LangChain as heading-aware chunks with navigation stripped and section metadata ready to load as Document objects

Get API key

A developer building a LangChain agent or RAG pipeline that reads HTML files needs clean, heading-aware chunks with section metadata, not raw HTML that still contains navigation chrome, sidebar content, and markup that buries the actual content. Parsing HTML with BeautifulSoup or LangChain’s BSHTMLLoader extracts text but does not strip navigation sidebars, does not split at heading boundaries, and does not attach the headings_path or section metadata a retrieval layer can filter on. For an agent reading exported documentation, a saved web page, or an HTML knowledge base article, flat extracted text without section context produces chunks the agent cannot locate within the document or filter by section. EnConvert’s Ingest endpoint takes the uploaded HTML file and returns JSONL chunks split at H1 to H3 heading boundaries with navigation chrome stripped, each carrying its full headings_path breadcrumb, section name, and chunk index in metadata, so clean HTML content is ready to load as LangChain Document objects and retrieve in one call.

Example

python
import json
import os
import time

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

HEADERS = {"X-API-Key": os.environ["ENCONVERT_API_KEY"]}

# Step 1: Upload the HTML file to Ingest
# Navigation and footer chrome is stripped where detected
with open("/path/to/your/exported-docs-page.html", "rb") as f:
    upload = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={"files": ("exported-docs-page.html", f, "text/html")},
        timeout=60,
    )
upload.raise_for_status()
job_id = upload.json()["job_id"]

# Step 2: Poll until the job completes (give up after 10 minutes)
deadline = time.monotonic() + 600
while True:
    status_response = requests.get(
        f"https://api.enconvert.com/v2/ingest/{job_id}",
        headers=HEADERS,
        timeout=60,
    )
    status_response.raise_for_status()
    job = status_response.json()
    if job["status"] == "completed":
        break
    if job["status"] in ("failed", "canceled"):
        raise RuntimeError(f"Ingest {job['status']}: {job.get('error_message')}")
    if time.monotonic() > deadline:
        raise TimeoutError(f"Ingest {job_id} is still {job['status']} after 10 minutes")
    time.sleep(2)

# Step 3: Download the JSONL output
# The signed URL is valid for 15 minutes. Plain GET, no API key.
jsonl = requests.get(job["output_url"], timeout=60)
jsonl.raise_for_status()

# Split on newline only: str.splitlines() also breaks on characters like U+0085
chunks = [
    json.loads(line)
    for line in jsonl.content.decode("utf-8").split("\n")
    if line.strip()
]
print(f"Loaded {len(chunks)} chunks ready for embedding")

# Step 4: Load each chunk as a LangChain Document with metadata preserved
# Do not apply another text splitter - heading-aware boundaries are already set
# headings_path is a list of strings. A store that accepts only scalar metadata
# needs " > ".join(headings_path) instead.
documents = [
    Document(
        page_content=chunk["content"],
        metadata={
            "source_url": chunk["metadata"]["source_url"],
            "title": chunk["metadata"]["title"],
            "section": chunk["metadata"]["section"],
            "headings_path": chunk["metadata"]["headings_path"],
            "chunk_index": chunk["metadata"]["chunk_index"],
            "word_count": chunk["metadata"]["word_count"],
        },
    )
    for chunk in chunks
]

# Step 5: Embed the chunks into an in-memory vector store and save it locally
# InMemoryVectorStore ships with langchain-core; its search needs numpy installed
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
    api_key=os.environ["OPENAI_API_KEY"],
)
vectorstore = InMemoryVectorStore.from_documents(documents, embeddings)
vectorstore.dump("./html_index.json")

# Step 6: Similarity search - results carry the section they came from
results = vectorstore.similarity_search("how do I rotate an API key", k=3)
for doc in results:
    print(doc.metadata["section"])
    print(" > ".join(doc.metadata["headings_path"]))
    print(doc.page_content[:200])

# Step 7: Restrict retrieval to one section of the file
# Replace with a section heading from your own file (printed in Step 6)
SECTION = "Rotating API keys"
section_results = vectorstore.similarity_search(
    "how do I rotate an API key",
    k=3,
    filter=lambda doc: doc.metadata["section"] == SECTION,
)
for doc in section_results:
    print(doc.metadata["section"], "-", doc.page_content[:200])

# To reload the saved index later:
# vectorstore = InMemoryVectorStore.load("./html_index.json", embeddings)

What you get back

Ingest returns the HTML file as JSONL chunks split at H1 to H3 heading boundaries with navigation chrome stripped, each carrying its headings_path breadcrumb, section name, and chunk index, so LangChain retrieval can filter by section. Parsing the same file with BeautifulSoup or BSHTMLLoader extracts text but does not strip navigation sidebars, does not split at heading boundaries, and attaches no section metadata LangChain can filter on.

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