Repository: run-llama/llama_index
Pull request: feat(node_parser): `MetadataExtractor` - Feature Augmentation via node parser post-processing
# Description

Rendered link to notebook: 
https://github.com/jerryjliu/llama_index/blob/56060f7c3ee1643d6cdca0fd24c9d4b96acecf60/docs/examples/metadata_extraction/MetadataExtractionSEC.ipynb

Fixes https://github.com/jerryjliu/llama_index/issues/6627
Partial implementation: https://github.com/jerryjliu/llama_index/issues/6625 (needs further evaluation)

Concerns:
(outdated)
<details>
1. Document title works well, but other features not so well and may be considered "experimental features". How could we indicate this?
2. In order to deal with the scenario where single document may be split apart into multiple (as with PDF pages), we apply the document title to the entire set of documents. However, this may not be the best default. Ideally, the user can structure their documents that preserves the hierarchical parent child relationship. Another way is to apply document title to the nodes with the same file name. However, file name may not be available. This is quite a concerning problem. 
</details>

## Type of Change


- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

# How Has This Been Tested?


- [x] Added new notebook (that tests end-to-end)

# Suggested Checklist:

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes


Problem statement:
[Feature Request]: Pattern: Enriched Document Embedding
### Feature Description

To have a more robust embedding, I propose the following strategies:

1. Extract title, file name, subsections, other metadata (summary of sub section and article, keyword extraction)
2. Embed a summary of the previous chunk and next chunk.

Problems solved:
1. Coreference resolution / document context 
    - (e.g. multiple 10Ks from the different company over multiple years have the similar sections with similar contents but do not state the year nor company - the metadata or contextual data). 
    - This is a source of retrieval-augmented hallucination.

Strategies for section-wise extraction:
1. Hierarchical
    - Hierarchically construct a summary using the tree-summarizer pattern. This special summary will attempt to hierarchically construct the table of contents by identifying sections and the location of the source documents among them.
2. Linear
    - Attempt to linearly extract subsection titles


In summary, here are the two specific deliverables:
- [ ] Previous and next chunk summary metadata extractor
  - Require `include_prev_next_rel`
- [ ] Title, subsection, keyword metadata extractor
### Reason

_No response_

### Value of Feature

_No response_

Hints from issue discussion:
TODO:
1. Investigate other methods for long-document understanding.
2. Ideally, feature augmentation has good defaults optimized to particular use-cases (long-form documents, financial documents, presentations). The first lesson of ML is that the more we know about the data, the better we do (bootstrapping). Automation is automating more of what additional information we can discover about the data.
Academic work on summary-based embeddings: https://arxiv.org/abs/2305.12517

Definitely keen to integrate this (currently not publically available)

Commit message: commit

