最近想从文档知识库里提取一些文本片段,送给大模型,整理出想要的内容,并减少幻觉,这就是常说的检索增强生成技术(RAG:Retrieval-Augmented Generation)。

一条可行的技术路线就是使用LangChain,带着这个想法开始学习LangChain,没想到LangChain官网的第一个实例就是语义搜索,链接在这里:https://docs.langchain.com/oss/python/langchain/knowledge-base

一步一步完成了这个例子,一个简陋的基于PDF的问答程序就出来了。当然,这里还没有做界面。

大模型API

个人进行大模型的本地部署有一定难度,还对显卡有要求,openai有免费的API接口,可惜国内的大多数用户访问不了,这里花11.66元买了aliyun的三个月的100万token试用,足够学习过程的各种折腾。

订单完成之后,在阿里云百炼平台的密钥管理里可以创建API Key,类似这样的“sk-2026***0204”一个字符串。

为了不在源代码里暴露这个secret key,我在windows里设置了系统环境变量 DASHSCOPE_API_KEY。

设置了环境变量之后,记得重启vscode开发环境。否则,你可能会看到下面的错误信息:

ValueError: Sync client is not available. This happens when an async callable was provided for the API key. Use async methods (ainvoke, astream) instead, or provide a string or sync callable for the API key.

大模型的Hello World

安装langchain

安装langchain的相关软件包,使用pip安装即可,最好一次性把相关内容先装上,后面早晚会用到。

pip install langchain langchain-openai langchain-core langchain-community langchain-chroma

使用pip show langchain可以查看langchain的版本,我使用的是V1.2.7。

openai的Hello World写法

阿里官网上提供的第一个程序:

import os
from openai import OpenAI

