1nvfdvce7p Wgr0zfre3mnq.png

Los datos para este proyecto provienen de La base de datos de películas (TMDB), con permiso del propietario. Su API era fácil de usar, estaba bien mantenida y no tenía una velocidad muy limitada. Saqué los siguientes atributos de película de su API:

  • Título
  • Tiempo de ejecución (minutos)
  • Idioma
  • Descripción general
  • Año de lanzamiento
  • Género
  • Palabras clave que describen la película.
  • actores
  • Directores
  • Lugares para transmitir
  • Lugares para comprar
  • Lugares para alquilar
  • Lista de empresas productoras

A continuación se muestra un fragmento de cómo se extrajeron los datos utilizando la API TMDB y la biblioteca de respuestas de Python:

def get_data(API_key, Movie_ID, max_retries=5):
"""
Function to pull details of your film of interest in JSON format.

parameters:
API_key (str): Your API key for TMBD
Movie_ID (str): TMDB id for film of interest

returns:
dict: JSON formatted dictionary containing all details of your film of
interest
"""

query = 'https://api.themoviedb.org/3/movie/' + Movie_ID + \
'?api_key='+API_key + '&append_to_response=keywords,' + \
'watch/providers,credits'
response = requests.get(query)
for i in range(max_retries):
if response.status_code == 429:
# If the response was a 429, wait and then try again
print(
f"Request limit reached. Waiting and retrying ({i+1}/{
max_retries})")
time.sleep(2 ** i) # Exponential backoff
else:
dict = response.json()
return dict

Tenga en cuenta que la consulta requiere ID de película (que también se obtuvieron utilizando TMDB), así como append_to_response, lo que me permite obtener varios tipos de datos, por ejemplo, palabras clave, proveedores de relojes, créditos (directores y actores), además de información básica sobre la película. También hay un código de andamiaje básico en caso de que alcance un límite de velocidad, aunque esto nunca se cumplió.

Luego tenemos que analizar la respuesta JSON. Aquí hay un fragmento que muestra cómo se hizo esto para analizar a los actores y directores que trabajaron en una película:

credits = dict['credits']
actor_list, director_list = [], []

# Parsing cast
cast = credits['cast']
NUM_ACTORS = 5
for member in cast[:NUM_ACTORS]:
actor_list.append(member["name"])

# Parsing crew
crew = credits['crew']
for member in crew:
if member['job'] == 'Director':
director_list.append(member["name"])

actor_str = ', '.join(list(set(actor_list)))
director_str = ', '.join(list(set(director_list)))

Tenga en cuenta que limité el número de actores a los cinco primeros en una película. También tuve que especificar que sólo me interesaban los directores, ya que la respuesta incluía otro tipo de miembros del equipo como editores, diseñadores de vestuario, etc.

Luego, todos estos datos se compilaron en archivos CSV. Cada atributo enumerado anteriormente se convirtió en una columna y cada fila ahora representa una película en particular. A continuación se muestra un breve fragmento de películas del 2008_movie_collection_data.csv archivo que fue creado programáticamente. Para este proyecto conseguí aproximadamente las 100 mejores películas de los años 1920-2023.

Fragmento de datos de la película con fines de demostración. Por autor.

Lo creas o no, todavía no he visto Kung Fu Panda. Quizás tenga que hacerlo después de este proyecto.

Luego tuve que cargar los datos csv en Pinecone. Normalmente, la fragmentación es importante en un sistema RAG, pero aquí cada “documento” (fila de un archivo CSV) es bastante corto, por lo que la fragmentación no era una preocupación. Primero tuve que convertir cada archivo CSV en un documento LangChain y luego especificar qué campos deberían ser el contenido principal y qué campos deberían ser los metadatos.

A continuación se muestra un fragmento de código utilizado para construir estos documentos:

# Loading in data from all csv files
loader = DirectoryLoader(
path="./data",
glob="*.csv",
loader_cls=CSVLoader,
show_progress=True)

docs = loader.load()

metadata_field_info = [
AttributeInfo(
name="Title", description="The title of the movie", type="string"),
AttributeInfo(name="Runtime (minutes)",
description="The runtime of the movie in minutes", type="integer"),
AttributeInfo(name="Language",
description="The language of the movie", type="string"),
...
]

for doc in docs:
# Parse the page_content string into a dictionary
page_content_dict = dict(line.split(": ", 1)
for line in doc.page_content.split("\n") if ": " in line)

doc.page_content = 'Overview: ' + page_content_dict.get(
'Overview') + '. Keywords: ' + page_content_dict.get('Keywords')
doc.metadata = {field.name: page_content_dict.get(
field.name) for field in metadata_field_info}