Patch to review:
diff --git a/examples/metadata_extraction/MetadataExtractionSEC.ipynb b/examples/metadata_extraction/MetadataExtractionSEC.ipynb
new file mode 100644
index 0000000000..2a4b6f74ff
--- /dev/null
+++ b/examples/metadata_extraction/MetadataExtractionSEC.ipynb
@@ -0,0 +1,560 @@
+{
+    "cells": [
+        {
+            "cell_type": "markdown",
+            "id": "b07531d9-7473-480d-bee6-c1ee4cbc207c",
+            "metadata": {},
+            "source": [
+                "# Extracting Metadata for Better Document Indexing and Understanding\n",
+                "#### In many cases, especially with long documents, a chunk of text may lack the context necessary to disambiguate the chunk from other similar chunks of text. One method of addressing this is manually labelling each chunk in our dataset or knowledge base. However, this can be labour intensive and time consuming for a large number or continually updated set of documents.\n",
+                "#### To combat this, we use LLMs to extract certain contextual information relevant to the document to better help the retrieval and language models disambiguate similar-looking passages."
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 5,
+            "id": "0adb8e4a-6728-4073-8256-8b3be4ab1e64",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "from llama_index import ListIndex, LLMPredictor\n",
+                "from langchain import OpenAI\n",
+                "from llama_index import download_loader, VectorStoreIndex, ServiceContext\n",
+                "from llama_index.schema import MetadataMode"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 6,
+            "id": "a0231dff-7443-46bf-9b9d-759198d3408e",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "llm_predictor = LLMPredictor(\n",
+                "    llm=OpenAI(temperature=0, model_name=\"text-davinci-003\", max_tokens=512)\n",
+                ")"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "2db2cf90-f295-4a3d-a47c-4b2b1dd2d7c5",
+            "metadata": {},
+            "source": [
+                "We create a node parser that extracts the document title and hypothetical question embeddings relevant to the document chunk."
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 7,
+            "id": "3bda151d-6fb8-427e-82fc-0f3bb469d705",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "from llama_index.node_parser import MetadataExtractor, SimpleNodeParser\n",
+                "from llama_index.langchain_helpers.text_splitter import TokenTextSplitter\n",
+                "\n",
+                "text_splitter = TokenTextSplitter(separator=\" \", chunk_size=512, chunk_overlap=128)\n",
+                "\n",
+                "\n",
+                "def custom(nodes):\n",
+                "    for node in nodes:\n",
+                "        node.metadata[\"custom\"] = (\n",
+                "            node.metadata[\"document_title\"]\n",
+                "            + \"\\n-----\\n\"\n",
+                "            + node.metadata[\"excerpt_keywords\"]\n",
+                "        )\n",
+                "\n",
+                "\n",
+                "post_processors = [\n",
+                "    MetadataExtractor(llm_predictor=llm_predictor)\n",
+                "    .extract_title(nodes=5)\n",
+                "    .extract_questions_answered(questions=3)\n",
+                "    # .extract_summary(summaries=[\"prev\", \"self\"])\n",
+                "    # .extract_keywords(keywords=10)\n",
+                "    # .extract(custom)\n",
+                "    .build()\n",
+                "]\n",
+                "\n",
+                "node_parser = SimpleNodeParser(\n",
+                "    text_splitter=text_splitter,\n",
+                "    post_processors=post_processors,\n",
+                ")"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 8,
+            "id": "c72c45a9-dcad-4925-b2f7-d25fe5d80c2d",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "from llama_index import SimpleDirectoryReader, DocumentSummaryIndex"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "e4e54937-e9e7-48ed-8600-72cd2f3c529b",
+            "metadata": {},
+            "source": [
+                "We first load the 10k annual SEC report for Uber and Lyft for the years 2019 and 2020 respectively."
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 9,
+            "id": "38a46bf6-9539-4ac2-ad97-eb909992b94d",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "# Note the uninformative document file name, which may be a common scenario in a production setting\n",
+                "uber_docs = SimpleDirectoryReader(input_files=[\"data/10k-132.pdf\"]).load_data()\n",
+                "uber_front_pages = uber_docs[0:3]\n",
+                "uber_content = uber_docs[63:69]\n",
+                "uber_docs = uber_front_pages + uber_content"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 10,
+            "id": "269f8ecc-489d-435f-9d81-a9c64fd4d400",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "uber_nodes = node_parser.get_nodes_from_documents(uber_docs)"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 11,
+            "id": "8da4d824-d518-4d37-8322-a35adac05157",
+            "metadata": {},
+            "outputs": [
+                {
+                    "data": {
+                        "text/plain": [
+                            "{'page_label': '2',\n",
+                            " 'file_name': '10k-132.pdf',\n",
+                            " 'document_title': 'Uber Technologies, Inc. 2019 Annual Report: Revolutionizing Mobility and Logistics with Technology and Infrastructure.',\n",
+                            " 'questions_this_excerpt_can_answer': \"\\n1. What is the total number of countries served by Uber's platform?\\n2. How much gross bookings did Uber generate in 2019?\\n3. What is the total number of trips facilitated by Uber's platform?\"}"
+                        ]
+                    },
+                    "execution_count": 11,
+                    "metadata": {},
+                    "output_type": "execute_result"
+                }
+            ],
+            "source": [
+                "uber_nodes[1].metadata"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 12,
+            "id": "93e70bfb-6c02-401b-be91-3827f358b22c",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "# Note the uninformative document file name, which may be a common scenario in a production setting\n",
+                "lyft_docs = SimpleDirectoryReader(input_files=[\"data/10k-vFinal.pdf\"]).load_data()\n",
+                "lyft_front_pages = lyft_docs[0:3]\n",
+                "lyft_content = lyft_docs[68:73]\n",
+                "lyft_docs = lyft_front_pages + lyft_content"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 13,
+            "id": "e3720b40-c50c-4185-aaf4-289ff8ab057e",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "lyft_nodes = node_parser.get_nodes_from_documents(lyft_docs)"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 14,
+            "id": "98740f96-afdd-45ff-bcc0-2c50965a7349",
+            "metadata": {},
+            "outputs": [
+                {
+                    "data": {
+                        "text/plain": [
+                            "{'page_label': '2',\n",
+                            " 'file_name': '10k-vFinal.pdf',\n",
+                            " 'document_title': \"Lyft, Inc. Filing and Attestation of Management's Assessment Report, Filer Status and Stock Information, and Proxy Statement Incorporation in Annual Report on Form 10-K with Historical Consolidated Statements of Operations Data\",\n",
+                            " 'questions_this_excerpt_can_answer': \"\\n1. What is the status of the registrant as a filer?\\n2. Has the registrant filed a report on and attestation to its management's assessment of the effectiveness of its internal control over financial reporting?\\n3. What is the aggregate market value of the Registrant's common stock held by non-affiliates of the Registrant on June 30, 2020?\"}"
+                        ]
+                    },
+                    "execution_count": 14,
+                    "metadata": {},
+                    "output_type": "execute_result"
+                }
+            ],
+            "source": [
+                "lyft_nodes[2].metadata"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "ddd5805f-c459-40ae-a21c-5fa0de750a60",
+            "metadata": {},
+            "source": [
+                "Since we are asking fairly sophisticated questions, we utilize a subquestion query engine for all QnA pipelines below, and prompt it to pay more attention to the relevance of the retrieved sources. "
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 16,
+            "id": "302bb085-86cc-4b76-a452-67bc826b292d",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "from llama_index.question_gen.llm_generators import LLMQuestionGenerator\n",
+                "from llama_index.question_gen.prompts import DEFAULT_SUB_QUESTION_PROMPT_TMPL\n",
+                "\n",
+                "service_context = ServiceContext.from_defaults(\n",
+                "    llm_predictor=llm_predictor, node_parser=node_parser\n",
+                ")\n",
+                "question_gen = LLMQuestionGenerator.from_defaults(\n",
+                "    service_context=service_context,\n",
+                "    prompt_template_str=\"\"\"\n",
+                "        Follow the example, but instead of giving a question, always prefix the question \n",
+                "        with: 'By first identifying and quoting the most relevant sources, '. \n",
+                "        \"\"\"\n",
+                "    + DEFAULT_SUB_QUESTION_PROMPT_TMPL,\n",
+                ")"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "12cd959f-80ac-4d10-9246-d27cc6c1096a",
+            "metadata": {},
+            "source": [
+                "## Querying an Index With No Extra Metadata"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 17,
+            "id": "37dd8992-3716-44da-9309-154fb5946e98",
+            "metadata": {},
+            "outputs": [
+                {
+                    "name": "stdout",
+                    "output_type": "stream",
+                    "text": [
+                        "LLM sees:\n",
+                        " [Excerpt from document]\n",
+                        "page_label: 65\n",
+                        "file_name: 10k-132.pdf\n",
+                        "            Excerpt:\n",
+                        "-----\n",
+                        "See the section titled “Reconciliations of Non-GAAP Financial Measures” for our definition and a \n",
+                        "reconciliation of net income (loss) attributable to  Uber Technologies, Inc. to Adjusted EBITDA. \n",
+                        "            \n",
+                        "  Year Ended December 31,   2017 to 2018   2018 to 2019   \n",
+                        "(In millions, exce pt percenta ges)  2017   2018   2019   % Chan ge  % Chan ge  \n",
+                        "Adjusted EBITDA ................................  $ (2,642) $ (1,847) $ (2,725)  30%  (48)%\n",
+                        "-----\n"
+                    ]
+                }
+            ],
+            "source": [
+                "from copy import deepcopy\n",
+                "\n",
+                "nodes_no_metadata = deepcopy(uber_nodes) + deepcopy(lyft_nodes)\n",
+                "for node in nodes_no_metadata:\n",
+                "    node.metadata = {\n",
+                "        k: node.metadata[k] for k in node.metadata if k in [\"page_label\", \"file_name\"]\n",
+                "    }\n",
+                "print(\"LLM sees:\\n\", (nodes_no_metadata)[9].get_content(metadata_mode=MetadataMode.LLM))"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 18,
+            "id": "b8ff619d-67ed-4263-bfc7-2a7a1b7320e7",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "from llama_index import VectorStoreIndex\n",
+                "from llama_index.vector_stores import FaissVectorStore\n",
+                "from llama_index.query_engine import SubQuestionQueryEngine\n",
+                "from llama_index.tools import QueryEngineTool, ToolMetadata"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 19,
+            "id": "028a65d7-8065-4798-acec-1c3486633e14",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "index_no_metadata = VectorStoreIndex(nodes=nodes_no_metadata)\n",
+                "engine_no_metadata = index_no_metadata.as_query_engine(similarity_top_k=10)"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 20,
+            "id": "73ea9e05-ff5a-49b6-8e52-139d156cde47",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "final_engine_no_metadata = SubQuestionQueryEngine.from_defaults(\n",
+                "    query_engine_tools=[\n",
+                "        QueryEngineTool(\n",
+                "            query_engine=engine_no_metadata,\n",
+                "            metadata=ToolMetadata(\n",
+                "                name=\"sec_filing_documents\",\n",
+                "                description=\"financial information on companies\",\n",
+                "            ),\n",
+                "        )\n",
+                "    ],\n",
+                "    question_gen=question_gen,\n",
+                "    use_async=False,\n",
+                ")"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 21,
+            "id": "fd5a3e51-e252-4e24-bc2b-fbc32ce078dd",
+            "metadata": {},
+            "outputs": [
+                {
+                    "name": "stdout",
+                    "output_type": "stream",
+                    "text": [
+                        "Generated 4 sub questions.\n",
+                        "\u001b[36;1m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to research and development for Uber in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[36;1m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the excerpt from page 70 of the document 10k-vFinal.pdf, research and development expenses for Uber in 2019 were $909.1 million in thousands of USD, before income taxes and loss from equity method investment. Therefore, the cost due to research and development for Uber in 2019 in millions of USD was $909.1 million.\n",
+                        "\u001b[0m\u001b[33;1m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to sales and marketing for Uber in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[33;1m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the document \"10k-vFinal.pdf\" on page 69, the cost due to sales and marketing for Uber in 2019 was $397.8 million in thousands of USD, or $397,800,000 in millions of USD. This cost was part of the overall increase in Adjusted EBITDA loss of $878 million, or 48%, primarily due to continued investments within Uber's non-Rides offerings and an increase in corporate overhead.\n",
+                        "\u001b[0m\u001b[38;5;200m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to research and development for Lyft in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[38;5;200m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the document \"10k-132.pdf\" on page 69, the cost of research and development for Lyft in 2019 was $232.1 million in thousands of USD. This figure is supported by the financial statements on page 2 of the document \"10k-vFinal.pdf\", which show that Lyft had a net loss of $458,155 thousand in 2019.\n",
+                        "\u001b[0m\u001b[32;1m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to sales and marketing for Lyft in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[32;1m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the excerpt from page_label: 65, file_name: 10k-132.pdf, the cost due to sales and marketing for Lyft in 2019 was $814.1 million in thousands of USD.\n",
+                        "\u001b[0m"
+                    ]
+                },
+                {
+                    "data": {
+                        "text/plain": [
+                            "'Answer: \\n{\\n    \"Uber\": {\\n        \"Research and Development\": 909.1,\\n        \"Sales and Marketing\": 397.8\\n    },\\n    \"Lyft\": {\\n        \"Research and Development\": 232.1,\\n        \"Sales and Marketing\": 814.1\\n    }\\n}'"
+                        ]
+                    },
+                    "execution_count": 21,
+                    "metadata": {},
+                    "output_type": "execute_result"
+                }
+            ],
+            "source": [
+                "response_no_metadata = final_engine_no_metadata.query(\n",
+                "    \"\"\"\n",
+                "    What was the cost due to research and development v.s. sales and marketing for uber and lyft in 2019 in millions of USD?\n",
+                "    Give your answer as a JSON.\n",
+                "    \"\"\"\n",
+                ")\n",
+                "response_no_metadata.response\n",
+                "# Correct answer:\n",
+                "# {\"Uber\": {\"Research and Development\": 4836, \"Sales and Marketing\": 4626},\n",
+                "#  \"Lyft\": {\"Research and Development\": 1505.6, \"Sales and Marketing\": 814 }}"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "e9dafdad-c18c-4e0f-8a35-b691ca73e1f2",
+            "metadata": {},
+            "source": [
+                "#### **RESULT**: As we can see, the QnA agent does not seem to know where to look for the right documents. As a result it gets only 1/4 of the subquestions right."
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "9878905f-c47d-46b2-9ad9-063538e717e1",
+            "metadata": {},
+            "source": [
+                "## Querying an Index With Extracted Metadata"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 22,
+            "id": "97f00a18-e9e6-47db-bef5-cbf5bb5016be",
+            "metadata": {},
+            "outputs": [
+                {
+                    "name": "stdout",
+                    "output_type": "stream",
+                    "text": [
+                        "LLM sees:\n",
+                        " [Excerpt from document]\n",
+                        "page_label: 65\n",
+                        "file_name: 10k-132.pdf\n",
+                        "document_title: Uber Technologies, Inc. 2019 Annual Report: Revolutionizing Mobility and Logistics with Technology and Infrastructure.\n",
+                        "            Excerpt:\n",
+                        "-----\n",
+                        "See the section titled “Reconciliations of Non-GAAP Financial Measures” for our definition and a \n",
+                        "reconciliation of net income (loss) attributable to  Uber Technologies, Inc. to Adjusted EBITDA. \n",
+                        "            \n",
+                        "  Year Ended December 31,   2017 to 2018   2018 to 2019   \n",
+                        "(In millions, exce pt percenta ges)  2017   2018   2019   % Chan ge  % Chan ge  \n",
+                        "Adjusted EBITDA ................................  $ (2,642) $ (1,847) $ (2,725)  30%  (48)%\n",
+                        "-----\n"
+                    ]
+                }
+            ],
+            "source": [
+                "print(\n",
+                "    \"LLM sees:\\n\",\n",
+                "    (uber_nodes + lyft_nodes)[9].get_content(metadata_mode=MetadataMode.LLM),\n",
+                ")"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 23,
+            "id": "c7d255de-3034-4035-93bc-45d535ce1700",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "index = VectorStoreIndex(nodes=uber_nodes + lyft_nodes)\n",
+                "engine = index.as_query_engine(similarity_top_k=10)"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 24,
+            "id": "bbe42516-a2ca-4986-9012-cb15682323f5",
+            "metadata": {},
+            "outputs": [],
+            "source": [
+                "final_engine = SubQuestionQueryEngine.from_defaults(\n",
+                "    query_engine_tools=[\n",
+                "        QueryEngineTool(\n",
+                "            query_engine=engine,\n",
+                "            metadata=ToolMetadata(\n",
+                "                name=\"sec_filing_documents\",\n",
+                "                description=\"financial information on companies.\",\n",
+                "            ),\n",
+                "        )\n",
+                "    ],\n",
+                "    question_gen=question_gen,\n",
+                "    use_async=False,\n",
+                ")"
+            ]
+        },
+        {
+            "cell_type": "code",
+            "execution_count": 25,
+            "id": "f48ac2d9-58e9-4b98-9bad-b8ce1eea7934",
+            "metadata": {},
+            "outputs": [
+                {
+                    "name": "stdout",
+                    "output_type": "stream",
+                    "text": [
+                        "Generated 4 sub questions.\n",
+                        "\u001b[36;1m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to research and development for Uber in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[36;1m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the excerpt from page 69 of the document \"Uber Technologies, Inc. 2019 Annual Report: Revolutionizing Mobility and Logistics with Technology and Infrastructure.\", the cost due to research and development for Uber in 2019 was $4.836 billion in millions of USD. This is stated in the following excerpt: \"Research and development .....................................................................  $1,201  $1,505  $4,836\". This cost is exclusive of depreciation and amortization, and includes fees, hosting and co-located data center expenses, and other cost of revenue. It is driven by investments in the business, including excess Driver incentives, and the Freight offering and New Mobility products.\n",
+                        "\u001b[0m\u001b[33;1m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to sales and marketing for Uber in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[33;1m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the excerpt from page_label: 69, file_name: 10k-132.pdf, and document_title: Uber Technologies, Inc. 2019 Annual Report: Revolutionizing Mobility and Logistics with Technology and Infrastructure, the cost due to sales and marketing for Uber in 2019 in millions of USD was $4,626. This can be found in the table under the heading \"Year Ended December 31, 2017 2018 2019\" and the row labeled \"Sales and marketing\". This cost includes expenses related to marketing campaigns, advertising, and other sales and marketing activities.\n",
+                        "\u001b[0m\u001b[38;5;200m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to research and development for Lyft in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[38;5;200m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the document titled \"Lyft, Inc. Filing and Attestation of Management's Assessment Report, Filer Status and Stock Information, and Proxy Statement Incorporation in Annual Report on Form 10-K with Historical Consolidated Statements of Operations Data\" on page 69, the cost due to research and development for Lyft in 2019 was $1,505 million in USD, representing a 40% decrease from the prior year. This decrease was primarily due to a $609.6 million reduction in stock-based compensation expense primarily attributable to (i) the use of the accelerated attribution method to recognize expenses for RSUs granted prior to the effectiveness of the company's IPO Registration Statement which resulted in higher stock-based compensation expense for the year ended December 31, 2019, and (ii) the stock-based compensation benefit related to the restructuring in the second quarter of 2020.\n",
+                        "\u001b[0m\u001b[32;1m\u001b[1;3m[sec_filing_documents] Q: By first identifying and quoting the most relevant sources, what was the cost due to sales and marketing for Lyft in 2019 in millions of USD?\n",
+                        "\u001b[0m\u001b[32;1m\u001b[1;3m[sec_filing_documents] A: \n",
+                        "\n",
+                        "According to the document excerpt, the cost of sales and marketing for Lyft in 2019 was $814.122 million in USD. This can be found in the table on page 69 of the document, which states that the cost of sales and marketing for 2019 was $814.122 million. This is further supported by the table on page 73 of the document, which states that sales and marketing expenses decreased $397.8 million, or 49%, in the year ended December 31, 2020 as compared to the prior year. This is also supported by the table on page 70 of the document, which states that sales and marketing expenses decreased $246.5 million, primarily due to a reduction in rider incentives beginning in the second quarter of 2020 through the fourth quarter of 2020.\n",
+                        "\u001b[0m"
+                    ]
+                },
+                {
+                    "data": {
+                        "text/plain": [
+                            "'Answer: \\n{\\n    \"Uber\": {\\n        \"Research and Development\": 4836,\\n        \"Sales and Marketing\": 4626\\n    },\\n    \"Lyft\": {\\n        \"Research and Development\": 1505,\\n        \"Sales and Marketing\": 814.122\\n    }\\n}'"
+                        ]
+                    },
+                    "execution_count": 25,
+                    "metadata": {},
+                    "output_type": "execute_result"
+                }
+            ],
+            "source": [
+                "response = final_engine.query(\n",
+                "    \"\"\"\n",
+                "    What was the cost due to research and development v.s. sales and marketing for uber and lyft in 2019 in millions of USD?\n",
+                "    Give your answer as a JSON.\n",
+                "    \"\"\"\n",
+                ")\n",
+                "response.response\n",
+                "# Correct answer:\n",
+                "# {\"Uber\": {\"Research and Development\": 4836, \"Sales and Marketing\": 4626},\n",
+                "#  \"Lyft\": {\"Research and Development\": 1505.6, \"Sales and Marketing\": 814 }}"
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "8bee6d91-84f4-4bde-89dc-d010f9aebc3e",
+            "metadata": {},
+            "source": [
+                "#### **RESULT**: As we can see, the LLM answers the questions correctly."
+            ]
+        },
+        {
+            "cell_type": "markdown",
+            "id": "14826bae-4032-4886-87a8-50f9f28d7ace",
+            "metadata": {},
+            "source": [
+                "### Challenges Identified in the Problem Domain\n",
+                "\n",
+                "In this example, we observed that the search quality as provided by vector embeddings was rather poor. This was likely due to highly dense financial documents that were likely not representative of the training set for the model.\n",
+                "\n",
+                "In order to improve the search quality, other methods of neural search that employ more keyword-based approaches may help, such as ColBERTv2/PLAID. In particular, this would help in matching on particular keywords to identify high-relevance chunks.\n",
+                "\n",
+                "Other valid steps may include utilizing models that are fine-tuned on financial datasets such as Bloomberg GPT.\n",
+                "\n",
+                "Finally, we can help to further enrich the metadata by providing more contextual information regarding the surrounding context that the chunk is located in.\n",
+                "\n",
+                "### Improvements to this Example\n",
+                "Generally, this example can be improved further with more rigorous evaluation of both the metadata extraction accuracy, and the accuracy and recall of the QnA pipeline. Further, incorporating a larger set of documents as well as the full length documents, which may provide more confounding passages that are difficult to disambiguate, could further stresss test the system we have built and suggest further improvements. "
+            ]
+        }
+    ],
+    "metadata": {
+        "kernelspec": {
+            "display_name": "Python 3 (ipykernel)",
+            "language": "python",
+            "name": "python3"
+        },
+        "language_info": {
+            "codemirror_mode": {
+                "name": "ipython",
+                "version": 3
+            },
+            "file_extension": ".py",
+            "mimetype": "text/x-python",
+            "name": "python",
+            "nbconvert_exporter": "python",
+            "pygments_lexer": "ipython3",
+            "version": "3.10.6"
+        }
+    },
+    "nbformat": 4,
+    "nbformat_minor": 5
+}
diff --git a/llama_index/chat_engine/simple.py b/llama_index/chat_engine/simple.py
index 98d8c568a7..4309c06600 100644
--- a/llama_index/chat_engine/simple.py
+++ b/llama_index/chat_engine/simple.py
@@ -12,6 +12,8 @@ class SimpleChatEngine(BaseChatEngine):
 
     Have a conversation with the LLM.
     This does not make use of a knowledge base.
+
+    # TODO: add back ability to configure prompt/system message
     """
 
     def __init__(
diff --git a/llama_index/node_parser/__init__.py b/llama_index/node_parser/__init__.py
index b33740e7f2..fd1ec185f9 100644
--- a/llama_index/node_parser/__init__.py
+++ b/llama_index/node_parser/__init__.py
@@ -1,6 +1,15 @@
 """Node parsers."""
 
-from llama_index.node_parser.simple import SimpleNodeParser
 from llama_index.node_parser.interface import NodeParser
+from llama_index.node_parser.simple import SimpleNodeParser
+from llama_index.node_parser.postprocessors.metadata_extractors import (
+    MetadataExtractor,
+    MetadataExtractorBase,
+)
 
-__all__ = ["SimpleNodeParser", "NodeParser"]
+__all__ = [
+    "SimpleNodeParser",
+    "NodeParser",
+    "MetadataExtractor",
+    "MetadataExtractorBase",
+]
diff --git a/llama_index/node_parser/interface.py b/llama_index/node_parser/interface.py
index 6b3af2f04b..c843d87af0 100644
--- a/llama_index/node_parser/interface.py
+++ b/llama_index/node_parser/interface.py
@@ -22,3 +22,18 @@ def get_nodes_from_documents(
             documents (Sequence[Document]): documents to parse
 
         """
+
+
+class NodeParserPostProcessor(ABC):
+    """Base interface for node parser post processor."""
+
+    @abstractmethod
+    def post_process_nodes(
+        self,
+        nodes: List[BaseNode],
+    ) -> None:
+        """Post process nodes parsed from documents.
+
+        Args:
+            nodes (List[BaseNode]): nodes to post-process
+        """
diff --git a/llama_index/node_parser/postprocessors/metadata_extractors.py b/llama_index/node_parser/postprocessors/metadata_extractors.py
new file mode 100644
index 0000000000..2ea4748bc5
--- /dev/null
+++ b/llama_index/node_parser/postprocessors/metadata_extractors.py
@@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
+        .extract_title(nodes=3)
+        .extract_questions_answered(questions=3)
+        .extract_keywords(
+            node_template="Context: {context_str}. 10 Keywords: "
+        )
+        .extract_summary(summaries=['self', 'prev', 'next'])
+        .extract(lambda nodes:
+            for node in nodes:
+                node.metadata["custom"]) = llm_predictor.predict(
+                    prompt,
+                    node.get_content()
+                )
+        )
+        .finish()
+
+    node_parser = SimpleNodeParser(..., post_processors=[post_processor])
+    ```
+    """
+
+    def __init__(
+        self,
+        llm_predictor: Optional[BaseLLMPredictor],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._llm_predictor = llm_predictor or LLMPredictor()
+        self._extractors: List[Callable[[Sequence[BaseNode]], None]] = []
+        self._node_text_template = node_text_template
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def extract_title(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract document title for the sequence of nodes.
+        Args:
+            nodes (int): number of nodes (from front of sequence)
+                to use for title extraction
+            node_template (str): template for node-level title
+                clues extraction
+            combine_template (str): template for combining node-level
+                clues into a document-level title
+        """
+        self._extractors.append(
+            TitleExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_questions_answered(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract questions answered by the node.
+        Args:
+            questions (int): number of questions to extract
+            prompt_template (str): template for question extraction
+        """
+        self._extractors.append(
+            QuestionsAnsweredExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_keywords(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract keywords for the node.
+        Args:
+            keywords (int): number of keywords to extract
+        """
+        self._extractors.append(
+            KeywordExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_summary(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract summary for the node.
+        Args:
+            summaries (List[str]): list of summaries to extract: "self", "prev", "next"
+            prompt_template (str): template for summary extraction"""
+        self._extractors.append(
+            SummaryExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract(
+        self, extractor: Callable[[Sequence[BaseNode]], None]
+    ) -> "MetadataExtractor":
+        """Extract metadata using a custom extractor.
+        Args:
+            extractor (Callable[[Sequence[BaseNode]], None]):
+                Callable that takes a sequence of nodes and mutates the
+                metadata of the nodes in place. It can be a custom class with an
+                  appropriate __call__ or a simply a function/lambda function.
+        """
+        self._extractors.append(extractor)
+        return self
+
+    def build(self) -> "MetadataAugmentationPostProcessor":
+        return MetadataAugmentationPostProcessor(
+            self._extractors, self._node_text_template, self._disable_template_rewrite
+        )
+
+
+class MetadataAugmentationPostProcessor(NodeParserPostProcessor):
+    def __init__(
+        self,
+        extractors: Sequence[Callable[[Sequence[BaseNode]], None]],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._extractors = extractors
+        self._node_text_template = (
+            node_text_template
+            or """\
+            [Excerpt from document]\n{metadata_str}\n\
+            Excerpt:\n-----\n{content}\n-----\n"""
+        )
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def post_process_nodes(self, nodes: Sequence[BaseNode]) -> None:
+        """Extract metadata from a document.
+
+        Args:
+            nodes (Sequence[BaseNode]): nodes to extract metadata from
+
+        """
+        if not self._disable_template_rewrite:
+            for node in nodes:
+                if isinstance(node, TextNode):
+                    cast(TextNode, node).text_template = self._node_text_template
+        for extractor in self._extractors:
+            extractor(nodes)
+
+
+class TitleExtractor(MetadataExtractorBase):
+    """Title extractor. Useful for long documents. Extracts `document_title`
+    metadata field.
+    Args:
+        nodes (int): number of nodes from front to use for title extraction
+        node_template (str): template for node-level title clues extraction
+        combine_template (str): template for combining node-level clues into
+            a document-level title
+    """
+
+    def __init__(
+        self,
+        llm_predictor: BaseLLMPredictor,
+        nodes: int = 5,
+        node_template: Optional[str] = None,
+        combine_template: Optional[str] = None,
+    ) -> None:
+        """Init params."""
+        if nodes < 1:
+            raise ValueError("num_nodes must be >= 1")
+        self._nodes = nodes
+        self._node_template = node_template
+        self._combine_template = combine_template
+        self._llm_predictor = llm_predictor
+
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        nodes_to_extract_title: List[BaseNode] = []
+        for node in nodes:
+            if len(nodes_to_extract_title) >= self._nodes:
+                break
+            if self.is_text_node_only and not isinstance(node, TextNode):
+                continue
+            nodes_to_extract_title.append(node)
+
+        if len(nodes_to_extract_title) == 0:
+            # Could not extract title
+            return
+
+        title_candidates = [
+            self._llm_predictor.predict(
+                Prompt(
+                    template=self._node_template
+                    or """\
+Context: {context_str}. Give a title that summarizes all of \
+the unique entities, titles or themes found in the context. Title: \
+                    """
+                ),
+                context_str=cast(TextNode, node).text,
+            )
+            for node in nodes_to_extract_title
+        ]
+        if len(nodes_to_extract_title) > 1:
+            titles = reduce(
+                lambda x, y: x + "," + y, title_candidates[1:], title_candidates[0]
+            )
+
+            title = self._llm_predictor.predict(
+                Prompt(
+                    template=self._combine_template
+                    or """\
+{context_str}. Based on the above candidate titles and content, \
+what is the comprehensive title for this document? Title: \
+                    """
+                ),
+                context_str=titles,
+            )
+        else:
+            title = title_candidates[
+                0
+            ]  # if single node, just use the title from that node
+
+        for node in nodes:
+            node.metadata["document_title"] = title.strip(' \t\n\r"')
+
+
+class KeywordExtractor(MetadataExtractorBase):
+    """Keyword extractor. Node-level extractor. Extracts
+    `excerpt_keywords` metadata field.
+    Args:
+        llm_predictor (BaseLLMPredictor): LLM predictor
+        keywords (int): number of keywords to extract
+    """
+
+    def __init__(
+        self,
+        llm_predictor: BaseLLMPredictor,
+        keywords: int = 5,
+    ) -> None:
+        """Init params."""
+        self._llm_predictor = llm_predictor
+        if keywords < 1:
+            raise ValueError("num_keywords must be >= 1")
+        self._keywords = keywords
+
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        for node in nodes:
+            if self.is_text_node_only and not isinstance(node, TextNode):
+                continue
+            keywords = self._llm_predictor.predict(
+                Prompt(
+                    template=f"""\
+{{context_str}}. Give {self._keywords} unique keywords for this \
+document. Format as comma separated. Keywords: """
+                ),
+                context_str=cast(TextNode, node).text,
+            )
+            node.metadata["excerpt_keywords"] = keywords
+
+
+class QuestionsAnsweredExtractor(MetadataExtractorBase):
+    """
+    Questions answered extractor. Node-level extractor.
+    Extracts `questions_this_excerpt_can_answer` metadata field.
+    Args:
+        llm_predictor (BaseLLMPredictor): LLM predictor
+        questions (int): number of questions to extract
+        prompt_template (str): template for question extraction
+    """
+
+    def __init__(
+        self,
+        llm_predictor: BaseLLMPredictor,
+        questions: int = 5,
+        prompt_template: Optional[str] = None,
+    ) -> None:
+        """Init params."""
+        if questions < 1:
+            raise ValueError("questions must be >= 1")
+        self._llm_predictor = llm_predictor
+        self._questions = questions
+        self._prompt_template = prompt_template
+
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        for node in nodes:
+            if self.is_text_node_only and not isinstance(node, TextNode):
+                continue
+            # Extract the title from the first node
+            questions = self._llm_predictor.predict(
+                Prompt(
+                    template=self._prompt_template
+                    or f"""\
+{{context_str}}. Given the contextual information, \
+generate {self._questions} questions this document can provide \
+specific answers to which are unlikely to be found elsewhere: \
+                """
+                ),
+                context_str=f"""\
+metadata: {json.dumps(node.metadata)} \
+content: {cast(TextNode, node).text}""",
+            )
+            node.metadata["questions_this_excerpt_can_answer"] = questions
+            # Only use this for the embedding
+            node.excluded_llm_metadata_keys = ["questions_this_excerpt_can_answer"]
+
+
+class SummaryExtractor(MetadataExtractorBase):
+    """
+    Summary extractor. Node-level extractor with adjacent sharing.
+    Extracts `section_summary`, `prev_section_summary`, `next_section_summary`
+    metadata fields
+    Args:
+        llm_predictor (BaseLLMPredictor): LLM predictor
+        summaries (List[str]): list of summaries to extract: 'self', 'prev', 'next'
+        prompt_template (str): template for summary extraction"""
+
+    def __init__(
+        self,
+        llm_predictor: BaseLLMPredictor,
+        summaries: List[str] = ["self"],
+        prompt_template: Optional[str] = None,
+    ):
+        self._llm_predictor = llm_predictor
+        # validation
+        if not all([s in ["self", "prev", "next"] for s in summaries]):
+            raise ValueError("summaries must be one of ['self', 'prev', 'next']")
+        self._self_summary = "self" in summaries
+        self._prev_summary = "prev" in summaries
+        self._next_summary = "next" in summaries
+        self._prompt_template = prompt_template
+
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        node_summaries = [
+            self._llm_predictor.predict(
+                Prompt(
+                    template=self._prompt_template
+                    or """\
+Here is the content of the section: {context_str}. \
+Summarize the key topics and entities of the section. Summary: \
+                    """
+                ),
+                context_str=cast(TextNode, node).text,
+            )
+            for node in nodes
+        ]
+
+        if self._embedding_only:
+            excluded_llm_metadata_keys = []
+            if self._self_summary:
+                excluded_llm_metadata_keys.append("section_summary")
+            if self._prev_summary:
+                excluded_llm_metadata_keys.append("prev_section_summary")
+            if self._next_summary:
+                excluded_llm_metadata_keys.append("next_section_summary")
+
+        # Extract node-level summary metadata
+        for i, node in enumerate(nodes):
+            if i > 0 and self._prev_summary:
+                node.metadata["prev_section_summary"] = node_summaries[i - 1]
+            if i < len(nodes) - 1 and self._next_summary:
+                node.metadata["next_section_summary"] = node_summaries[i + 1]
+            if self._self_summary:
+                node.metadata["section_summary"] = node_summaries[i]
+            if self._embedding_only:
+                node.excluded_llm_metadata_keys = excluded_llm_metadata_keys
diff --git a/llama_index/node_parser/simple.py b/llama_index/node_parser/simple.py
index 72f0191968..9494fb33e1 100644
--- a/llama_index/node_parser/simple.py
+++ b/llama_index/node_parser/simple.py
@@ -5,7 +5,7 @@
 from llama_index.callbacks.schema import CBEventType, EventPayload
 from llama_index.constants import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE
 from llama_index.langchain_helpers.text_splitter import TextSplitter, TokenTextSplitter
-from llama_index.node_parser.interface import NodeParser
+from llama_index.node_parser.interface import NodeParser, NodeParserPostProcessor
 from llama_index.node_parser.node_utils import get_nodes_from_document
 from llama_index.utils import get_tqdm_iterable
 from llama_index.schema import Document
@@ -30,6 +30,7 @@ def __init__(
         include_metadata: bool = True,
         include_prev_next_rel: bool = True,
         callback_manager: Optional[CallbackManager] = None,
+        post_processors: Optional[List[NodeParserPostProcessor]] = None,
     ) -> None:
         """Init params."""
         self.callback_manager = callback_manager or CallbackManager([])
@@ -38,6 +39,7 @@ def __init__(
         )
         self._include_metadata = include_metadata
         self._include_prev_next_rel = include_prev_next_rel
+        self._post_processors = post_processors or []
 
     @classmethod
     def from_defaults(
@@ -46,6 +48,7 @@ def from_defaults(
         chunk_overlap: Optional[int] = None,
         include_metadata: bool = True,
         include_prev_next_rel: bool = True,
+        post_processors: Optional[List[NodeParserPostProcessor]] = None,
         callback_manager: Optional[CallbackManager] = None,
     ) -> "SimpleNodeParser":
         callback_manager = callback_manager or CallbackManager([])
@@ -95,6 +98,10 @@ def get_nodes_from_documents(
                 include_prev_next_rel=self._include_prev_next_rel,
             )
             all_nodes.extend(nodes)
+
+        for post_processor in self._post_processors:
+            post_processor.post_process_nodes(all_nodes)
+
         self.callback_manager.on_event_end(
             CBEventType.NODE_PARSING,
             payload={EventPayload.NODES: all_nodes},


Code review comments:
diff_hunk: @@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
+        .extract_title(nodes=3)
+        .extract_questions_answered(questions=3)
+        .extract_keywords(
+            node_template="Context: {context_str}. 10 Keywords: "
+        )
+        .extract_summary(summaries=['self', 'prev', 'next'])
+        .extract(lambda nodes:
path: llama_index/node_parser/postprocessors/metadata_extractors.py
text: @author:
This example doesn't actually work (not sure why) so maybe remove.

diff_hunk: @@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
+        .extract_title(nodes=3)
+        .extract_questions_answered(questions=3)
+        .extract_keywords(
+            node_template="Context: {context_str}. 10 Keywords: "
+        )
+        .extract_summary(summaries=['self', 'prev', 'next'])
+        .extract(lambda nodes:
+            for node in nodes:
+                node.metadata["custom"]) = llm_predictor.predict(
+                    prompt,
+                    node.get_content()
+                )
+        )
+        .finish()
+
+    node_parser = SimpleNodeParser(..., post_processors=[post_processor])
+    ```
+    """
+
+    def __init__(
+        self,
+        llm_predictor: Optional[BaseLLMPredictor],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._llm_predictor = llm_predictor or LLMPredictor()
+        self._extractors: List[Callable[[Sequence[BaseNode]], None]] = []
+        self._node_text_template = node_text_template
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def extract_title(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract document title for the sequence of nodes.
+        Args:
+            nodes (int): number of nodes (from front of sequence)
+                to use for title extraction
+            node_template (str): template for node-level title
+                clues extraction
+            combine_template (str): template for combining node-level
+                clues into a document-level title
+        """
+        self._extractors.append(
+            TitleExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_questions_answered(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract questions answered by the node.
+        Args:
+            questions (int): number of questions to extract
+            prompt_template (str): template for question extraction
+        """
+        self._extractors.append(
+            QuestionsAnsweredExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_keywords(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract keywords for the node.
+        Args:
+            keywords (int): number of keywords to extract
+        """
+        self._extractors.append(
+            KeywordExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_summary(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract summary for the node.
+        Args:
+            summaries (List[str]): list of summaries to extract: "self", "prev", "next"
+            prompt_template (str): template for summary extraction"""
+        self._extractors.append(
+            SummaryExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract(
+        self, extractor: Callable[[Sequence[BaseNode]], None]
+    ) -> "MetadataExtractor":
+        """Extract metadata using a custom extractor.
+        Args:
+            extractor (Callable[[Sequence[BaseNode]], None]):
+                Callable that takes a sequence of nodes and mutates the
+                metadata of the nodes in place. It can be a custom class with an
+                  appropriate __call__ or a simply a function/lambda function.
+        """
+        self._extractors.append(extractor)
+        return self
+
+    def build(self) -> "MetadataAugmentationPostProcessor":
+        return MetadataAugmentationPostProcessor(
+            self._extractors, self._node_text_template, self._disable_template_rewrite
+        )
+
+
+class MetadataAugmentationPostProcessor(NodeParserPostProcessor):
+    def __init__(
+        self,
+        extractors: Sequence[Callable[[Sequence[BaseNode]], None]],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._extractors = extractors
+        self._node_text_template = (
+            node_text_template
+            or """\
+            [Excerpt from document]\n{metadata_str}\n\
+            Excerpt:\n-----\n{content}\n-----\n"""
+        )
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def post_process_nodes(self, nodes: Sequence[BaseNode]) -> None:
+        """Extract metadata from a document.
+
+        Args:
+            nodes (Sequence[BaseNode]): nodes to extract metadata from
+
+        """
+        if not self._disable_template_rewrite:
+            for node in nodes:
+                if isinstance(node, TextNode):
+                    cast(TextNode, node).text_template = self._node_text_template
+        for extractor in self._extractors:
+            extractor(nodes)
+
+
+class TitleExtractor(MetadataExtractorBase):
+    """Title extractor. Useful for long documents. Extracts `document_title`
+    metadata field.
+    Args:
+        nodes (int): number of nodes from front to use for title extraction
+        node_template (str): template for node-level title clues extraction
+        combine_template (str): template for combining node-level clues into
+            a document-level title
+    """
+
+    def __init__(
+        self,
+        llm_predictor: BaseLLMPredictor,
+        nodes: int = 5,
+        node_template: Optional[str] = None,
+        combine_template: Optional[str] = None,
+    ) -> None:
+        """Init params."""
+        if nodes < 1:
+            raise ValueError("num_nodes must be >= 1")
+        self._nodes = nodes
+        self._node_template = node_template
+        self._combine_template = combine_template
+        self._llm_predictor = llm_predictor
+
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        nodes_to_extract_title: List[BaseNode] = []
+        for node in nodes:
+            if len(nodes_to_extract_title) >= self._nodes:
+                break
+            if self.is_text_node_only and not isinstance(node, TextNode):
+                continue
+            nodes_to_extract_title.append(node)
+
+        if len(nodes_to_extract_title) == 0:
+            # Could not extract title
+            return
+
+        title_candidates = [
+            self._llm_predictor.predict(
+                Prompt(
+                    template=self._node_template
+                    or """\
path: llama_index/node_parser/postprocessors/metadata_extractors.py
text: @user1:
this should be a constant

diff_hunk: @@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
+        .extract_title(nodes=3)
+        .extract_questions_answered(questions=3)
+        .extract_keywords(
+            node_template="Context: {context_str}. 10 Keywords: "
+        )
+        .extract_summary(summaries=['self', 'prev', 'next'])
+        .extract(lambda nodes:
+            for node in nodes:
+                node.metadata["custom"]) = llm_predictor.predict(
+                    prompt,
+                    node.get_content()
+                )
+        )
+        .finish()
+
+    node_parser = SimpleNodeParser(..., post_processors=[post_processor])
+    ```
+    """
+
+    def __init__(
+        self,
+        llm_predictor: Optional[BaseLLMPredictor],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._llm_predictor = llm_predictor or LLMPredictor()
+        self._extractors: List[Callable[[Sequence[BaseNode]], None]] = []
+        self._node_text_template = node_text_template
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def extract_title(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract document title for the sequence of nodes.
+        Args:
+            nodes (int): number of nodes (from front of sequence)
+                to use for title extraction
+            node_template (str): template for node-level title
+                clues extraction
+            combine_template (str): template for combining node-level
+                clues into a document-level title
+        """
+        self._extractors.append(
+            TitleExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_questions_answered(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract questions answered by the node.
+        Args:
+            questions (int): number of questions to extract
+            prompt_template (str): template for question extraction
+        """
+        self._extractors.append(
+            QuestionsAnsweredExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_keywords(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract keywords for the node.
+        Args:
+            keywords (int): number of keywords to extract
+        """
+        self._extractors.append(
+            KeywordExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_summary(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract summary for the node.
+        Args:
+            summaries (List[str]): list of summaries to extract: "self", "prev", "next"
+            prompt_template (str): template for summary extraction"""
+        self._extractors.append(
+            SummaryExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract(
+        self, extractor: Callable[[Sequence[BaseNode]], None]
+    ) -> "MetadataExtractor":
+        """Extract metadata using a custom extractor.
+        Args:
+            extractor (Callable[[Sequence[BaseNode]], None]):
+                Callable that takes a sequence of nodes and mutates the
+                metadata of the nodes in place. It can be a custom class with an
+                  appropriate __call__ or a simply a function/lambda function.
+        """
+        self._extractors.append(extractor)
+        return self
+
+    def build(self) -> "MetadataAugmentationPostProcessor":
+        return MetadataAugmentationPostProcessor(
+            self._extractors, self._node_text_template, self._disable_template_rewrite
+        )
+
+
+class MetadataAugmentationPostProcessor(NodeParserPostProcessor):
+    def __init__(
+        self,
+        extractors: Sequence[Callable[[Sequence[BaseNode]], None]],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._extractors = extractors
+        self._node_text_template = (
+            node_text_template
+            or """\
+            [Excerpt from document]\n{metadata_str}\n\
+            Excerpt:\n-----\n{content}\n-----\n"""
+        )
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def post_process_nodes(self, nodes: Sequence[BaseNode]) -> None:
+        """Extract metadata from a document.
+
+        Args:
+            nodes (Sequence[BaseNode]): nodes to extract metadata from
+
+        """
+        if not self._disable_template_rewrite:
+            for node in nodes:
+                if isinstance(node, TextNode):
+                    cast(TextNode, node).text_template = self._node_text_template
+        for extractor in self._extractors:
+            extractor(nodes)
+
+
+class TitleExtractor(MetadataExtractorBase):
+    """Title extractor. Useful for long documents. Extracts `document_title`
+    metadata field.
+    Args:
+        nodes (int): number of nodes from front to use for title extraction
+        node_template (str): template for node-level title clues extraction
+        combine_template (str): template for combining node-level clues into
+            a document-level title
+    """
+
+    def __init__(
+        self,
+        llm_predictor: BaseLLMPredictor,
+        nodes: int = 5,
+        node_template: Optional[str] = None,
+        combine_template: Optional[str] = None,
+    ) -> None:
+        """Init params."""
+        if nodes < 1:
+            raise ValueError("num_nodes must be >= 1")
+        self._nodes = nodes
+        self._node_template = node_template
+        self._combine_template = combine_template
+        self._llm_predictor = llm_predictor
+
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        nodes_to_extract_title: List[BaseNode] = []
+        for node in nodes:
+            if len(nodes_to_extract_title) >= self._nodes:
+                break
+            if self.is_text_node_only and not isinstance(node, TextNode):
+                continue
+            nodes_to_extract_title.append(node)
+
+        if len(nodes_to_extract_title) == 0:
+            # Could not extract title
+            return
+
+        title_candidates = [
+            self._llm_predictor.predict(
path: llama_index/node_parser/postprocessors/metadata_extractors.py
text: @user1:
i'd consider using the ResponseSynthesizer module instead, it has in-built capabilities to abstract over long contexts

@user2:
If this is switched, seems like a good use case for the accumulate synthesizer 

@author:
I suggest to utilize more of the synthesizer API in a follow up PR so that we can get this functional feature released / publicized sooner.

diff_hunk: @@ -1,6 +1,15 @@
 """Node parsers."""
 
-from llama_index.node_parser.simple import SimpleNodeParser
 from llama_index.node_parser.interface import NodeParser
+from llama_index.node_parser.simple import SimpleNodeParser
+from llama_index.node_parser.postprocessors.metadata_extractors import (
+    MetadataExtractor,
+    MetadataExtractorBase,
path: llama_index/node_parser/__init__.py
text: @user1:
why is this imported?

diff_hunk: @@ -1,6 +1,15 @@
 """Node parsers."""
 
-from llama_index.node_parser.simple import SimpleNodeParser
 from llama_index.node_parser.interface import NodeParser
+from llama_index.node_parser.simple import SimpleNodeParser
+from llama_index.node_parser.postprocessors.metadata_extractors import (
+    MetadataExtractor,
+    MetadataExtractorBase,
+)
path: llama_index/node_parser/__init__.py
text: @user1:
i would expose the top-level factory function, the "MetadataExtractor" class that contains links to the individual metadata extractors, and I'd also expose each metadata extractor in the `__init__`. This gives the user control in whether they want to use our top-level metadata extractor or individual extractors

diff_hunk: @@ -22,3 +22,18 @@ def get_nodes_from_documents(
             documents (Sequence[Document]): documents to parse
 
         """
+
+
+class NodeParserPostProcessor(ABC):
+    """Base interface for node parser post processor."""
+
+    @abstractmethod
+    def post_process_nodes(
path: llama_index/node_parser/interface.py
text: @user1:
not a huge fan of this name (and the class name), would prefer the class to be `BaseMetadataExtractor` and the function to be `extract_metadata` 

also in general to make this a tighter function signature, i wonder if it would be nicer if this was stateless - e.g. return a Dict in metadata. the outer class (the current class `MetadataAugmentationPostProcessor`) can actually decide to 1) merge the metadata from different fields, and 2) set the metadata on the nodes 

@user2:
I agree here. Feels a little nicer to work with when nodes in, metadata dict out

@author:
Hmm, sounds good.

diff_hunk: @@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
+        .extract_title(nodes=3)
+        .extract_questions_answered(questions=3)
+        .extract_keywords(
+            node_template="Context: {context_str}. 10 Keywords: "
+        )
+        .extract_summary(summaries=['self', 'prev', 'next'])
+        .extract(lambda nodes:
+            for node in nodes:
+                node.metadata["custom"]) = llm_predictor.predict(
+                    prompt,
+                    node.get_content()
+                )
+        )
+        .finish()
+
+    node_parser = SimpleNodeParser(..., post_processors=[post_processor])
+    ```
+    """
+
+    def __init__(
+        self,
+        llm_predictor: Optional[BaseLLMPredictor],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._llm_predictor = llm_predictor or LLMPredictor()
+        self._extractors: List[Callable[[Sequence[BaseNode]], None]] = []
+        self._node_text_template = node_text_template
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def extract_title(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract document title for the sequence of nodes.
+        Args:
+            nodes (int): number of nodes (from front of sequence)
+                to use for title extraction
+            node_template (str): template for node-level title
+                clues extraction
+            combine_template (str): template for combining node-level
+                clues into a document-level title
+        """
+        self._extractors.append(
+            TitleExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_questions_answered(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract questions answered by the node.
+        Args:
+            questions (int): number of questions to extract
+            prompt_template (str): template for question extraction
+        """
+        self._extractors.append(
+            QuestionsAnsweredExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_keywords(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract keywords for the node.
+        Args:
+            keywords (int): number of keywords to extract
+        """
+        self._extractors.append(
+            KeywordExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_summary(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract summary for the node.
+        Args:
+            summaries (List[str]): list of summaries to extract: "self", "prev", "next"
+            prompt_template (str): template for summary extraction"""
+        self._extractors.append(
+            SummaryExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract(
+        self, extractor: Callable[[Sequence[BaseNode]], None]
+    ) -> "MetadataExtractor":
+        """Extract metadata using a custom extractor.
+        Args:
+            extractor (Callable[[Sequence[BaseNode]], None]):
+                Callable that takes a sequence of nodes and mutates the
+                metadata of the nodes in place. It can be a custom class with an
+                  appropriate __call__ or a simply a function/lambda function.
+        """
+        self._extractors.append(extractor)
+        return self
+
+    def build(self) -> "MetadataAugmentationPostProcessor":
+        return MetadataAugmentationPostProcessor(
+            self._extractors, self._node_text_template, self._disable_template_rewrite
+        )
+
+
+class MetadataAugmentationPostProcessor(NodeParserPostProcessor):
+    def __init__(
+        self,
+        extractors: Sequence[Callable[[Sequence[BaseNode]], None]],
path: llama_index/node_parser/postprocessors/metadata_extractors.py
text: @user1:
seems like typing should be List[MetadataExtractorBase] 

@author:
I wasn't sure if we should require the metadata extractor to be a subclass of `MetadataExtractorBase` or just an arbitrary callable. I find defining a function to be less cumbersome, but I'm open to just forcing the user to implement `MetadataExtractorBase`.

@user1:
yeah we should be restrictive for clarity unless we have a reason otherwise

diff_hunk: @@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
path: llama_index/node_parser/postprocessors/metadata_extractors.py
text: @user1:
mentioned this offline - the main change i was going to suggest is alter the factory pattern to do something similar to `get_response_synthesizer` in `llama_index/response_synthesizers/factory.py`

@user1:
or just have the user use the classes directly. Honestly might be the most straightforward UX to have them load it themselves, e.g.

```python
extractors = [
  KeywordExtractor(llm_predictor),
  QuestionsAnsweredExtractor(llm_predictor),
]
```
etc. 

@author:
Hmm, I see. As mentioned offline, I think the main difference between ResponseSynthesizer and MetadataExtractor is for the latter we chain together many extractors. The builder pattern is nice as well because instead of passing in a list of enums, lets say, we have extractor specific parameters.

Open to discuss more on the right API.

@user2:
My two cents here is I like Jerry's second suggestion. You can build a pipeline by instantiating in a list like that. Also encourages the Metadata extractors to be more modular (and therefore easy for users to add their own custom versions)

@author:
Yep agreed that forcing users to deal with the classes can also encourage them to play with them e.g. modify and inspect them, and build their own.

diff_hunk: @@ -0,0 +1,393 @@
+"""
+Metadata extractors for nodes. Applied as a post processor to node parsing.
+Currently, only `TextNode` is supported.
+
+Supported metadata:
+Node-level:
+    - `SummaryExtractor`: Summary of each node, and pre and post nodes
+    - `QuestionsAnsweredExtractor`: Questions that the node can answer
+    - `KeywordsExtractor`: Keywords that uniquely identify the node
+Document-level:
+    - `TitleExtractor`: Document title, possible inferred across multiple nodes
+
+Unimplemented (contributions welcome):
+Subsection:
+    - Position of node in subsection hierarchy (and associated subtitles)
+    - Hierarchically organized summary
+
+The prompts used to generate the metadata are specifically aimed to help
+disambiguate the document or subsection from other similar documents or subsections.
+(similar with contrastive learning)
+"""
+
+from abc import ABC, abstractmethod
+import json
+from typing import Any, Callable, List, Optional, Sequence, cast
+from functools import reduce
+
+from llama_index.llm_predictor.base import BaseLLMPredictor, LLMPredictor
+from llama_index.node_parser.interface import NodeParserPostProcessor
+from llama_index.prompts.base import Prompt
+from llama_index.schema import BaseNode, TextNode
+
+
+class MetadataExtractorBase(ABC):
+    is_text_node_only = True
+
+    @abstractmethod
+    def __call__(self, nodes: Sequence[BaseNode]) -> None:
+        """Extracts metadata for a sequence of nodes and mutates the nodes in place.
+
+        Args:
+            nodes (Sequence[Document]): nodes to extract metadata from
+
+        """
+
+
+class MetadataExtractor:
+    """Factory for metadata extractors.
+
+    Example:
+
+    ```
+    post_processor = MetadataExtractor(llm_predictor=llm_predictor)
+        .extract_title(nodes=3)
+        .extract_questions_answered(questions=3)
+        .extract_keywords(
+            node_template="Context: {context_str}. 10 Keywords: "
+        )
+        .extract_summary(summaries=['self', 'prev', 'next'])
+        .extract(lambda nodes:
+            for node in nodes:
+                node.metadata["custom"]) = llm_predictor.predict(
+                    prompt,
+                    node.get_content()
+                )
+        )
+        .finish()
+
+    node_parser = SimpleNodeParser(..., post_processors=[post_processor])
+    ```
+    """
+
+    def __init__(
+        self,
+        llm_predictor: Optional[BaseLLMPredictor],
+        node_text_template: Optional[str] = None,
+        disable_template_rewrite: bool = False,
+    ) -> None:
+        self._llm_predictor = llm_predictor or LLMPredictor()
+        self._extractors: List[Callable[[Sequence[BaseNode]], None]] = []
+        self._node_text_template = node_text_template
+        self._disable_template_rewrite = disable_template_rewrite
+
+    def extract_title(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract document title for the sequence of nodes.
+        Args:
+            nodes (int): number of nodes (from front of sequence)
+                to use for title extraction
+            node_template (str): template for node-level title
+                clues extraction
+            combine_template (str): template for combining node-level
+                clues into a document-level title
+        """
+        self._extractors.append(
+            TitleExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_questions_answered(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract questions answered by the node.
+        Args:
+            questions (int): number of questions to extract
+            prompt_template (str): template for question extraction
+        """
+        self._extractors.append(
+            QuestionsAnsweredExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_keywords(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract keywords for the node.
+        Args:
+            keywords (int): number of keywords to extract
+        """
+        self._extractors.append(
+            KeywordExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract_summary(self, **kwargs: Any) -> "MetadataExtractor":
+        """Extract summary for the node.
+        Args:
+            summaries (List[str]): list of summaries to extract: "self", "prev", "next"
+            prompt_template (str): template for summary extraction"""
+        self._extractors.append(
+            SummaryExtractor(llm_predictor=self._llm_predictor, **kwargs)
+        )
+        return self
+
+    def extract(
+        self, extractor: Callable[[Sequence[BaseNode]], None]
+    ) -> "MetadataExtractor":
+        """Extract metadata using a custom extractor.
+        Args:
+            extractor (Callable[[Sequence[BaseNode]], None]):
+                Callable that takes a sequence of nodes and mutates the
+                metadata of the nodes in place. It can be a custom class with an
+                  appropriate __call__ or a simply a function/lambda function.
+        """
+        self._extractors.append(extractor)
+        return self
+
+    def build(self) -> "MetadataAugmentationPostProcessor":
+        return MetadataAugmentationPostProcessor(
+            self._extractors, self._node_text_template, self._disable_template_rewrite
+        )
+
+
+class MetadataAugmentationPostProcessor(NodeParserPostProcessor):
path: llama_index/node_parser/postprocessors/metadata_extractors.py
text: @user1:
i have a nit about the name - don't like the name "postprocessor" (i know it exists in the second stage processing module).

Would prefer to just name this "MetadataExtractor" (and each individual extractor could be called "MetadataFeatureExtractor" 

@author:
Sure, but I just wanted to leave the option open for other node parser post processors. 

I get the feeling there might be others, though I can't think of one off the top of my head.

@user1:
We can always modify the base class abstractions + naming, but for user-facing classes (`MetadataAugmentationPostProcessor`) I'd heavily prefer it to be concise/intuitive. e.g. "MetadataExtractor"

diff_hunk: @@ -12,6 +12,8 @@ class SimpleChatEngine(BaseChatEngine):
 
     Have a conversation with the LLM.
     This does not make use of a knowledge base.
+
+    # TODO: add back ability to configure prompt/system message
path: llama_index/chat_engine/simple.py
text: @author:
Remove this