diff --git a/docs/docs/how_to/graph_mapping.ipynb b/docs/docs/how_to/graph_mapping.ipynb deleted file mode 100644 index 146f479e27d..00000000000 --- a/docs/docs/how_to/graph_mapping.ipynb +++ /dev/null @@ -1,459 +0,0 @@ -{ - "cells": [ - { - "cell_type": "raw", - "id": "5e61b0f2-15b9-4241-9ab5-ff0f3f732232", - "metadata": {}, - "source": [ - "---\n", - "sidebar_position: 1\n", - "---" - ] - }, - { - "cell_type": "markdown", - "id": "846ef4f4-ee38-4a42-a7d3-1a23826e4830", - "metadata": {}, - "source": [ - "# How to map values to a graph database\n", - "\n", - "In this guide we'll go over strategies to improve graph database query generation by mapping values from user inputs to database.\n", - "When using the built-in graph chains, the LLM is aware of the graph schema, but has no information about the values of properties stored in the database.\n", - "Therefore, we can introduce a new step in graph database QA system to accurately map values.\n", - "\n", - "## Setup\n", - "\n", - "First, get required packages and set environment variables:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18294435-182d-48da-bcab-5b8945b6d9cf", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install --upgrade --quiet langchain langchain-neo4j langchain-openai neo4j" - ] - }, - { - "cell_type": "markdown", - "id": "d86dd771-4001-4a34-8680-22e9b50e1e88", - "metadata": {}, - "source": [ - "We default to OpenAI models in this guide, but you can swap them out for the model provider of your choice." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "9346f8e9-78bf-4667-b3d3-72807a73b718", - "metadata": {}, - "outputs": [ - { - "name": "stdin", - "output_type": "stream", - "text": [ - " ········\n" - ] - } - ], - "source": [ - "import getpass\n", - "import os\n", - "\n", - "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass()\n", - "\n", - "# Uncomment the below to use LangSmith. Not required.\n", - "# os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass()\n", - "# os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"" - ] - }, - { - "cell_type": "markdown", - "id": "271c8a23-e51c-4ead-a76e-cf21107db47e", - "metadata": {}, - "source": [ - "Next, we need to define Neo4j credentials.\n", - "Follow [these installation steps](https://neo4j.com/docs/operations-manual/current/installation/) to set up a Neo4j database." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "a2a3bb65-05c7-4daf-bac2-b25ae7fe2751", - "metadata": {}, - "outputs": [], - "source": [ - "os.environ[\"NEO4J_URI\"] = \"bolt://localhost:7687\"\n", - "os.environ[\"NEO4J_USERNAME\"] = \"neo4j\"\n", - "os.environ[\"NEO4J_PASSWORD\"] = \"password\"" - ] - }, - { - "cell_type": "markdown", - "id": "50fa4510-29b7-49b6-8496-5e86f694e81f", - "metadata": {}, - "source": [ - "The below example will create a connection with a Neo4j database and will populate it with example data about movies and their actors." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "4ee9ef7a-eef9-4289-b9fd-8fbc31041688", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from langchain_neo4j import Neo4jGraph\n", - "\n", - "graph = Neo4jGraph()\n", - "\n", - "# Import movie information\n", - "\n", - "movies_query = \"\"\"\n", - "LOAD CSV WITH HEADERS FROM \n", - "'https://raw.githubusercontent.com/tomasonjo/blog-datasets/main/movies/movies_small.csv'\n", - "AS row\n", - "MERGE (m:Movie {id:row.movieId})\n", - "SET m.released = date(row.released),\n", - " m.title = row.title,\n", - " m.imdbRating = toFloat(row.imdbRating)\n", - "FOREACH (director in split(row.director, '|') | \n", - " MERGE (p:Person {name:trim(director)})\n", - " MERGE (p)-[:DIRECTED]->(m))\n", - "FOREACH (actor in split(row.actors, '|') | \n", - " MERGE (p:Person {name:trim(actor)})\n", - " MERGE (p)-[:ACTED_IN]->(m))\n", - "FOREACH (genre in split(row.genres, '|') | \n", - " MERGE (g:Genre {name:trim(genre)})\n", - " MERGE (m)-[:IN_GENRE]->(g))\n", - "\"\"\"\n", - "\n", - "graph.query(movies_query)" - ] - }, - { - "cell_type": "markdown", - "id": "0cb0ea30-ca55-4f35-aad6-beb57453de66", - "metadata": {}, - "source": [ - "## Detecting entities in the user input\n", - "We have to extract the types of entities/values we want to map to a graph database. In this example, we are dealing with a movie graph, so we can map movies and people to the database." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "e1a19424-6046-40c2-81d1-f3b88193a293", - "metadata": {}, - "outputs": [], - "source": [ - "from typing import List, Optional\n", - "\n", - "from langchain_core.prompts import ChatPromptTemplate\n", - "from langchain_openai import ChatOpenAI\n", - "from pydantic import BaseModel, Field\n", - "\n", - "llm = ChatOpenAI(model=\"gpt-3.5-turbo\", temperature=0)\n", - "\n", - "\n", - "class Entities(BaseModel):\n", - " \"\"\"Identifying information about entities.\"\"\"\n", - "\n", - " names: List[str] = Field(\n", - " ...,\n", - " description=\"All the person or movies appearing in the text\",\n", - " )\n", - "\n", - "\n", - "prompt = ChatPromptTemplate.from_messages(\n", - " [\n", - " (\n", - " \"system\",\n", - " \"You are extracting person and movies from the text.\",\n", - " ),\n", - " (\n", - " \"human\",\n", - " \"Use the given format to extract information from the following \"\n", - " \"input: {question}\",\n", - " ),\n", - " ]\n", - ")\n", - "\n", - "\n", - "entity_chain = prompt | llm.with_structured_output(Entities)" - ] - }, - { - "cell_type": "markdown", - "id": "9c14084c-37a7-4a9c-a026-74e12961c781", - "metadata": {}, - "source": [ - "We can test the entity extraction chain." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "bbfe0d8f-982e-46e6-88fb-8a4f0d850b07", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Entities(names=['Casino'])" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "entities = entity_chain.invoke({\"question\": \"Who played in Casino movie?\"})\n", - "entities" - ] - }, - { - "cell_type": "markdown", - "id": "a8afbf13-05d0-4383-8050-f88b8c2f6fab", - "metadata": {}, - "source": [ - "We will utilize a simple `CONTAINS` clause to match entities to database. In practice, you might want to use a fuzzy search or a fulltext index to allow for minor misspellings." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "6f92929f-74fb-4db2-b7e1-eb1e9d386a67", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Casino maps to Casino Movie in database\\n'" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "match_query = \"\"\"MATCH (p:Person|Movie)\n", - "WHERE p.name CONTAINS $value OR p.title CONTAINS $value\n", - "RETURN coalesce(p.name, p.title) AS result, labels(p)[0] AS type\n", - "LIMIT 1\n", - "\"\"\"\n", - "\n", - "\n", - "def map_to_database(entities: Entities) -> Optional[str]:\n", - " result = \"\"\n", - " for entity in entities.names:\n", - " response = graph.query(match_query, {\"value\": entity})\n", - " try:\n", - " result += f\"{entity} maps to {response[0]['result']} {response[0]['type']} in database\\n\"\n", - " except IndexError:\n", - " pass\n", - " return result\n", - "\n", - "\n", - "map_to_database(entities)" - ] - }, - { - "cell_type": "markdown", - "id": "f66c6756-6efb-4b1e-9b5d-87ed914a5212", - "metadata": {}, - "source": [ - "## Custom Cypher generating chain\n", - "\n", - "We need to define a custom Cypher prompt that takes the entity mapping information along with the schema and the user question to construct a Cypher statement.\n", - "We will be using the LangChain expression language to accomplish that." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "8ef3e21d-f1c2-45e2-9511-4920d1cf6e7e", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_core.output_parsers import StrOutputParser\n", - "from langchain_core.runnables import RunnablePassthrough\n", - "\n", - "# Generate Cypher statement based on natural language input\n", - "cypher_template = \"\"\"Based on the Neo4j graph schema below, write a Cypher query that would answer the user's question:\n", - "{schema}\n", - "Entities in the question map to the following database values:\n", - "{entities_list}\n", - "Question: {question}\n", - "Cypher query:\"\"\"\n", - "\n", - "cypher_prompt = ChatPromptTemplate.from_messages(\n", - " [\n", - " (\n", - " \"system\",\n", - " \"Given an input question, convert it to a Cypher query. No pre-amble.\",\n", - " ),\n", - " (\"human\", cypher_template),\n", - " ]\n", - ")\n", - "\n", - "cypher_response = (\n", - " RunnablePassthrough.assign(names=entity_chain)\n", - " | RunnablePassthrough.assign(\n", - " entities_list=lambda x: map_to_database(x[\"names\"]),\n", - " schema=lambda _: graph.get_schema,\n", - " )\n", - " | cypher_prompt\n", - " | llm.bind(stop=[\"\\nCypherResult:\"])\n", - " | StrOutputParser()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "1f0011e3-9660-4975-af2a-486b1bc3b954", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'MATCH (:Movie {title: \"Casino\"})<-[:ACTED_IN]-(actor)\\nRETURN actor.name'" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cypher = cypher_response.invoke({\"question\": \"Who played in Casino movie?\"})\n", - "cypher" - ] - }, - { - "cell_type": "markdown", - "id": "38095678-611f-4847-a4de-e51ef7ef727c", - "metadata": {}, - "source": [ - "## Generating answers based on database results\n", - "\n", - "Now that we have a chain that generates the Cypher statement, we need to execute the Cypher statement against the database and send the database results back to an LLM to generate the final answer.\n", - "Again, we will be using LCEL." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "d1fa97c0-1c9c-41d3-9ee1-5f1905d17434", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_neo4j.chains.graph_qa.cypher_utils import (\n", - " CypherQueryCorrector,\n", - " Schema,\n", - ")\n", - "\n", - "graph.refresh_schema()\n", - "# Cypher validation tool for relationship directions\n", - "corrector_schema = [\n", - " Schema(el[\"start\"], el[\"type\"], el[\"end\"])\n", - " for el in graph.structured_schema.get(\"relationships\")\n", - "]\n", - "cypher_validation = CypherQueryCorrector(corrector_schema)\n", - "\n", - "# Generate natural language response based on database results\n", - "response_template = \"\"\"Based on the the question, Cypher query, and Cypher response, write a natural language response:\n", - "Question: {question}\n", - "Cypher query: {query}\n", - "Cypher Response: {response}\"\"\"\n", - "\n", - "response_prompt = ChatPromptTemplate.from_messages(\n", - " [\n", - " (\n", - " \"system\",\n", - " \"Given an input question and Cypher response, convert it to a natural\"\n", - " \" language answer. No pre-amble.\",\n", - " ),\n", - " (\"human\", response_template),\n", - " ]\n", - ")\n", - "\n", - "chain = (\n", - " RunnablePassthrough.assign(query=cypher_response)\n", - " | RunnablePassthrough.assign(\n", - " response=lambda x: graph.query(cypher_validation(x[\"query\"])),\n", - " )\n", - " | response_prompt\n", - " | llm\n", - " | StrOutputParser()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "918146e5-7918-46d2-a774-53f9547d8fcb", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Robert De Niro, James Woods, Joe Pesci, and Sharon Stone played in the movie \"Casino\".'" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "chain.invoke({\"question\": \"Who played in Casino movie?\"})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c7ba75cd-8399-4e54-a6f8-8a411f159f56", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "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.9.18" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/docs/how_to/graph_prompting.ipynb b/docs/docs/how_to/graph_prompting.ipynb deleted file mode 100644 index db4922fb3a2..00000000000 --- a/docs/docs/how_to/graph_prompting.ipynb +++ /dev/null @@ -1,548 +0,0 @@ -{ - "cells": [ - { - "cell_type": "raw", - "metadata": {}, - "source": [ - "---\n", - "sidebar_position: 2\n", - "---" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# How to best prompt for Graph-RAG\n", - "\n", - "In this guide we'll go over prompting strategies to improve graph database query generation. We'll largely focus on methods for getting relevant database-specific information in your prompt.\n", - "\n", - "## Setup\n", - "\n", - "First, get required packages and set environment variables:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Note: you may need to restart the kernel to use updated packages.\n" - ] - } - ], - "source": [ - "%pip install --upgrade --quiet langchain langchain-neo4j langchain-openai neo4j" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We default to OpenAI models in this guide, but you can swap them out for the model provider of your choice." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stdin", - "output_type": "stream", - "text": [ - " ········\n" - ] - } - ], - "source": [ - "import getpass\n", - "import os\n", - "\n", - "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass()\n", - "\n", - "# Uncomment the below to use LangSmith. Not required.\n", - "# os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass()\n", - "# os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Next, we need to define Neo4j credentials.\n", - "Follow [these installation steps](https://neo4j.com/docs/operations-manual/current/installation/) to set up a Neo4j database." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "os.environ[\"NEO4J_URI\"] = \"bolt://localhost:7687\"\n", - "os.environ[\"NEO4J_USERNAME\"] = \"neo4j\"\n", - "os.environ[\"NEO4J_PASSWORD\"] = \"password\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The below example will create a connection with a Neo4j database and will populate it with example data about movies and their actors." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from langchain_neo4j import Neo4jGraph\n", - "\n", - "graph = Neo4jGraph()\n", - "\n", - "# Import movie information\n", - "\n", - "movies_query = \"\"\"\n", - "LOAD CSV WITH HEADERS FROM \n", - "'https://raw.githubusercontent.com/tomasonjo/blog-datasets/main/movies/movies_small.csv'\n", - "AS row\n", - "MERGE (m:Movie {id:row.movieId})\n", - "SET m.released = date(row.released),\n", - " m.title = row.title,\n", - " m.imdbRating = toFloat(row.imdbRating)\n", - "FOREACH (director in split(row.director, '|') | \n", - " MERGE (p:Person {name:trim(director)})\n", - " MERGE (p)-[:DIRECTED]->(m))\n", - "FOREACH (actor in split(row.actors, '|') | \n", - " MERGE (p:Person {name:trim(actor)})\n", - " MERGE (p)-[:ACTED_IN]->(m))\n", - "FOREACH (genre in split(row.genres, '|') | \n", - " MERGE (g:Genre {name:trim(genre)})\n", - " MERGE (m)-[:IN_GENRE]->(g))\n", - "\"\"\"\n", - "\n", - "graph.query(movies_query)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Filtering graph schema\n", - "\n", - "At times, you may need to focus on a specific subset of the graph schema while generating Cypher statements.\n", - "Let's say we are dealing with the following graph schema:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Node properties are the following:\n", - "Movie {imdbRating: FLOAT, id: STRING, released: DATE, title: STRING},Person {name: STRING},Genre {name: STRING}\n", - "Relationship properties are the following:\n", - "\n", - "The relationships are the following:\n", - "(:Movie)-[:IN_GENRE]->(:Genre),(:Person)-[:DIRECTED]->(:Movie),(:Person)-[:ACTED_IN]->(:Movie)\n" - ] - } - ], - "source": [ - "graph.refresh_schema()\n", - "print(graph.schema)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's say we want to exclude the _Genre_ node from the schema representation we pass to an LLM.\n", - "We can achieve that using the `exclude` parameter of the GraphCypherQAChain chain." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_neo4j import GraphCypherQAChain\n", - "from langchain_openai import ChatOpenAI\n", - "\n", - "llm = ChatOpenAI(model=\"gpt-3.5-turbo\", temperature=0)\n", - "chain = GraphCypherQAChain.from_llm(\n", - " graph=graph,\n", - " llm=llm,\n", - " exclude_types=[\"Genre\"],\n", - " verbose=True,\n", - " allow_dangerous_requests=True,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Node properties are the following:\n", - "Movie {imdbRating: FLOAT, id: STRING, released: DATE, title: STRING},Person {name: STRING}\n", - "Relationship properties are the following:\n", - "\n", - "The relationships are the following:\n", - "(:Person)-[:DIRECTED]->(:Movie),(:Person)-[:ACTED_IN]->(:Movie)\n" - ] - } - ], - "source": [ - "print(chain.graph_schema)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Few-shot examples\n", - "\n", - "Including examples of natural language questions being converted to valid Cypher queries against our database in the prompt will often improve model performance, especially for complex queries.\n", - "\n", - "Let's say we have the following examples:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "examples = [\n", - " {\n", - " \"question\": \"How many artists are there?\",\n", - " \"query\": \"MATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN count(DISTINCT a)\",\n", - " },\n", - " {\n", - " \"question\": \"Which actors played in the movie Casino?\",\n", - " \"query\": \"MATCH (m:Movie {{title: 'Casino'}})<-[:ACTED_IN]-(a) RETURN a.name\",\n", - " },\n", - " {\n", - " \"question\": \"How many movies has Tom Hanks acted in?\",\n", - " \"query\": \"MATCH (a:Person {{name: 'Tom Hanks'}})-[:ACTED_IN]->(m:Movie) RETURN count(m)\",\n", - " },\n", - " {\n", - " \"question\": \"List all the genres of the movie Schindler's List\",\n", - " \"query\": \"MATCH (m:Movie {{title: 'Schindler\\\\'s List'}})-[:IN_GENRE]->(g:Genre) RETURN g.name\",\n", - " },\n", - " {\n", - " \"question\": \"Which actors have worked in movies from both the comedy and action genres?\",\n", - " \"query\": \"MATCH (a:Person)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name\",\n", - " },\n", - " {\n", - " \"question\": \"Which directors have made movies with at least three different actors named 'John'?\",\n", - " \"query\": \"MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Person) WHERE a.name STARTS WITH 'John' WITH d, COUNT(DISTINCT a) AS JohnsCount WHERE JohnsCount >= 3 RETURN d.name\",\n", - " },\n", - " {\n", - " \"question\": \"Identify movies where directors also played a role in the film.\",\n", - " \"query\": \"MATCH (p:Person)-[:DIRECTED]->(m:Movie), (p)-[:ACTED_IN]->(m) RETURN m.title, p.name\",\n", - " },\n", - " {\n", - " \"question\": \"Find the actor with the highest number of movies in the database.\",\n", - " \"query\": \"MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1\",\n", - " },\n", - "]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can create a few-shot prompt with them like so:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate\n", - "\n", - "example_prompt = PromptTemplate.from_template(\n", - " \"User input: {question}\\nCypher query: {query}\"\n", - ")\n", - "prompt = FewShotPromptTemplate(\n", - " examples=examples[:5],\n", - " example_prompt=example_prompt,\n", - " prefix=\"You are a Neo4j expert. Given an input question, create a syntactically correct Cypher query to run.\\n\\nHere is the schema information\\n{schema}.\\n\\nBelow are a number of examples of questions and their corresponding Cypher queries.\",\n", - " suffix=\"User input: {question}\\nCypher query: \",\n", - " input_variables=[\"question\", \"schema\"],\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "You are a Neo4j expert. Given an input question, create a syntactically correct Cypher query to run.\n", - "\n", - "Here is the schema information\n", - "foo.\n", - "\n", - "Below are a number of examples of questions and their corresponding Cypher queries.\n", - "\n", - "User input: How many artists are there?\n", - "Cypher query: MATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN count(DISTINCT a)\n", - "\n", - "User input: Which actors played in the movie Casino?\n", - "Cypher query: MATCH (m:Movie {title: 'Casino'})<-[:ACTED_IN]-(a) RETURN a.name\n", - "\n", - "User input: How many movies has Tom Hanks acted in?\n", - "Cypher query: MATCH (a:Person {name: 'Tom Hanks'})-[:ACTED_IN]->(m:Movie) RETURN count(m)\n", - "\n", - "User input: List all the genres of the movie Schindler's List\n", - "Cypher query: MATCH (m:Movie {title: 'Schindler\\'s List'})-[:IN_GENRE]->(g:Genre) RETURN g.name\n", - "\n", - "User input: Which actors have worked in movies from both the comedy and action genres?\n", - "Cypher query: MATCH (a:Person)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name\n", - "\n", - "User input: How many artists are there?\n", - "Cypher query: \n" - ] - } - ], - "source": [ - "print(prompt.format(question=\"How many artists are there?\", schema=\"foo\"))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Dynamic few-shot examples\n", - "\n", - "If we have enough examples, we may want to only include the most relevant ones in the prompt, either because they don't fit in the model's context window or because the long tail of examples distracts the model. And specifically, given any input we want to include the examples most relevant to that input.\n", - "\n", - "We can do just this using an ExampleSelector. In this case we'll use a [SemanticSimilarityExampleSelector](https://python.langchain.com/api_reference/core/example_selectors/langchain_core.example_selectors.semantic_similarity.SemanticSimilarityExampleSelector.html), which will store the examples in the vector database of our choosing. At runtime it will perform a similarity search between the input and our examples, and return the most semantically similar ones: " - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "from langchain_core.example_selectors import SemanticSimilarityExampleSelector\n", - "from langchain_neo4j import Neo4jVector\n", - "from langchain_openai import OpenAIEmbeddings\n", - "\n", - "example_selector = SemanticSimilarityExampleSelector.from_examples(\n", - " examples,\n", - " OpenAIEmbeddings(),\n", - " Neo4jVector,\n", - " k=5,\n", - " input_keys=[\"question\"],\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[{'query': 'MATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN count(DISTINCT a)',\n", - " 'question': 'How many artists are there?'},\n", - " {'query': \"MATCH (a:Person {{name: 'Tom Hanks'}})-[:ACTED_IN]->(m:Movie) RETURN count(m)\",\n", - " 'question': 'How many movies has Tom Hanks acted in?'},\n", - " {'query': \"MATCH (a:Person)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name\",\n", - " 'question': 'Which actors have worked in movies from both the comedy and action genres?'},\n", - " {'query': \"MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Person) WHERE a.name STARTS WITH 'John' WITH d, COUNT(DISTINCT a) AS JohnsCount WHERE JohnsCount >= 3 RETURN d.name\",\n", - " 'question': \"Which directors have made movies with at least three different actors named 'John'?\"},\n", - " {'query': 'MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1',\n", - " 'question': 'Find the actor with the highest number of movies in the database.'}]" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "example_selector.select_examples({\"question\": \"how many artists are there?\"})" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To use it, we can pass the ExampleSelector directly in to our FewShotPromptTemplate:" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [], - "source": [ - "prompt = FewShotPromptTemplate(\n", - " example_selector=example_selector,\n", - " example_prompt=example_prompt,\n", - " prefix=\"You are a Neo4j expert. Given an input question, create a syntactically correct Cypher query to run.\\n\\nHere is the schema information\\n{schema}.\\n\\nBelow are a number of examples of questions and their corresponding Cypher queries.\",\n", - " suffix=\"User input: {question}\\nCypher query: \",\n", - " input_variables=[\"question\", \"schema\"],\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "You are a Neo4j expert. Given an input question, create a syntactically correct Cypher query to run.\n", - "\n", - "Here is the schema information\n", - "foo.\n", - "\n", - "Below are a number of examples of questions and their corresponding Cypher queries.\n", - "\n", - "User input: How many artists are there?\n", - "Cypher query: MATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN count(DISTINCT a)\n", - "\n", - "User input: How many movies has Tom Hanks acted in?\n", - "Cypher query: MATCH (a:Person {name: 'Tom Hanks'})-[:ACTED_IN]->(m:Movie) RETURN count(m)\n", - "\n", - "User input: Which actors have worked in movies from both the comedy and action genres?\n", - "Cypher query: MATCH (a:Person)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name\n", - "\n", - "User input: Which directors have made movies with at least three different actors named 'John'?\n", - "Cypher query: MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Person) WHERE a.name STARTS WITH 'John' WITH d, COUNT(DISTINCT a) AS JohnsCount WHERE JohnsCount >= 3 RETURN d.name\n", - "\n", - "User input: Find the actor with the highest number of movies in the database.\n", - "Cypher query: MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1\n", - "\n", - "User input: how many artists are there?\n", - "Cypher query: \n" - ] - } - ], - "source": [ - "print(prompt.format(question=\"how many artists are there?\", schema=\"foo\"))" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [], - "source": [ - "llm = ChatOpenAI(model=\"gpt-3.5-turbo\", temperature=0)\n", - "chain = GraphCypherQAChain.from_llm(\n", - " graph=graph,\n", - " llm=llm,\n", - " cypher_prompt=prompt,\n", - " verbose=True,\n", - " allow_dangerous_requests=True,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "\u001b[1m> Entering new GraphCypherQAChain chain...\u001b[0m\n", - "Generated Cypher:\n", - "\u001b[32;1m\u001b[1;3mMATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN count(DISTINCT a)\u001b[0m\n", - "Full Context:\n", - "\u001b[32;1m\u001b[1;3m[{'count(DISTINCT a)': 967}]\u001b[0m\n", - "\n", - "\u001b[1m> Finished chain.\u001b[0m\n" - ] - }, - { - "data": { - "text/plain": [ - "{'query': 'How many actors are in the graph?',\n", - " 'result': 'There are 967 actors in the graph.'}" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "chain.invoke(\"How many actors are in the graph?\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "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.1" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/docs/how_to/index.mdx b/docs/docs/how_to/index.mdx index b432569bf66..1ce6cc2737a 100644 --- a/docs/docs/how_to/index.mdx +++ b/docs/docs/how_to/index.mdx @@ -316,9 +316,7 @@ For a high-level tutorial, check out [this guide](/docs/tutorials/sql_qa/). You can use an LLM to do question answering over graph databases. For a high-level tutorial, check out [this guide](/docs/tutorials/graph/). -- [How to: map values to a database](/docs/how_to/graph_mapping) - [How to: add a semantic layer over the database](/docs/how_to/graph_semantic) -- [How to: improve results with prompting](/docs/how_to/graph_prompting) - [How to: construct knowledge graphs](/docs/how_to/graph_constructing) ### Summarization diff --git a/docs/docs/tutorials/graph.ipynb b/docs/docs/tutorials/graph.ipynb index 4130bae5a84..41960e0186b 100644 --- a/docs/docs/tutorials/graph.ipynb +++ b/docs/docs/tutorials/graph.ipynb @@ -15,7 +15,7 @@ "source": [ "# Build a Question Answering application over a Graph Database\n", "\n", - "In this guide we'll go over the basic ways to create a Q&A chain over a graph database. These systems will allow us to ask a question about the data in a graph database and get back a natural language answer.\n", + "In this guide we'll go over the basic ways to create a Q&A chain over a graph database. These systems will allow us to ask a question about the data in a graph database and get back a natural language answer. First, we will show a simple out-of-the-box option and then implement a more sophisticated version with LangGraph.\n", "\n", "## ⚠️ Security note ⚠️\n", "\n", @@ -45,7 +45,7 @@ "metadata": {}, "outputs": [], "source": [ - "%pip install --upgrade --quiet langchain langchain-neo4j langchain-openai neo4j" + "%pip install --upgrade --quiet langchain langchain-neo4j langchain-openai langgraph" ] }, { @@ -57,14 +57,14 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - " ········\n" + "Enter your OpenAI API key: ········\n" ] } ], @@ -90,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -108,7 +108,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -117,7 +117,7 @@ "[]" ] }, - "execution_count": 3, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -162,19 +162,24 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Node properties are the following:\n", - "Movie {imdbRating: FLOAT, id: STRING, released: DATE, title: STRING},Person {name: STRING},Genre {name: STRING},Chunk {id: STRING, question: STRING, query: STRING, text: STRING, embedding: LIST}\n", - "Relationship properties are the following:\n", + "Node properties:\n", + "Person {name: STRING}\n", + "Movie {id: STRING, released: DATE, title: STRING, imdbRating: FLOAT}\n", + "Genre {name: STRING}\n", + "Chunk {id: STRING, embedding: LIST, text: STRING, question: STRING, query: STRING}\n", + "Relationship properties:\n", "\n", - "The relationships are the following:\n", - "(:Movie)-[:IN_GENRE]->(:Genre),(:Person)-[:DIRECTED]->(:Movie),(:Person)-[:ACTED_IN]->(:Movie)\n" + "The relationships:\n", + "(:Person)-[:DIRECTED]->(:Movie)\n", + "(:Person)-[:ACTED_IN]->(:Movie)\n", + "(:Movie)-[:IN_GENRE]->(:Genre)\n" ] } ], @@ -187,11 +192,65 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "For more involved schema information, you can use `enhanced_schema` option." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Received notification from DBMS server: {severity: WARNING} {code: Neo.ClientNotification.Statement.FeatureDeprecationWarning} {category: DEPRECATION} {title: This feature is deprecated and will be removed in future versions.} {description: The procedure has a deprecated field. ('config' used by 'apoc.meta.graphSample' is deprecated.)} {position: line: 1, column: 1, offset: 0} for query: \"CALL apoc.meta.graphSample() YIELD nodes, relationships RETURN nodes, [rel in relationships | {name:apoc.any.property(rel, 'type'), count: apoc.any.property(rel, 'count')}] AS relationships\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Node properties:\n", + "- **Person**\n", + " - `name`: STRING Example: \"John Lasseter\"\n", + "- **Movie**\n", + " - `id`: STRING Example: \"1\"\n", + " - `released`: DATE Min: 1964-12-16, Max: 1996-09-15\n", + " - `title`: STRING Example: \"Toy Story\"\n", + " - `imdbRating`: FLOAT Min: 2.4, Max: 9.3\n", + "- **Genre**\n", + " - `name`: STRING Example: \"Adventure\"\n", + "- **Chunk**\n", + " - `id`: STRING Available options: ['d66006059fd78d63f3df90cc1059639a', '0e3dcb4502853979d12357690a95ec17', 'c438c6bcdcf8e4fab227f29f8e7ff204', '97fe701ec38057594464beaa2df0710e', 'b54f9286e684373498c4504b4edd9910', '5b50a72c3a4954b0ff7a0421be4f99b9', 'fb28d41771e717255f0d8f6c799ede32', '58e6f14dd2e6c6702cf333f2335c499c']\n", + " - `text`: STRING Available options: ['How many artists are there?', 'Which actors played in the movie Casino?', 'How many movies has Tom Hanks acted in?', \"List all the genres of the movie Schindler's List\", 'Which actors have worked in movies from both the c', 'Which directors have made movies with at least thr', 'Identify movies where directors also played a role', 'Find the actor with the highest number of movies i']\n", + " - `question`: STRING Available options: ['How many artists are there?', 'Which actors played in the movie Casino?', 'How many movies has Tom Hanks acted in?', \"List all the genres of the movie Schindler's List\", 'Which actors have worked in movies from both the c', 'Which directors have made movies with at least thr', 'Identify movies where directors also played a role', 'Find the actor with the highest number of movies i']\n", + " - `query`: STRING Available options: ['MATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN coun', \"MATCH (m:Movie {title: 'Casino'})<-[:ACTED_IN]-(a)\", \"MATCH (a:Person {name: 'Tom Hanks'})-[:ACTED_IN]->\", \"MATCH (m:Movie {title: 'Schindler's List'})-[:IN_G\", 'MATCH (a:Person)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]', 'MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_I', 'MATCH (p:Person)-[:DIRECTED]->(m:Movie), (p)-[:ACT', 'MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.na']\n", + "Relationship properties:\n", + "\n", + "The relationships:\n", + "(:Person)-[:DIRECTED]->(:Movie)\n", + "(:Person)-[:ACTED_IN]->(:Movie)\n", + "(:Movie)-[:IN_GENRE]->(:Genre)\n" + ] + } + ], + "source": [ + "enhanced_graph = Neo4jGraph(enhanced_schema=True)\n", + "print(enhanced_graph.schema)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `enhanced_schema` option enriches property information by including details such as minimum and maximum values for floats and dates, as well as example values for string properties. This additional context helps guide the LLM toward generating more accurate and effective queries.\n", + "\n", "Great! We've got a graph database that we can query. Now let's try hooking it up to an LLM.\n", "\n", - "## Chain\n", + "## GraphQACypherChain\n", "\n", - "Let's use a simple chain that takes a question, turns it into a Cypher query, executes the query, and uses the result to answer the original question.\n", + "Let's use a simple out-of-the-box chain that takes a question, turns it into a Cypher query, executes the query, and uses the result to answer the original question.\n", "\n", "![graph_chain.webp](../../static/img/graph_chain.webp)\n", "\n", @@ -201,7 +260,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -212,10 +271,12 @@ "\n", "\u001b[1m> Entering new GraphCypherQAChain chain...\u001b[0m\n", "Generated Cypher:\n", - "\u001b[32;1m\u001b[1;3mMATCH (:Movie {title: \"Casino\"})<-[:ACTED_IN]-(actor:Person)\n", - "RETURN actor.name\u001b[0m\n", + "\u001b[32;1m\u001b[1;3mcypher\n", + "MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: \"Casino\"})\n", + "RETURN p.name\n", + "\u001b[0m\n", "Full Context:\n", - "\u001b[32;1m\u001b[1;3m[{'actor.name': 'Joe Pesci'}, {'actor.name': 'Robert De Niro'}, {'actor.name': 'Sharon Stone'}, {'actor.name': 'James Woods'}]\u001b[0m\n", + "\u001b[32;1m\u001b[1;3m[{'p.name': 'Robert De Niro'}, {'p.name': 'Joe Pesci'}, {'p.name': 'Sharon Stone'}, {'p.name': 'James Woods'}]\u001b[0m\n", "\n", "\u001b[1m> Finished chain.\u001b[0m\n" ] @@ -224,10 +285,10 @@ "data": { "text/plain": [ "{'query': 'What was the cast of the Casino?',\n", - " 'result': 'The cast of Casino included Joe Pesci, Robert De Niro, Sharon Stone, and James Woods.'}" + " 'result': 'Robert De Niro, Joe Pesci, Sharon Stone, and James Woods were the cast of Casino.'}" ] }, - "execution_count": 5, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -236,9 +297,9 @@ "from langchain_neo4j import GraphCypherQAChain\n", "from langchain_openai import ChatOpenAI\n", "\n", - "llm = ChatOpenAI(model=\"gpt-3.5-turbo\", temperature=0)\n", + "llm = ChatOpenAI(model=\"gpt-4o\", temperature=0)\n", "chain = GraphCypherQAChain.from_llm(\n", - " graph=graph, llm=llm, verbose=True, allow_dangerous_requests=True\n", + " graph=enhanced_graph, llm=llm, verbose=True, allow_dangerous_requests=True\n", ")\n", "response = chain.invoke({\"query\": \"What was the cast of the Casino?\"})\n", "response" @@ -248,54 +309,754 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Validating relationship direction\n", + "## Advanced implementation with LangGraph\n", "\n", - "LLMs can struggle with relationship directions in generated Cypher statement. Since the graph schema is predefined, we can validate and optionally correct relationship directions in the generated Cypher statements by using the `validate_cypher` parameter." + "While the GraphCypherQAChain is effective for quick demonstrations, it may face challenges in production environments. Transitioning to LangGraph can enhance the workflow, but implementing natural language to query flows in production remains a complex task. Nevertheless, there are several strategies to significantly improve accuracy and reliability, which we will explore next.\n", + "\n", + "Here is the visualized LangGraph flow we will implement:\n", + "\n", + "![langgraph_text2cypher](../../static/img/langgraph_text2cypher.webp)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will begin by defining the Input, Output, and Overall state of the LangGraph application." ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "from operator import add\n", + "from typing import Annotated, List\n", + "\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class InputState(TypedDict):\n", + " question: str\n", + "\n", + "\n", + "class OverallState(TypedDict):\n", + " question: str\n", + " next_action: str\n", + " cypher_statement: str\n", + " cypher_errors: List[str]\n", + " database_records: List[dict]\n", + " steps: Annotated[List[str], add]\n", + "\n", + "\n", + "class OutputState(TypedDict):\n", + " answer: str\n", + " steps: List[str]\n", + " cypher_statement: str" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The first step is a simple `guardrails` step, where we validate whether the question pertains to movies or their cast. If it doesn't, we notify the user that we cannot answer any other questions. Otherwise, we move on to the Cypher generation step." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Literal\n", + "\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from pydantic import BaseModel, Field\n", + "\n", + "guardrails_system = \"\"\"\n", + "As an intelligent assistant, your primary objective is to decide whether a given question is related to movies or not. \n", + "If the question is related to movies, output \"movie\". Otherwise, output \"end\".\n", + "To make this decision, assess the content of the question and determine if it refers to any movie, actor, director, film industry, \n", + "or related topics. Provide only the specified output: \"movie\" or \"end\".\n", + "\"\"\"\n", + "guardrails_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " guardrails_system,\n", + " ),\n", + " (\n", + " \"human\",\n", + " (\"{question}\"),\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "\n", + "class GuardrailsOutput(BaseModel):\n", + " decision: Literal[\"movie\", \"end\"] = Field(\n", + " description=\"Decision on whether the question is related to movies\"\n", + " )\n", + "\n", + "\n", + "guardrails_chain = guardrails_prompt | llm.with_structured_output(GuardrailsOutput)\n", + "\n", + "\n", + "def guardrails(state: InputState) -> OverallState:\n", + " \"\"\"\n", + " Decides if the question is related to movies or not.\n", + " \"\"\"\n", + " guardrails_output = guardrails_chain.invoke({\"question\": state.get(\"question\")})\n", + " database_records = None\n", + " if guardrails_output.decision == \"end\":\n", + " database_records = \"This questions is not about movies or their cast. Therefore I cannot answer this question.\"\n", + " return {\n", + " \"next_action\": guardrails_output.decision,\n", + " \"database_records\": database_records,\n", + " \"steps\": [\"guardrail\"],\n", + " }" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Few-shot prompting" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Converting natural language into accurate queries is challenging. One way to enhance this process is by providing relevant few-shot examples to guide the LLM in query generation. To achieve this, we will use the `SemanticSimilarityExampleSelector` to dynamically select the most relevant examples." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.example_selectors import SemanticSimilarityExampleSelector\n", + "from langchain_neo4j import Neo4jVector\n", + "from langchain_openai import OpenAIEmbeddings\n", + "\n", + "examples = [\n", + " {\n", + " \"question\": \"How many artists are there?\",\n", + " \"query\": \"MATCH (a:Person)-[:ACTED_IN]->(:Movie) RETURN count(DISTINCT a)\",\n", + " },\n", + " {\n", + " \"question\": \"Which actors played in the movie Casino?\",\n", + " \"query\": \"MATCH (m:Movie {title: 'Casino'})<-[:ACTED_IN]-(a) RETURN a.name\",\n", + " },\n", + " {\n", + " \"question\": \"How many movies has Tom Hanks acted in?\",\n", + " \"query\": \"MATCH (a:Person {name: 'Tom Hanks'})-[:ACTED_IN]->(m:Movie) RETURN count(m)\",\n", + " },\n", + " {\n", + " \"question\": \"List all the genres of the movie Schindler's List\",\n", + " \"query\": \"MATCH (m:Movie {title: 'Schindler's List'})-[:IN_GENRE]->(g:Genre) RETURN g.name\",\n", + " },\n", + " {\n", + " \"question\": \"Which actors have worked in movies from both the comedy and action genres?\",\n", + " \"query\": \"MATCH (a:Person)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g1:Genre), (a)-[:ACTED_IN]->(:Movie)-[:IN_GENRE]->(g2:Genre) WHERE g1.name = 'Comedy' AND g2.name = 'Action' RETURN DISTINCT a.name\",\n", + " },\n", + " {\n", + " \"question\": \"Which directors have made movies with at least three different actors named 'John'?\",\n", + " \"query\": \"MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Person) WHERE a.name STARTS WITH 'John' WITH d, COUNT(DISTINCT a) AS JohnsCount WHERE JohnsCount >= 3 RETURN d.name\",\n", + " },\n", + " {\n", + " \"question\": \"Identify movies where directors also played a role in the film.\",\n", + " \"query\": \"MATCH (p:Person)-[:DIRECTED]->(m:Movie), (p)-[:ACTED_IN]->(m) RETURN m.title, p.name\",\n", + " },\n", + " {\n", + " \"question\": \"Find the actor with the highest number of movies in the database.\",\n", + " \"query\": \"MATCH (a:Actor)-[:ACTED_IN]->(m:Movie) RETURN a.name, COUNT(m) AS movieCount ORDER BY movieCount DESC LIMIT 1\",\n", + " },\n", + "]\n", + "\n", + "example_selector = SemanticSimilarityExampleSelector.from_examples(\n", + " examples, OpenAIEmbeddings(), Neo4jVector, k=5, input_keys=[\"question\"]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we implement the Cypher generation chain, also known as **text2cypher**. The prompt includes an enhanced graph schema, dynamically selected few-shot examples, and the user’s question. This combination enables the generation of a Cypher query to retrieve relevant information from the database." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.output_parsers import StrOutputParser\n", + "\n", + "text2cypher_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " (\n", + " \"Given an input question, convert it to a Cypher query. No pre-amble.\"\n", + " \"Do not wrap the response in any backticks or anything else. Respond with a Cypher statement only!\"\n", + " ),\n", + " ),\n", + " (\n", + " \"human\",\n", + " (\n", + " \"\"\"You are a Neo4j expert. Given an input question, create a syntactically correct Cypher query to run.\n", + "Do not wrap the response in any backticks or anything else. Respond with a Cypher statement only!\n", + "Here is the schema information\n", + "{schema}\n", + "\n", + "Below are a number of examples of questions and their corresponding Cypher queries.\n", + "\n", + "{fewshot_examples}\n", + "\n", + "User input: {question}\n", + "Cypher query:\"\"\"\n", + " ),\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "text2cypher_chain = text2cypher_prompt | llm | StrOutputParser()\n", + "\n", + "\n", + "def generate_cypher(state: OverallState) -> OverallState:\n", + " \"\"\"\n", + " Generates a cypher statement based on the provided schema and user input\n", + " \"\"\"\n", + " NL = \"\\n\"\n", + " fewshot_examples = (NL * 2).join(\n", + " [\n", + " f\"Question: {el['question']}{NL}Cypher:{el['query']}\"\n", + " for el in example_selector.select_examples(\n", + " {\"question\": state.get(\"question\")}\n", + " )\n", + " ]\n", + " )\n", + " generated_cypher = text2cypher_chain.invoke(\n", + " {\n", + " \"question\": state.get(\"question\"),\n", + " \"fewshot_examples\": fewshot_examples,\n", + " \"schema\": enhanced_graph.schema,\n", + " }\n", + " )\n", + " return {\"cypher_statement\": generated_cypher, \"steps\": [\"generate_cypher\"]}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Query validation\n", + "\n", + "The next step is to validate the generated Cypher statement and ensuring that all property values are accurate. While numbers and dates typically don’t require validation, strings such as movie titles or people’s names do. In this example, we’ll use a basic `CONTAINS` clause for validation, though more advanced mapping and validation techniques can be implemented if needed.\n", + "\n", + "First, we will create a chain that detects any errors in the Cypher statement and extracts the property values it references." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List, Optional\n", + "\n", + "validate_cypher_system = \"\"\"\n", + "You are a Cypher expert reviewing a statement written by a junior developer.\n", + "\"\"\"\n", + "\n", + "validate_cypher_user = \"\"\"You must check the following:\n", + "* Are there any syntax errors in the Cypher statement?\n", + "* Are there any missing or undefined variables in the Cypher statement?\n", + "* Are any node labels missing from the schema?\n", + "* Are any relationship types missing from the schema?\n", + "* Are any of the properties not included in the schema?\n", + "* Does the Cypher statement include enough information to answer the question?\n", + "\n", + "Examples of good errors:\n", + "* Label (:Foo) does not exist, did you mean (:Bar)?\n", + "* Property bar does not exist for label Foo, did you mean baz?\n", + "* Relationship FOO does not exist, did you mean FOO_BAR?\n", + "\n", + "Schema:\n", + "{schema}\n", + "\n", + "The question is:\n", + "{question}\n", + "\n", + "The Cypher statement is:\n", + "{cypher}\n", + "\n", + "Make sure you don't make any mistakes!\"\"\"\n", + "\n", + "validate_cypher_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " validate_cypher_system,\n", + " ),\n", + " (\n", + " \"human\",\n", + " (validate_cypher_user),\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "\n", + "class Property(BaseModel):\n", + " \"\"\"\n", + " Represents a filter condition based on a specific node property in a graph in a Cypher statement.\n", + " \"\"\"\n", + "\n", + " node_label: str = Field(\n", + " description=\"The label of the node to which this property belongs.\"\n", + " )\n", + " property_key: str = Field(description=\"The key of the property being filtered.\")\n", + " property_value: str = Field(\n", + " description=\"The value that the property is being matched against.\"\n", + " )\n", + "\n", + "\n", + "class ValidateCypherOutput(BaseModel):\n", + " \"\"\"\n", + " Represents the validation result of a Cypher query's output,\n", + " including any errors and applied filters.\n", + " \"\"\"\n", + "\n", + " errors: Optional[List[str]] = Field(\n", + " description=\"A list of syntax or semantical errors in the Cypher statement. Always explain the discrepancy between schema and Cypher statement\"\n", + " )\n", + " filters: Optional[List[Property]] = Field(\n", + " description=\"A list of property-based filters applied in the Cypher statement.\"\n", + " )\n", + "\n", + "\n", + "validate_cypher_chain = validate_cypher_prompt | llm.with_structured_output(\n", + " ValidateCypherOutput\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "LLMs often struggle with correctly determining relationship directions in generated Cypher statements. Since we have access to the schema, we can deterministically correct these directions using the **CypherQueryCorrector**. \n", + "\n", + "*Note: The `CypherQueryCorrector` is an experimental feature and doesn't support all the newest Cypher syntax.*" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_neo4j.chains.graph_qa.cypher_utils import CypherQueryCorrector, Schema\n", + "\n", + "# Cypher query corrector is experimental\n", + "corrector_schema = [\n", + " Schema(el[\"start\"], el[\"type\"], el[\"end\"])\n", + " for el in enhanced_graph.structured_schema.get(\"relationships\")\n", + "]\n", + "cypher_query_corrector = CypherQueryCorrector(corrector_schema)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we can implement the Cypher validation step. First, we use the `EXPLAIN` method to detect any syntax errors. Next, we leverage the LLM to identify potential issues and extract the properties used for filtering. For string properties, we validate them against the database using a simple `CONTAINS` clause.\n", + "\n", + "Based on the validation results, the process can take the following paths:\n", + "\n", + "- If value mapping fails, we end the conversation and inform the user that we couldn't identify a specific property value (e.g., a person or movie title). \n", + "- If errors are found, we route the query for correction. \n", + "- If no issues are detected, we proceed to the Cypher execution step. " + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "from neo4j.exceptions import CypherSyntaxError\n", + "\n", + "\n", + "def validate_cypher(state: OverallState) -> OverallState:\n", + " \"\"\"\n", + " Validates the Cypher statements and maps any property values to the database.\n", + " \"\"\"\n", + " errors = []\n", + " mapping_errors = []\n", + " # Check for syntax errors\n", + " try:\n", + " enhanced_graph.query(f\"EXPLAIN {state.get('cypher_statement')}\")\n", + " except CypherSyntaxError as e:\n", + " errors.append(e.message)\n", + " # Experimental feature for correcting relationship directions\n", + " corrected_cypher = cypher_query_corrector(state.get(\"cypher_statement\"))\n", + " if not corrected_cypher:\n", + " errors.append(\"The generated Cypher statement doesn't fit the graph schema\")\n", + " if not corrected_cypher == state.get(\"cypher_statement\"):\n", + " print(\"Relationship direction was corrected\")\n", + " # Use LLM to find additional potential errors and get the mapping for values\n", + " llm_output = validate_cypher_chain.invoke(\n", + " {\n", + " \"question\": state.get(\"question\"),\n", + " \"schema\": enhanced_graph.schema,\n", + " \"cypher\": state.get(\"cypher_statement\"),\n", + " }\n", + " )\n", + " if llm_output.errors:\n", + " errors.extend(llm_output.errors)\n", + " if llm_output.filters:\n", + " for filter in llm_output.filters:\n", + " # Do mapping only for string values\n", + " if (\n", + " not [\n", + " prop\n", + " for prop in enhanced_graph.structured_schema[\"node_props\"][\n", + " filter.node_label\n", + " ]\n", + " if prop[\"property\"] == filter.property_key\n", + " ][0][\"type\"]\n", + " == \"STRING\"\n", + " ):\n", + " pass\n", + " mapping = enhanced_graph.query(\n", + " f\"MATCH (n:{filter.node_label}) WHERE toLower(n.`{filter.property_key}`) = toLower($value) RETURN 'yes' LIMIT 1\",\n", + " {\"value\": filter.property_value},\n", + " )\n", + " if not mapping:\n", + " print(\n", + " f\"Missing value mapping for {filter.node_label} on property {filter.property_key} with value {filter.property_value}\"\n", + " )\n", + " mapping_errors.append(\n", + " f\"Missing value mapping for {filter.node_label} on property {filter.property_key} with value {filter.property_value}\"\n", + " )\n", + " if mapping_errors:\n", + " next_action = \"end\"\n", + " elif errors:\n", + " next_action = \"correct_cypher\"\n", + " else:\n", + " next_action = \"execute_cypher\"\n", + "\n", + " return {\n", + " \"next_action\": next_action,\n", + " \"cypher_statement\": corrected_cypher,\n", + " \"cypher_errors\": errors,\n", + " \"steps\": [\"validate_cypher\"],\n", + " }" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The Cypher correction step takes the existing Cypher statement, any identified errors, and the original question to generate a corrected version of the query." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "correct_cypher_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " (\n", + " \"You are a Cypher expert reviewing a statement written by a junior developer. \"\n", + " \"You need to correct the Cypher statement based on the provided errors. No pre-amble.\"\n", + " \"Do not wrap the response in any backticks or anything else. Respond with a Cypher statement only!\"\n", + " ),\n", + " ),\n", + " (\n", + " \"human\",\n", + " (\n", + " \"\"\"Check for invalid syntax or semantics and return a corrected Cypher statement.\n", + "\n", + "Schema:\n", + "{schema}\n", + "\n", + "Note: Do not include any explanations or apologies in your responses.\n", + "Do not wrap the response in any backticks or anything else.\n", + "Respond with a Cypher statement only!\n", + "\n", + "Do not respond to any questions that might ask anything else than for you to construct a Cypher statement.\n", + "\n", + "The question is:\n", + "{question}\n", + "\n", + "The Cypher statement is:\n", + "{cypher}\n", + "\n", + "The errors are:\n", + "{errors}\n", + "\n", + "Corrected Cypher statement: \"\"\"\n", + " ),\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "correct_cypher_chain = correct_cypher_prompt | llm | StrOutputParser()\n", + "\n", + "\n", + "def correct_cypher(state: OverallState) -> OverallState:\n", + " \"\"\"\n", + " Correct the Cypher statement based on the provided errors.\n", + " \"\"\"\n", + " corrected_cypher = correct_cypher_chain.invoke(\n", + " {\n", + " \"question\": state.get(\"question\"),\n", + " \"errors\": state.get(\"cypher_errors\"),\n", + " \"cypher\": state.get(\"cypher_statement\"),\n", + " \"schema\": enhanced_graph.schema,\n", + " }\n", + " )\n", + "\n", + " return {\n", + " \"next_action\": \"validate_cypher\",\n", + " \"cypher_statement\": corrected_cypher,\n", + " \"steps\": [\"correct_cypher\"],\n", + " }" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We need to add a step that executes the given Cypher statement. If no results are returned, we should explicitly handle this scenario, as leaving the context empty can sometimes lead to LLM hallucinations." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "no_results = \"I couldn't find any relevant information in the database\"\n", + "\n", + "\n", + "def execute_cypher(state: OverallState) -> OverallState:\n", + " \"\"\"\n", + " Executes the given Cypher statement.\n", + " \"\"\"\n", + "\n", + " records = enhanced_graph.query(state.get(\"cypher_statement\"))\n", + " return {\n", + " \"database_records\": records if records else no_results,\n", + " \"next_action\": \"end\",\n", + " \"steps\": [\"execute_cypher\"],\n", + " }" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The final step is to generate the answer. This involves combining the initial question with the database output to produce a relevant response." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "generate_final_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " \"You are a helpful assistant\",\n", + " ),\n", + " (\n", + " \"human\",\n", + " (\n", + " \"\"\"Use the following results retrieved from a database to provide\n", + "a succinct, definitive answer to the user's question.\n", + "\n", + "Respond as if you are answering the question directly.\n", + "\n", + "Results: {results}\n", + "Question: {question}\"\"\"\n", + " ),\n", + " ),\n", + " ]\n", + ")\n", + "\n", + "generate_final_chain = generate_final_prompt | llm | StrOutputParser()\n", + "\n", + "\n", + "def generate_final_answer(state: OverallState) -> OutputState:\n", + " \"\"\"\n", + " Decides if the question is related to movies.\n", + " \"\"\"\n", + " final_answer = generate_final_chain.invoke(\n", + " {\"question\": state.get(\"question\"), \"results\": state.get(\"database_records\")}\n", + " )\n", + " return {\"answer\": final_answer, \"steps\": [\"generate_final_answer\"]}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we will implement the LangGraph workflow, starting with defining the conditional edge functions." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "def guardrails_condition(\n", + " state: OverallState,\n", + ") -> Literal[\"generate_cypher\", \"generate_final_answer\"]:\n", + " if state.get(\"next_action\") == \"end\":\n", + " return \"generate_final_answer\"\n", + " elif state.get(\"next_action\") == \"movie\":\n", + " return \"generate_cypher\"\n", + "\n", + "\n", + "def validate_cypher_condition(\n", + " state: OverallState,\n", + ") -> Literal[\"generate_final_answer\", \"correct_cypher\", \"execute_cypher\"]:\n", + " if state.get(\"next_action\") == \"end\":\n", + " return \"generate_final_answer\"\n", + " elif state.get(\"next_action\") == \"correct_cypher\":\n", + " return \"correct_cypher\"\n", + " elif state.get(\"next_action\") == \"execute_cypher\":\n", + " return \"execute_cypher\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's put it all together now." + ] + }, + { + "cell_type": "code", + "execution_count": 19, "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "\u001b[1m> Entering new GraphCypherQAChain chain...\u001b[0m\n", - "Generated Cypher:\n", - "\u001b[32;1m\u001b[1;3mMATCH (:Movie {title: \"Casino\"})<-[:ACTED_IN]-(actor:Person)\n", - "RETURN actor.name\u001b[0m\n", - "Full Context:\n", - "\u001b[32;1m\u001b[1;3m[{'actor.name': 'Joe Pesci'}, {'actor.name': 'Robert De Niro'}, {'actor.name': 'Sharon Stone'}, {'actor.name': 'James Woods'}]\u001b[0m\n", - "\n", - "\u001b[1m> Finished chain.\u001b[0m\n" - ] - }, + "data": { + "image/png": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Image, display\n", + "from langgraph.graph import END, START, StateGraph\n", + "\n", + "langgraph = StateGraph(OverallState, input=InputState, output=OutputState)\n", + "langgraph.add_node(guardrails)\n", + "langgraph.add_node(generate_cypher)\n", + "langgraph.add_node(validate_cypher)\n", + "langgraph.add_node(correct_cypher)\n", + "langgraph.add_node(execute_cypher)\n", + "langgraph.add_node(generate_final_answer)\n", + "\n", + "langgraph.add_edge(START, \"guardrails\")\n", + "langgraph.add_conditional_edges(\n", + " \"guardrails\",\n", + " guardrails_condition,\n", + ")\n", + "langgraph.add_edge(\"generate_cypher\", \"validate_cypher\")\n", + "langgraph.add_conditional_edges(\n", + " \"validate_cypher\",\n", + " validate_cypher_condition,\n", + ")\n", + "langgraph.add_edge(\"execute_cypher\", \"generate_final_answer\")\n", + "langgraph.add_edge(\"correct_cypher\", \"validate_cypher\")\n", + "langgraph.add_edge(\"generate_final_answer\", END)\n", + "\n", + "langgraph = langgraph.compile()\n", + "\n", + "# View\n", + "display(Image(langgraph.get_graph().draw_mermaid_png()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now test the application by asking an irrelevant question." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ { "data": { "text/plain": [ - "{'query': 'What was the cast of the Casino?',\n", - " 'result': 'The cast of Casino included Joe Pesci, Robert De Niro, Sharon Stone, and James Woods.'}" + "{'answer': \"I'm sorry, but I cannot provide current weather information. Please check a reliable weather website or app for the latest updates on the weather in Spain.\",\n", + " 'steps': ['guardrail', 'generate_final_answer']}" ] }, - "execution_count": 6, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "chain = GraphCypherQAChain.from_llm(\n", - " graph=graph,\n", - " llm=llm,\n", - " verbose=True,\n", - " validate_cypher=True,\n", - " allow_dangerous_requests=True,\n", - ")\n", - "response = chain.invoke({\"query\": \"What was the cast of the Casino?\"})\n", - "response" + "langgraph.invoke({\"question\": \"What's the weather in Spain?\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's now ask something relevant about the movies." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'answer': 'The cast of \"Casino\" includes Robert De Niro, Joe Pesci, Sharon Stone, and James Woods.',\n", + " 'steps': ['guardrail',\n", + " 'generate_cypher',\n", + " 'validate_cypher',\n", + " 'execute_cypher',\n", + " 'generate_final_answer'],\n", + " 'cypher_statement': \"MATCH (m:Movie {title: 'Casino'})<-[:ACTED_IN]-(a:Person) RETURN a.name\"}" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "langgraph.invoke({\"question\": \"What was the cast of the Casino?\"})" ] }, { @@ -304,10 +1065,8 @@ "source": [ "### Next steps\n", "\n", - "For more complex query-generation, we may want to create few-shot prompts or add query-checking steps. For advanced techniques like this and more check out:\n", + "For other graph techniques like this and more check out:\n", "\n", - "* [Prompting strategies](/docs/how_to/graph_prompting): Advanced prompt engineering techniques.\n", - "* [Mapping values](/docs/how_to/graph_mapping): Techniques for mapping values from questions to database.\n", "* [Semantic layer](/docs/how_to/graph_semantic): Techniques for implementing semantic layers.\n", "* [Constructing graphs](/docs/how_to/graph_constructing): Techniques for constructing knowledge graphs." ] @@ -336,7 +1095,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.4" + "version": "3.11.5" } }, "nbformat": 4, diff --git a/docs/scripts/prepare_notebooks_for_ci.py b/docs/scripts/prepare_notebooks_for_ci.py index 4fb96152a9a..b96e2629462 100644 --- a/docs/scripts/prepare_notebooks_for_ci.py +++ b/docs/scripts/prepare_notebooks_for_ci.py @@ -25,8 +25,6 @@ NOTEBOOKS_NO_EXECUTION = [ "docs/docs/how_to/example_selectors_langsmith.ipynb", # TODO: add langchain-benchmarks; fix cassette issue "docs/docs/how_to/extraction_long_text.ipynb", # Non-determinism due to batch "docs/docs/how_to/graph_constructing.ipynb", # Requires local neo4j - "docs/docs/how_to/graph_mapping.ipynb", # Requires local neo4j - "docs/docs/how_to/graph_prompting.ipynb", # Requires local neo4j "docs/docs/how_to/graph_semantic.ipynb", # Requires local neo4j "docs/docs/how_to/hybrid.ipynb", # Requires AstraDB instance "docs/docs/how_to/indexing.ipynb", # Requires local Elasticsearch diff --git a/docs/static/img/langgraph_text2cypher.webp b/docs/static/img/langgraph_text2cypher.webp new file mode 100644 index 00000000000..a5afd292ceb Binary files /dev/null and b/docs/static/img/langgraph_text2cypher.webp differ diff --git a/docs/vercel.json b/docs/vercel.json index 869e2e6b506..f91844dda12 100644 --- a/docs/vercel.json +++ b/docs/vercel.json @@ -62,6 +62,14 @@ "source": "/docs/tutorials/local_rag", "destination": "/docs/tutorials/rag" }, + { + "source": "/docs/how_to/graph_mapping(/?)", + "destination": "/docs/tutorials/graph#query-validation" + }, + { + "source": "/docs/how_to/graph_prompting(/?)", + "destination": "/docs/tutorials/graph#few-shot-prompting" + }, { "source": "/docs/tutorials/data_generation", "destination": "https://python.langchain.com/v0.2/docs/tutorials/data_generation/"