client = OpenAI(
    # 若没有配置环境变量,请用阿里云百炼API Key将下行替换为: api_key="sk-xxx",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

completion = client.chat.completions.create(
    model="qwen-plus",  # 模型列表: https://help.aliyun.com/model-studio/getting-started/models
    messages=[
        {'role': 'system', 'content': '你是AI大模型助手'},
        {'role': 'user', 'content': '你是谁?'}
    ]
)

print(completion.choices[0].message.content)

如果一切顺利,会得到类似下面的输出:

你好!我是通义千问(Qwen),阿里巴巴集团旗下的超大规模语言模型。我能够回答问题、创作文字,比如写故事、写公文、写邮件、写剧本、逻辑推理、编程等等,还能表达观点,玩游戏等。如果你有任何问题或需要帮助,欢迎随时告诉我!😊

上面的程序并没有使用langchain,只使用了openai的类库。

langchain的Hello World

如果只是简单的问答,使用langchain的最简练的代码是:

import os
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="qwen-plus",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

response = llm.invoke('你是谁?')
print(response.content)

带系统提示词的Hello World

可以加上系统提示词,代码里多了两个类HumanMessage, SystemMessage

import os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage

llm = ChatOpenAI(
    model="qwen-plus",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

messages = [
    SystemMessage(content="你是AI大模型助手"),
    HumanMessage(content="你是谁?")
]

response = llm.invoke(messages)
print(response.content)  

批量对话

可以一次问大模型多个问题。

import os
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="qwen-plus",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

results = llm.batch([
    "LangChain里最重要的几个概念是什么?",
    "给一份10倍速学会LangChain的大纲",
])

for idx, res in enumerate(results):
    print(f"\n批量调用第{idx+1}条:", res.content)
    print('=' * 50)

读入PDF

先要安装pypdf包。

pip install langchain-community pypdf

langchain里有一个Document类,实现了对文档的抽象,主要有三个属性:

  • page_content:文档内容,字符串;
  • metadata:文档元数据,字典。例如:title:文档标题,creationdate:建立时间。
  • id:(可选)文档的字符串标识符。

最近正在读Dan Koe的一篇文章《How to fix your entire life in 1 day》,保存成PDF,19页,正好试着用langchain分析一下。

from langchain_community.document_loaders import PyPDFLoader

file_path = "how-to-fix-your-entire-life.pdf"
loader = PyPDFLoader(file_path)
docs = loader.load()

print("PDF的页数:", len(docs))
print(f"{docs[0].page_content[:200]}\n")   #第一页里的前200个字符 
print(docs[0].metadata)   #第一页的元数据
print(docs[1].metadata)   #第二页的元数据

文本分隔

PDF文档的内容一般都比较长,直接送给大模型肯定不行,一个常用的办法就是把文本分块处理,一个块称为chunk。LangChain里已经提供了TextSplitter类,拆分文档非常方便。

from langchain_text_splitters import RecursiveCharacterTextSplitter

# 每个块(chunk)有1000个字符,其中200个字符重复
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=200, add_start_index=True
)
all_splits = text_splitter.split_documents(docs)

print("chunks: ", len(all_splits))
print(all_splits[0].metadata)  
print(all_splits[1].metadata)  
print(all_splits[-1].metadata)  

原始PDF有19页,每1000个字符一个块,处理后得到44个块。为了防止断章取义,使用了重叠字符chunk_overlap。

每个块里多了一个元数据start_index。例如,最后一个块的元数据:

{
    'producer': 'Skia/PDF m144', 
    'creator': 'Mozilla/5.0...', 
    'creationdate': '2026-02-02T01:35:09+00:00', 
    'moddate': '2026-02-04T09:20:45+08:00', 
    'title': 'How to fix your entire life in 1 day - by DAN KOE', 
    'source': 'how-to-fix-your-entire-life.pdf', 
    'total_pages': 19, 
    'page': 18, 
    'page_label': '19', 
    'start_index': 803
}

向量嵌入

学过深度学习、大模型的基础知识后,应该知道,任何一串文本,可以映射为一个多维向量(元素都是浮点数)。比如GPT-3是768维的向量,阿里的text-embedding-v3支持1024维向量,text-embedding-v4则可以用2048维向量来表达。

alt text

将手里的文档全部嵌入之后,后面就可以进行相似度的检索,你输入的任何一段文字,都可以与向量库里的内容进行匹配查询,挑几条最相关的内容,再送给大模型进行推理分析,从而得到更准确的结果。

向量嵌入这一节的内容踩了几个坑,Langchain官方教程里的下面两行无法通过:

from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

阿里百炼给的官方文本嵌入步骤是:

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# completion 指文本补全响应的结果
# text-embedding-v3 / v4 模型:
#       输入为字符串:最长支持 8,192 Token。
#       输入为字符串列表或文件:最多支持 10 条(行),每条(行)最长支持 8,192 Token。
# text-embedding-v1 / v2 模型:
#       输入为字符串:最长支持 2,048 Token。
#       输入为字符串列表或文件:最多支持 25 条(行),每条(行)最长支持 2,048 Token。
completion = client.embeddings.create(
    model="text-embedding-v4",
    input=[ # 可以是一个字符串,也支持多个字符串的数组。
        "每天吃一个鸡蛋可以补充优质蛋白质,但胆固醇偏高的人应注意摄入量。",
        "长期不吃早餐可能会影响血糖稳定,也容易导致午餐时暴饮暴食。",
        "多喝水有助于促进新陈代谢,但短时间内大量饮水也可能增加身体负担。",
        "适量运动有助于控制体重和增强心肺功能,但运动强度应循序渐进。",
    ],
    # 指定的向量维度,默认值为1024,必须为以下值之一:
    # 2048、1536(仅适用于text-embedding-v4)、
    # 1024、768、512、256、128 或 64。
    dimensions=1024, 
)

#print(completion.model_dump_json())

vec1 = completion.data[0].embedding
vec2 = completion.data[1].embedding
print(f"嵌入向量维度: {len(vec1)}")  # 应该是1024
print(f"向量样例: {vec1[:5]}")  # 前5个值
print(f"向量样例: {vec2[:5]}")  # 前5个值

注意这里使用了OpenAI,而不是ChatOpenAI,顺利的话,输出如下:

嵌入向量维度: 1024
向量样例: [-0.010881742462515831, -0.042937248945236206, -0.015206357464194298, -0.012966824695467949, 0.04257218539714813]
向量样例: [-0.019730322062969208, -0.059398338198661804, -0.012864702381193638, -0.0652344822883606, 0.0032735744025558233]

通过多次询问大模型,终于给出了一个正确的阿里百炼Embeddings适配器。可以正常调用embed_query函数。

import os
from openai import OpenAI
from langchain_core.embeddings import Embeddings

# 阿里百炼 Embeddings 适配器
class DashScopeEmbeddings(Embeddings):
    def __init__(
        self,
        api_key: str,
        model: str = "text-embedding-v4",
        dimensions: int = 1024,
    ):
        self.client = OpenAI(
            api_key=api_key,
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
        )
        self.model = model
        self.dimensions = dimensions

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        response = self.client.embeddings.create(
            model=self.model,
            input=texts,
            dimensions=self.dimensions,
        )
        return [item.embedding for item in response.data]

    def embed_query(self, text: str) -> list[float]:
        response = self.client.embeddings.create(
            model=self.model,
            input=[text],
            dimensions=self.dimensions,
        )
        return response.data[0].embedding


embeddings = DashScopeEmbeddings(
    api_key=os.getenv("DASHSCOPE_API_KEY"), 
    model="text-embedding-v4",
    dimensions=1024,
)

vec1 = embeddings.embed_query("每天吃一个鸡蛋可以补充优质蛋白质,但胆固醇偏高的人应注意摄入量。")
vec2 = embeddings.embed_query("长期不吃早餐可能会影响血糖稳定,也容易导致午餐时暴饮暴食。")
print(f"嵌入向量维度: {len(vec1)}")  # 应该是1024
print(f"向量样例: {vec1[:5]}")  # 前5个值
print(f"向量样例: {vec2[:5]}")  # 前5个值

向量存储

内存存储

大量文本被向量化之后,有多种存储方案。最简单的是内存存储,不过缺少持久化。还可以放在MongoDB和PostgreSQL数据库中。

适配的Embeddings可以使用InMemoryVectorStore内存存储。

from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore

docs = [Document(page_content=input,) 
            for input in [
                "每天吃一个鸡蛋可以补充优质蛋白质,但胆固醇偏高的人应注意摄入量。",
                "长期不吃早餐可能会影响血糖稳定,也容易导致午餐时暴饮暴食。",
                "多喝水有助于促进新陈代谢,但短时间内大量饮水也可能增加身体负担。",
                "适量运动有助于控制体重和增强心肺功能,但运动强度应循序渐进。",
            ]]
vector_store = InMemoryVectorStore(embeddings)
ids = vector_store.add_documents(documents=docs)
print(ids)

chroma持久存储

专门做向量数据库存储的方案当前有很多,有FAISS(Meta公司的),Milvus,还有一个轻量级、开箱即用的适合小型项目的Chroma。增加几条语句,就可以将这些向量保存到Chroma里,内部采用Sqlite进行管理,非常方便。

import os
from openai import OpenAI
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_chroma import Chroma

# 阿里百炼 Embeddings 适配器
class DashScopeEmbeddings(Embeddings):
    def __init__(
        self,
        api_key: str,
        model: str = "text-embedding-v4",
        dimensions: int = 1024,
    ):
        self.client = OpenAI(
            api_key=api_key,
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
        )
        self.model = model
        self.dimensions = dimensions

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        response = self.client.embeddings.create(
            model=self.model,
            input=texts,
            dimensions=self.dimensions,
        )
        return [item.embedding for item in response.data]

    def embed_query(self, text: str) -> list[float]:
        response = self.client.embeddings.create(
            model=self.model,
            input=[text],
            dimensions=self.dimensions,
        )
        return response.data[0].embedding

docs = [Document(page_content=input,) 
            for input in [
                "每天吃一个鸡蛋可以补充优质蛋白质,但胆固醇偏高的人应注意摄入量。",
                "长期不吃早餐可能会影响血糖稳定,也容易导致午餐时暴饮暴食。",
                "多喝水有助于促进新陈代谢,但短时间内大量饮水也可能增加身体负担。",
                "适量运动有助于控制体重和增强心肺功能,但运动强度应循序渐进。",
            ]]

def main():
    embeddings = DashScopeEmbeddings(
        api_key=os.getenv("DASHSCOPE_API_KEY"),
        model="text-embedding-v4",
        dimensions=1024,
    )

    vector_store = Chroma(
        collection_name="daily_health_knowledge",
        embedding_function=embeddings,
        persist_directory="./health_chroma_db",
    )

    if vector_store._collection.count() == 0:
        print("首次运行,写入中文健康常识向量库...\n")
        vector_store.add_documents(docs)
    else:
        print("向量库已存在,直接查询...\n")

    # 相似度查询示例
    queries = [
        "长期不吃早餐可能会影响?",
        "不吃早餐有什么影响?",
        "鸡蛋每天能不能多吃?",
        "运动对身体有什么好处?",
        "南京的天气怎么样?",  # 明显无关查询
    ]

    # < 0.3	高度相关
    # 0.3 ~ 0.6	可能相关
    # > 0.6	基本不相关(应视为“没命中”)
    for query in queries:
        print(f"Query:{query}")
        results = vector_store.similarity_search_with_score(query, k=1)
        for doc, score in results:
            print(f'{score:.2f}, {doc.page_content}')
            print("-" * 50)


if __name__ == "__main__":
    main()

上面的程序会建立一个文件夹 health_chroma_db/,里面有sqlite3数据库,还有一些不知道用处的文件。我的查询结果:

Query:长期不吃早餐可能会影响?
0.43, 长期不吃早餐可能会影响血糖稳定,也容易导致午餐时暴饮暴食。
--------------------------------------------------
Query:不吃早餐有什么影响?
0.51, 长期不吃早餐可能会影响血糖稳定,也容易导致午餐时暴饮暴食。
--------------------------------------------------
Query:鸡蛋每天能不能多吃?
0.46, 每天吃一个鸡蛋可以补充优质蛋白质,但胆固醇偏高的人应注意摄入量。
--------------------------------------------------
Query:运动对身体有什么好处?
0.63, 适量运动有助于控制体重和增强心肺功能,但运动强度应循序渐进。
--------------------------------------------------
Query:南京的天气怎么样?
1.39, 多喝水有助于促进新陈代谢,但短时间内大量饮水也可能增加身体负担。

可以看出,比较相近的查询,返回的分数值应该小于0.7,而明显不相关的查询,会返回一个大小1.0的值,比如上面在一堆健康知识里询问南京的天气,分值是1.39。

基于PDF的问答程序

现在,可以把前面的逻辑拼在一起,完成一个基于PDF的问答程序,大概步骤:

1)读入PDF

