很久没有写过有趣的代码了,最近因为OpenAI开放了价格很低的API,所以很多和我一样对AGI一无所知的人都试图来实现一些好玩的东西。不过能看到的大部分应用都是聊天或者翻译之类的,感觉没什么新意,所以上周先写了个不同一点的玩具:将AI接入本地电脑的工具

最近在想,利用LLM强大的能力,在一定程度上其实可以实现人的数字永生。趁周末准备把我的博客内容提取一下,实现一个与自己对话的工具。这时候我发现了一个重大的障碍就是我的博客已经好多年没有更新了(于是正好更新一篇吧),并且也没什么有营养的内容。不过这个难题好解决,因为我马上就想到 @屈屈 的博客内容质量很高,内容量也足够我实现一个demo,所以我先实现一个JerryAI。

先看下效果:

实现原理

TL;DR: 先将文章内容通过 OpenAI 的 Embedding 模型转化成向量数据,然后与问题进行向量距离计算,最后将topK答案扔给 OpenAI 润色输出。

下面是主要的步骤,也可以参考官方的这个cookbook

第一步,获取内容源

下载数据这一步就不详细说明了,大家注意使用自己的博客、微博等内容源进行测试,不要逮着一个同学薅羊毛。

下载完之后可以做一些简单处理,主要是需要先去除图片、视频标签及无关的换行空白等,最终形成纯文本文件。

第二步,优化内容token长度

import os
import tiktoken

tokenizer = tiktoken.get_encoding("cl100k_base")
posts = []

# 假如我们第一步先将文章内容保存到了本地./posts 目录下
for file in os.listdir('posts/'):
with open('posts/' + file, 'r', encoding='UTF-8') as f:
    text = f.read()
    [title, content] = text.split('@@@')
    tokens = len(tokenizer.encode(content))
    posts.append({'title': title, 'content': content, 'tokens': tokens})

tiktoken 是 OpenAI 提供的一个工具,可以快速标记文本成模型 token 以计算其长度。

计算完之后可以统计一下每篇文章的 token 长度,因为 OpenAI 的不同模型对输入 token 长度有不同限制,如果超出了后续需要使用的模型限制,在这里就需要将内容做下分割。同时也要考虑到 system 和 user 的 prompt 将会占用的长度。

下面是检测和分割内容的主要代码,如果你使用的是微博这种很短的内容可以忽略这一步。

def split_text(text, max_tokens = max_tokens):
    sentences = text.split('。') #按句号分割,以防将同一个句子分割到了不同的text断中
    n_tokens = [len(tokenizer.encode(" " + sentence)) for sentence in sentences]
    chunks = []
    tokens_so_far = 0
    chunk = []

    for sentence, token in zip(sentences, n_tokens):
        if tokens_so_far + token > max_tokens:
            chunks.append("。".join(chunk) + "。")
            chunk = []
            tokens_so_far = 0
        if token > max_tokens:
            continue

        chunk.append(sentence)
        tokens_so_far += token + 1
    return chunks

ebd_posts = []
for post in posts:
    if post['content'] is None:
        continue
    if post['tokens'] > max_tokens:
        splited_posts = split_text(post['content'])
        for _post in splited_posts:
            ebd_posts.append((post['title'], _post))
    else:
        ebd_posts.append((post['title'], post['content']))

第三步,打标

把文本转化为向量矩阵,这一步是整个过程最核心的,也是所有搜索、推荐、智能问答必不可少的。但是借助 OpenAI 的 Embeddings 接口,我们可以使用一行代码就能实现,并且成本非常低。

import pandas as pd
import openai

df = pd.DataFrame(ebd_posts, columns = ['title', 'content'])
df['tokens'] = df.content.apply(lambda x: len(tokenizer.encode(x)))

# 以下这行代码会多次请求 OpenAI 接口,会有较长的等待时间,如果数据量大可以自行做优化处理
df['embeddings'] = df.content.apply(lambda x: openai.Embedding.create(input=x, engine='text-embedding-ada-002')['data'][0]['embedding'])

df.to_csv('data/embeddings.csv')
df.head()

同时,我们将打标后的数据保存在本地,方便后续查询。因为我们这里只是一个 demo 演示,所以使用本地文件存储,如果内容量比较大的,建议使用向量数据库。

第四步,计算匹配内容

在这一步我们先将问题也向量化,然后与本地内容进行距离计算,获取到匹配度更高的内容。

import pandas as pd
import numpy as np
import openai
from openai.embeddings_utils import distances_from_embeddings

max_len = 4000 #4096 for gpt-3.5-turbo

df=pd.read_csv('data/embeddings.csv', index_col=0)
df['embeddings'] = df['embeddings'].apply(eval).apply(np.array)
df.head()

def match_text(question):
    context = []
    cur_len = 0
    
    # 问题打标
    q_embeddings = openai.Embedding.create(input=question, engine='text-embedding-ada-002')['data'][0]['embedding']
    
    # 使用 OpenAI 提供的工具函数做向量匹配,如果是存储的向量数据库,查询计算更方便
    df['distances'] = distances_from_embeddings(q_embeddings, df['embeddings'].values, distance_metric='cosine')
    for i, row in df.sort_values('distances', ascending=True).iterrows():
        cur_len += row['tokens']
        if cur_len > max_len:
            break
        context.append(row['content'])
    return context

补充一句,因为 OpenAI 的 Embedding 模型是基于普遍性数据训练,如果你的内容或问答过于专业,有可能就会出现查询数据不准确的情况,这种时候可以考虑自己训练 Embedding 模型,比如可参考 text2vec 这个项目。

第五步,优化回答内容

上面的步骤已经能根据问题获取到答案了,但是返回的内容是原始的文本片段,不够友好,我们可以通过将匹配的内容发送给 OpenAI 来润色。

import openai

def q(question=''):
    answers = match_text(question)
    context = '\n\n###\n\n'.join(answers)

    try:
        # prompt 可以自己调整优化,其他参数也可以根据需要修改
        completion = openai.ChatCompletion.create(
            model='gpt-3.5-turbo',
            messages=[
                {'role': 'system', 'content': f"你叫xxx,是一个热心和大家分享和交流的人,你擅长的领域有xxxx。"},
                {'role': 'user', 'content': f"根据context提供的信息回答我提出的问题。\n\nContext: {context}\n\n---\n\nQuestion: {question}\nAnswer:"}
            ]
        )
        return completion['choices'][0]['message']['content']
    except Exception as e:
        print(e)
        return ''

最后的思考

因为 GPT-3.5 的模型是一个文本模型,并没有逻辑和推理的能力,所以现有的实现本质还只是一个搜索匹配的工具,加上对话功能也只能说是多了一些总结和表达的能力,但效果还是比较惊艳的。如果想达到更好的效果,还可以自己做 Fine-tuning,使用更简单,只是价格会贵一些。

按 OpenAI 的介绍,GPT-4 有更强的推理能力。回到我们的标题,如果我们将乔布斯的传记、演讲等内容交给 OpenAI,通过不断的训练调优,一定是可以做到与乔布斯进行跨时空的对话,甚至可以用他的思考方式来解决新的问题,实现思想的延续。再加上多模态的能力,或者使用这个项目,还可以输出乔布斯的语音。

而要实现上面的构想,在工程层面,只需要写很少的代码。最近在基于 OpenAI 做开发的时候,在惊喜的同时,又有一些失落,发现从代码上来说,没什么实现难度,也会发现大部分的应用可复制成本非常低。

a world where all of us have access to help with almost any cognitive task.

生产力在进步,保持思考,持续行动吧。