# Convert fields from string to list of strings
for field in fields_to_convert_list:
convert_to_list(doc, field)

# Convert fields from string to integers
for field in fields_to_convert_int:
convert_to_int(doc, field)

DirectoryLoader de LangChain se encarga de cargar todos los archivos csv en los documentos. Entonces necesito especificar lo que debería ser page_content y que debería ser metadata . Ésta es una decisión importante. page_content se incrustará y utilizará en la búsqueda de similitudes durante la fase de recuperación. metadata se utilizará únicamente con fines de filtrado antes de realizar la búsqueda de similitud. Decidí tomar el overview y keywords propiedades e incrustarlas, y el resto de las propiedades serían metadatos. Se deberían hacer más ajustes para ver si tal vez title también debería incluirse en page_contentpero encontré que esta configuración funciona bien para la mayoría de las consultas de los usuarios.

Luego, los documentos deben cargarse en Pinecone. Este es un proceso bastante sencillo:

# Create empty index
PINECONE_KEY, PINECONE_INDEX_NAME = os.getenv(
'PINECONE_API_KEY'), os.getenv('PINECONE_INDEX_NAME')

pc = Pinecone(api_key=PINECONE_KEY)

# Uncomment if index is not created already
pc.create_index(
name=PINECONE_INDEX_NAME,
dimension=1536,
metric="cosine",
spec=PodSpec(
environment="gcp-starter"
)
)

# Target index and check status
pc_index = pc.Index(PINECONE_INDEX_NAME)
print(pc_index.describe_index_stats())

embeddings = OpenAIEmbeddings(model='text-embedding-ada-002')

vectorstore = PineconeVectorStore(
pc_index, embeddings
)

# Create record manager
namespace = f"pinecone/{PINECONE_INDEX_NAME}"
record_manager = SQLRecordManager(
namespace, db_url="sqlite:///record_manager_cache.sql"
)

record_manager.create_schema()

# Upload documents to pinecome
index(docs, record_manager, vectorstore,
cleanup="full", source_id_key="Website")

Sólo resaltaré algunas cosas aquí:

  • Usando un SQLRecordManager garantiza que no se carguen documentos duplicados en Pinecone si este código se ejecuta varias veces. Si se modifica un documento, solo ese documento se modifica en el almacén de vectores.
  • Estamos usando el clásico. text-embedding-ada-002 de OpenAI como nuestro modelo de integración.

El recuperador de consulta automática nos permitirá filtrar las películas que se recuperan durante RAG a través de los metadatos que definimos anteriormente. Esto aumentará drásticamente la utilidad de nuestro recomendador de películas.

Una consideración importante al elegir su tienda de vectores es asegurarse de que admita el filtrado por metadatos, porque no todos lo hacen. Aquí hay una lista de bases de datos. de LangChain que admiten la recuperación mediante autoconsulta. Otra consideración importante es qué tipos de comparadores se permiten para cada almacén de vectores. Los comparadores son el método mediante el cual filtramos mediante metadatos. Por ejemplo, podemos utilizar el eq comparador para asegurarnos de que nuestra película entre en el género de ciencia ficción: eq('Genre', 'Science Fiction') . No todas las tiendas de vectores permiten todos los comparadores. Como ejemplo, consulte el comparadores permitidos en Chroma y cómo varían de la comparadores en Pinecone. Necesitamos informarle al modelo qué comparadores están permitidos para evitar que escriba accidentalmente una consulta prohibida.

Además de decirle al modelo qué comparadores existen, también podemos alimentar el modelo con ejemplos de consultas de usuarios y filtros correspondientes. Esto se conoce como aprendizaje de pocas oportunidadesy es invaluable para ayudar a guiar su modelo.

Para ver dónde ayuda esto, eche un vistazo a las dos consultas de usuarios siguientes:

  • “Recomiendo algunas películas de Yorgos Lanthimos.”
  • «Películas similares a las películas de Yorgos Lanthmios».

Es fácil para mi modelo de filtrado de metadatos escribir la misma consulta de filtro para cada uno de estos ejemplos, aunque quiero que se traten de forma diferente. La primera debería incluir únicamente películas dirigidas por Lanthimos, mientras que la segunda debería producir películas que tengan una similar onda a las películas de Lanthimos. Para garantizar este comportamiento, le doy al modelo ejemplos de mi comportamiento deseado. Lo bueno de los modelos de lenguaje es que pueden usar sus habilidades de «razonamiento» y su conocimiento del mundo para generalizar a partir de estos pocos ejemplos a las consultas de otros usuarios.