2)分块。这里每块使用5000个字符,提高问题的命中率。

3)嵌入chroma向量库。这里发现一个问题,百炼一次只能嵌入10行文本,这里的PDF有19页,要分批处理。

4)将3条相似度最高的回答拼在一起,送给LLM大模型,加一点简单的提示词,就可以得到一个相对不错的回答。

import os
from openai import OpenAI
from langchain_openai import ChatOpenAI
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_chroma import Chroma
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 阿里百炼 Embeddings 适配器
class DashScopeEmbeddings(Embeddings):
    def __init__(
        self,
        api_key: str,
        model: str = "text-embedding-v4",
        dimensions: int = 1024,
    ):
        self.client = OpenAI(
            api_key=api_key,
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
        )
        self.model = model
        self.dimensions = dimensions

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        response = self.client.embeddings.create(
            model=self.model,
            input=texts,
            dimensions=self.dimensions,
        )
        return [item.embedding for item in response.data]

    def embed_query(self, text: str) -> list[float]:
        response = self.client.embeddings.create(
            model=self.model,
            input=[text],
            dimensions=self.dimensions,
        )
        return response.data[0].embedding



# 读入PDF
file_path = "how-to-fix-your-entire-life.pdf"
loader = PyPDFLoader(file_path)
docs = loader.load()
print("PDF的页数:", len(docs))

