Búsqueda semántica eficiente sobre texto no estructurado en Neo4j
Búsqueda semántica eficiente en Neo4j
Integra el índice vector recién agregado a LangChain para mejorar tus aplicaciones RAG
Desde la aparición de ChatGPT hace seis meses, el panorama tecnológico ha experimentado un cambio transformador. La capacidad excepcional de ChatGPT para generalizar ha disminuido la necesidad de equipos especializados en aprendizaje profundo y conjuntos extensos de datos de entrenamiento para crear modelos NLP personalizados. Esto ha democratizado el acceso a una variedad de tareas de NLP, como la resumen y extracción de información, haciéndolas más disponibles que nunca. Sin embargo, pronto nos dimos cuenta de las limitaciones de los modelos similares a ChatGPT, como la fecha de corte del conocimiento y la falta de acceso a información privada. En mi opinión, lo que siguió fue la segunda ola de transformación de la IA generativa con el surgimiento de aplicaciones de Generación Mejorada con Recuperación (RAG), donde se alimenta información relevante al modelo en el momento de la consulta para construir respuestas mejores y más precisas.

Como se mencionó, las aplicaciones RAG requieren una herramienta de búsqueda inteligente que pueda recuperar información adicional basada en la entrada del usuario, lo que permite a los LLM generar respuestas más precisas y actualizadas. Al principio, el enfoque se centraba principalmente en recuperar información de texto no estructurado utilizando la búsqueda semántica. Sin embargo, pronto quedó claro que la combinación de datos estructurados y no estructurados es el mejor enfoque para las aplicaciones RAG si quieres ir más allá de las aplicaciones “Chatea con tu PDF”.
Neo4j fue y sigue siendo una excelente opción para manejar información estructurada, pero tuvo algunas dificultades con la búsqueda semántica debido a su enfoque de fuerza bruta. Sin embargo, esa lucha es cosa del pasado, ya que Neo4j ha introducido un nuevo índice vectorial en la versión 5.11 diseñado para realizar eficientemente búsqueda semántica en texto no estructurado u otras modalidades de datos incrustados. El índice vectorial recién agregado hace que Neo4j sea una excelente opción para la mayoría de las aplicaciones RAG, ya que ahora funciona muy bien con datos estructurados y no estructurados.
En esta publicación del blog te mostraré cómo configurar un índice vectorial en Neo4j e integrarlo en el ecosistema de LangChain. El código está disponible en GitHub.
Configuración del entorno de Neo4j
Necesitas configurar una instancia de Neo4j 5.11 o superior para seguir los ejemplos en esta publicación del blog. La forma más sencilla es iniciar una instancia gratuita en Neo4j Aura, que ofrece instancias en la nube de la base de datos de Neo4j. Alternativamente, también puedes configurar una instancia local de la base de datos de Neo4j descargando la aplicación Neo4j Desktop y creando una instancia de base de datos local.
- El Poder de la Alfabetización en Datos
- SMART lanza grupo de investigación para avanzar en IA, automatizaci...
- Análisis de importancia de características con SHAP que aprendí en ...
Después de haber instanciado la base de datos de Neo4j, puedes usar la biblioteca LangChain para conectarte a ella.
from langchain.graphs import Neo4jGraphNEO4J_URI="neo4j+s://1234.databases.neo4j.io"NEO4J_USERNAME="neo4j"NEO4J_PASSWORD="-"graph = Neo4jGraph( url=NEO4J_URI, username=NEO4J_USERNAME, password=NEO4J_PASSWORD)
Configuración del Índice Vectorial
El índice vectorial de Neo4j está impulsado por Lucene, donde Lucene implementa un Grafo de Mundo Pequeño Navegable Jerárquico (HNSW) para realizar una consulta aproximada de vecinos más cercanos (ANN) en el espacio vectorial.
La implementación de Neo4j del índice vectorial está diseñada para indexar una única propiedad de nodo de una etiqueta de nodo. Por ejemplo, si deseas indexar nodos con la etiqueta Chunk
en su propiedad de nodo embedding
, utilizarías el siguiente procedimiento Cypher.
CALL db.index.vector.createNodeIndex( 'wikipedia', // nombre del índice 'Chunk', // etiqueta del nodo 'embedding', // propiedad del nodo 1536, // tamaño del vector 'cosine' // métrica de similitud)
Junto con el nombre del índice, la etiqueta del nodo y la propiedad, debes especificar el tamaño del vector (dimensión del embedding) y la métrica de similitud. Estaremos utilizando el modelo de embedding de texto-embedding-ada-002 de OpenAI, que utiliza un tamaño de vector 1536 para representar texto en el espacio de embedding. En este momento, solo están disponibles las métricas de similitud cosine y Euclidean. OpenAI sugiere utilizar la métrica de similitud del coseno al usar su modelo de embedding.
Populando el índice del vector
Neo4j es sin esquema por diseño, lo que significa que no impone restricciones sobre qué se puede almacenar en una propiedad de un nodo. Por ejemplo, la propiedad embedding
del nodo Chunk
podría almacenar enteros, listas de enteros o incluso cadenas de texto. Vamos a probar esto.
WITH [1, [1,2,3], ["2","5"], [x in range(0, 1535) | toFloat(x)]] AS exampleValuesUNWIND range(0, size(exampleValues) - 1) as indexCREATE (:Chunk {embedding: exampleValues[index], index: index})
Esta consulta crea un nodo Chunk
para cada elemento de la lista y utiliza el elemento como valor de la propiedad embedding
. Por ejemplo, el primer nodo Chunk
tendrá el valor de la propiedad embedding
1, el segundo nodo [1,2,3], y así sucesivamente. Neo4j no impone reglas sobre lo que se puede almacenar en las propiedades de los nodos. Sin embargo, el índice del vector tiene instrucciones claras sobre el tipo de valores y su dimensión de embedding que debe indexar.
Podemos probar qué valores se han indexado realizando una búsqueda en el índice del vector.
CALL db.index.vector.queryNodes( 'wikipedia', // nombre del índice 3, // vecinos topK a devolver [x in range(0,1535) | toFloat(x) / 2] // vector de entrada)YIELD node, scoreRETURN node.index AS index, score
Si ejecutas esta consulta, obtendrás solo un nodo devuelto, aunque hayas solicitado que se devuelvan los 3 vecinos principales. ¿Por qué es así? El índice del vector solo indexa los valores de las propiedades, donde el valor es una lista de números decimales con el tamaño especificado. En este ejemplo, solo un valor de la propiedad embedding
tenía el tipo de lista de números decimales con la longitud seleccionada de 1536.
Un nodo se indexa en el índice del vector si se cumplen todas las siguientes condiciones:
- El nodo contiene la etiqueta configurada.
- El nodo contiene la clave de propiedad configurada.
- El valor de propiedad respectivo es de tipo
LIST<FLOAT>
. - El
size()
del valor respectivo es el mismo que la dimensionalidad configurada. - El valor es un vector válido para la función de similitud configurada.
Integrando el índice del vector en el ecosistema de LangChain
Ahora implementaremos una clase simple personalizada de LangChain que utilizará el índice del vector de Neo4j para obtener información relevante y generar respuestas precisas y actualizadas. Pero primero, tenemos que poblar el índice del vector.

La tarea consistirá en los siguientes pasos:
- Obtener un artículo de Wikipedia
- Dividir el texto en fragmentos
- Almacenar el texto junto con su representación vectorial en Neo4j
- Implementar una clase personalizada de LangChain para admitir aplicaciones de RAG
En este ejemplo, obtendremos solo un artículo de Wikipedia. He decidido utilizar la página de Baldur’s Gate 3.
import wikipediabg3 = wikipedia.page(pageid=60979422)
A continuación, necesitamos dividir y embeber el texto. Dividiremos el texto por secciones utilizando el delimitador de doble salto de línea y luego utilizaremos el modelo de embebimiento de OpenAI para representar cada sección con un vector apropiado.
import osfrom langchain.embeddings import OpenAIEmbeddingsos.environ["OPENAI_API_KEY"] = "API_KEY"embeddings = OpenAIEmbeddings()chunks = [{'text':el, 'embedding': embeddings.embed_query(el)} for el in bg3.content.split("\n\n") if len(el) > 50]
Antes de pasar a la clase de LangChain, debemos importar los fragmentos de texto en Neo4j.
graph.query("""UNWIND $data AS rowCREATE (c:Chunk {text: row.text})WITH c, rowCALL db.create.setVectorProperty(c, 'embedding', row.embedding)YIELD nodeRETURN distinct 'done'""", {'data': chunks})
Una cosa que puedes notar es que he utilizado el procedimiento db.create.setVectorProperty
para almacenar los vectores en Neo4j. Este procedimiento se utiliza para verificar que el valor de la propiedad sea realmente una lista de números decimales. Además, tiene la ventaja adicional de reducir el espacio de almacenamiento de la propiedad del vector aproximadamente en un 50%. Por lo tanto, se recomienda siempre utilizar este procedimiento para almacenar vectores en Neo4j.
Ahora podemos implementar la clase personalizada LangChain utilizada para obtener información del índice de vectores de Neo4j y utilizarla para generar respuestas. Primero, definiremos la declaración Cypher utilizada para recuperar información.
vector_search = """WITH $embedding AS eCALL db.index.vector.queryNodes('wikipedia',3, e) yield node, scoreRETURN node.text AS resultORDER BY score DESCLIMIT 3"""
Como puedes ver, he codificado el nombre del índice y el número k de vecinos a recuperar. Si lo deseas, puedes hacer esto dinámico agregando los parámetros apropiados.
La clase personalizada Neo4jVectorChain se implementa de manera bastante sencilla.
class Neo4jVectorChain(Chain): """Cadena para preguntas y respuestas contra un índice de vectores de Neo4j.""" graph: Neo4jGraph = Field(exclude=True) input_key: str = "query" #: :meta private: output_key: str = "result" #: :meta private: embeddings: OpenAIEmbeddings = OpenAIEmbeddings() qa_chain: LLMChain = LLMChain(llm=ChatOpenAI(temperature=0), prompt=CHAT_PROMPT) def _call(self, inputs: Dict[str, str], run_manager) -> Dict[str, Any]: """Incrusta una pregunta y realiza una búsqueda por vectores.""" question = inputs[self.input_key] # Incrusta la pregunta embedding = self.embeddings.embed_query(question) # Recupera información relevante del índice de vectores context = self.graph.query( vector_search, {'embedding': embedding}) context = [el['result'] for el in context] # Genera la respuesta result = self.qa_chain( {"question": question, "context": context}, ) final_result = result[self.qa_chain.output_key] return {self.output_key: final_result}
He omitido parte del código básico para que sea más legible. Básicamente, cuando se llama a Neo4jVectorChain, se ejecutan los siguientes pasos:
- Incrustar la pregunta utilizando el modelo de incrustación relevante
- Utilizar el valor de la incrustación de texto para recuperar contenido más similar del índice de vectores
- Utilizar el contexto proporcionado del contenido similar para generar la respuesta
Ahora podemos probar nuestra implementación.
vector_qa = Neo4jVectorChain(graph=graph, embeddings=embeddings, verbose=True)vector_qa.run("¿Cómo es la jugabilidad de Baldur's Gate 3?")
Respuesta

Utilizando la opción verbose
, también puedes evaluar el contexto recuperado del índice de vectores que se utilizó para generar la respuesta.
Resumen
Aprovechando las capacidades de indexación de vectores de Neo4j, puedes crear una fuente de datos unificada que alimenta aplicaciones de Generación con Recuperación mejoradas de manera efectiva. Esto te permite no solo implementar soluciones de “Chat con tu PDF o documentación”, sino también realizar análisis en tiempo real, todo desde una única fuente de datos sólida. Esta utilidad multiusos puede agilizar tus operaciones y mejorar la sinergia de los datos, convirtiendo a Neo4j en una excelente solución para gestionar tanto datos estructurados como no estructurados.
Como siempre, el código está disponible en GitHub.