document_content_description = "Brief overview of a movie, along with keywords"

# Define allowed comparators list
allowed_comparators = [
"$eq", # Equal to (number, string, boolean)
"$ne", # Not equal to (number, string, boolean)
"$gt", # Greater than (number)
"$gte", # Greater than or equal to (number)
"$lt", # Less than (number)
"$lte", # Less than or equal to (number)
"$in", # In array (string or number)
"$nin", # Not in array (string or number)
"$exists", # Has the specified metadata field (boolean)
]

examples = [
(
"Recommend some films by Yorgos Lanthimos.",
{
"query": "Yorgos Lanthimos",
"filter": 'in("Directors", ["Yorgos Lanthimos]")',
},
),
(
"Films similar to Yorgos Lanthmios movies.",
{
"query": "Dark comedy, absurd, Greek Weird Wave",
"filter": 'NO_FILTER',
},
),
...
]

metadata_field_info = [
AttributeInfo(
name="Title", description="The title of the movie", type="string"),
AttributeInfo(name="Runtime (minutes)",
description="The runtime of the movie in minutes", type="integer"),
AttributeInfo(name="Language",
description="The language of the movie", type="string"),
...
]

constructor_prompt = get_query_constructor_prompt(
document_content_description,
metadata_field_info,
allowed_comparators=allowed_comparators,
examples=examples,
)

output_parser = StructuredQueryOutputParser.from_components()
query_constructor = constructor_prompt | query_model | output_parser

retriever = SelfQueryRetriever(
query_constructor=query_constructor,
vectorstore=vectorstore,
structured_query_translator=PineconeTranslator(),
search_kwargs={'k': 10}
)

Además de los ejemplos, el modelo también debe conocer una descripción de cada campo de metadatos. Esto le ayuda a comprender qué filtrado de metadatos es posible.

Finalmente, construimos nuestra cadena. Aquí query_model es una instancia de GPT-4 Turbo que utiliza la API OpenAI. Recomiendo usar GPT-4 en lugar de 3.5 para escribir estas consultas de filtro de metadatos, ya que este es un paso crítico y en el que 3.5 se equivoca con más frecuencia. search_kwargs={'k':10} le dice al recuperador que busque las diez películas más similares según la consulta del usuario.

Finalmente, después de construir el recuperador de consulta automática, podemos construir el modelo RAG estándar encima de él. Comenzamos definiendo nuestro modelo de chat. Esto es lo que yo llamo un modelo de resumen porque toma un contexto (películas recuperadas + mensaje del sistema) y responde con un resumen de cada recomendación. Este modelo puede ser GPT-3.5 Turbo si está intentando mantener bajos los costos, o GPT-4 Turbo si desea obtener los mejores resultados.

En el mensaje del sistema le digo al bot cuál es su objetivo y le proporciono una serie de recomendaciones y restricciones, el Lo más importante es no recomendar una película que no le haya sido proporcionada por el recuperador de autoconsulta. Durante las pruebas, tuve problemas cuando la consulta de un usuario no arrojó películas en la base de datos. Por ejemplo, la consulta: “Recomendar algunas películas de terror protagonizadas por Matt Damon dirigidas por Wes Anderson realizadas antes de 1980” haría que el recuperador de autoconsulta no recuperara ninguna película (porque, por increíble que parezca, esa película no existe). Presentado sin datos de películas en su contexto, el modelo usaría su propia memoria (defectuosa) para intentar recomendar algunas películas. Este no es un buen comportamiento. No quiero que un recomendador de Netflix hable sobre películas que no están en la base de datos. El siguiente mensaje del sistema logró detener este comportamiento. Noté que GPT-4 sigue mejor las instrucciones que GPT-3.5, como era de esperar.

chat_model = ChatOpenAI(
model=SUMMARY_MODEL_NAME,
temperature=0,
streaming=True,
)

prompt = ChatPromptTemplate.from_messages(
[
(
'system',
"""
Your goal is to recommend films to users based on their
query and the retrieved context. If a retrieved film doesn't seem
relevant, omit it from your response. If your context is empty
or none of the retrieved films are relevant, do not recommend films
, but instead tell the user you couldn't find any films
that match their query. Aim for three to five film recommendations,
as long as the films are relevant. You cannot recommend more than
five films. Your recommendation should be relevant, original, and
at least two to three sentences long.

YOU CANNOT RECOMMEND A FILM IF IT DOES NOT APPEAR IN YOUR
CONTEXT.

# TEMPLATE FOR OUTPUT
- **Title of Film**:
- Runtime:
- Release Year:
- Streaming:
- (Your reasoning for recommending this film)

Question: {question}
Context: {context}
"""
),
]
)