# 文本分块
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=5000, chunk_overlap=500, add_start_index=True
)
all_splits = text_splitter.split_documents(docs)
print("chunks: ", len(all_splits))

# 嵌入向量库
embeddings = DashScopeEmbeddings(
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    model="text-embedding-v4",
    dimensions=1024,
)

vector_store = Chroma(
    collection_name="how_fix_life",
    embedding_function=embeddings,
    persist_directory="./how_fix_life_chroma_db",
)

if vector_store._collection.count() == 0:
    print("首次运行,写入Chroma向量库...\n")
    batch_size = 10  #阿里云百炼的文本嵌入,一次最多10行
    for i in range(0, len(all_splits), batch_size):
        batch = all_splits[i:i+batch_size]
        vector_store.add_documents(batch)
else:
    print("向量库已存在,直接查询...\n")

# 查询
llm = ChatOpenAI(
    model="qwen-plus",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

queries = [
    "human 3.0 model",
    "anti-vision",
    "take your life as video game",
    "如何彻底改变你的人生",  #原文是英文,这里直接使用中文
]

for query in queries:
    print(f"Query:{query}")
    results = vector_store.similarity_search_with_score(query, k=3) #取三近邻
    for doc, score in results:
        print('-'*10, f'{score:.2f}', '-'*10)
        print(f'{doc.page_content[:100]} ... ')

    # 把最相似的三个片段拼在一起,送给大模型
    combined_text = " ".join([doc.page_content for doc, score in results])
    print(f"问题查询:{query}")
    response = llm.invoke(f'请使用中文回答这个问题:{query},控制在500字左右,要严格基于下面文字素材: {combined_text}')
    print(response.content)
    print("=" * 50)

学习Langchain这几天,用了不到10万token。 alt text