def format_docs(docs):
return "\n\n".join(f"{doc.page_content}\n\nMetadata: {doc.metadata}" for doc in docs)

# Create a chatbot Question & Answer chain from the retriever
rag_chain_from_docs = (
RunnablePassthrough.assign(
context=(lambda x: format_docs(x["context"])))
| prompt
| chat_model
| StrOutputParser()
)

rag_chain_with_source = RunnableParallel(
{"context": retriever, "question": RunnablePassthrough()}
).assign(answer=rag_chain_from_docs)

format_docs se utiliza para formatear la información presentada al modelo para que sea fácil de entender y analizar. Presentamos al modelo tanto el page_content (descripción general y palabras clave), así como la metadata (todas las demás propiedades de la película); cualquier cosa que pueda necesitar para recomendar mejor una película al usuario.

rag_chain_from_docs es una cadena que toma los documentos recuperados, los formatea usando format_docs , introduce los documentos formateados en el contexto que luego utiliza el modelo para responder la pregunta. Finalmente creamos rag_chain_with_source el cual es un RunnableParallel que, como su nombre indica, ejecuta dos operaciones en paralelo: el recuperador de consultas automáticas recupera documentos similares mientras que la consulta simplemente se pasa al modelo a través de RunnablePassthrough() . Luego se combinan los resultados de los componentes paralelos y rag_chain_from_docs se utiliza para generar la respuesta. Aquí source se refiere al recuperador, que accede a todos los documentos ‘fuente’.

Como quiero que la respuesta se transmita (por ejemplo, presentada al usuario fragmento por fragmento como ChatGPT), utilizamos el siguiente código:

for chunk in rag_chain_with_source.stream(query):
for key in chunk:
if key == 'answer':
yield chunk[key]

Ahora a la parte divertida: jugar con el modelo. Como se mencionó anteriormente, Streamlit se utilizó para crear la interfaz y alojar la aplicación. No discutiré el código de la interfaz de usuario aquí; consulte el código sin formato para obtener detalles sobre la implementación. Es bastante sencillo y hay muchos otros ejemplos en el Sitio web optimizado.

Interfaz de usuario de búsqueda de películas. Por autor.

Hay varias sugerencias que puede utilizar, pero probemos nuestra propia consulta:

Consulta de ejemplo y respuesta modelo. Por autor.

Entre bastidores, el perro perdiguero que se interrogaba a sí mismo se aseguró de filtrar todas las películas que no estuvieran en francés. Luego, realizó una búsqueda de similitudes de “historias de mayoría de edad”, resultando en diez películas en el contexto. Finalmente, el robot resumidor seleccionó cinco películas para recomendar. Tenga en cuenta la variedad de películas sugeridas: algunas con fechas de estreno desde 1959 hasta 2012. Para mayor comodidad, me aseguro de que el bot incluya el tiempo de ejecución de la película, el año de estreno, los proveedores de transmisión y una breve recomendación elaborada a mano por el bot.

(Nota al margen: si no has visto Los 400 golpesdeja lo que estés haciendo y vete Míralo inmediatamente.)

Cualidades que normalmente se consideran negativas en un modelo de lenguaje grande, como la naturaleza no determinista de sus respuestas, ahora son positivas. Haga al modelo la misma pregunta dos veces y es posible que obtenga recomendaciones ligeramente diferentes.

Es importante tener en cuenta algunas limitaciones de la implementación actual:

  • No se guardan recomendaciones. Es probable que los usuarios quieran volver a visitar recomendaciones antiguas.
  • Actualización manual de datos sin procesar de The Movie Database. Automatizar esto y actualizarlo semanalmente sería una buena idea.
  • Mal filtrado de metadatos mediante la recuperación de autoconsulta. Por ejemplo, la consulta «películas de Ben Affleck» podría resultar problemática. Esto podría significar películas en las que Ben Affleck sea el estrella o películas que han sido dirigido Por Ben Affleck. Este es un ejemplo en el que sería útil aclarar la consulta.

Posibles mejoras a este proyecto podrían ser realizar una reclasificación de documentos después de la recuperación. También podría ser interesante tener un modelo de chat con el que puedas conversar en conversaciones de varios turnos, en lugar de simplemente un robot de control de calidad. También se podría crear un recomendador de agentes que solicita al usuario una pregunta aclaratoria si la consulta no está clara.

¡Diviértete buscando películas!