RAG从入门到精通:LangChain+Chroma构建知识库问答系统,根治模型幻觉

一、幻觉之困与破局之道

大模型好用,但有个老毛病:爱编。你问它“公司去年的营收是多少”,它能一本正经地报出个数字,听起来还挺像那么回事,但你去翻财报就会发现,全是假的。让它总结一篇内部会议纪要,它也能凭空加出几条根本没讨论过的决议。

这种“看起来合理,实际上胡扯”的现象,就是模型幻觉。原因也不复杂:模型的知识是训练时学的,不仅有个截止日期,更不可能知道你们公司的私有文档和实时数据。

于是 RAG(检索增强生成)出来了,思路很直接:给模型挂个外置知识库。每次提问,先去库里搜出最相关的几段资料,把这些资料连同问题一起丢给模型,还要在提示词里强调——“只准看这些资料回答,不知道就说不知道”。这样一来,答案就不再依赖模型的记忆,而是落到真实的文档上,幻觉从根儿上就被掐断了。

我写这篇文章,就是想带着你用 LangChain 和 Chroma 从头搭一套能跑起来的知识库问答系统,把文档加载、分割、向量化、检索、生成这五个环节都过一遍,整个过程都有代码,拿来就能用。

二、知识地基:文档加载与语义切分

先装依赖,Python 3.9 以上,一行搞定:

pip install langchain langchain-openai langchain-community langchain-chroma chromadb openai tiktoken pypdf unstructured sentence-transformers

这里每个包都有用:langchain 是流程编排的核心,langchain-openailangchain-community 负责模型和加载器,langchain-chroma 专门对接向量库,chromadb 是底层数据库,pypdfunstructured 拿来读 PDF、TXT 等各种格式,sentence-transformers 则是为了后面本地运行嵌入模型准备的。当然,还得准备一个 OpenAI API Key,设成环境变量:

import os
os.environ["OPENAI_API_KEY"] = "你的api-key"

加载与切分文档

我们假设手头有一个 PDF 产品手册和一个 TXT 技术白皮书。先用加载器把它们读进来:

from langchain_community.document_loaders import PyPDFLoader, TextLoader

pdf_loader = PyPDFLoader("documents/产品手册.pdf")
txt_loader = TextLoader("documents/技术白皮书.txt", encoding="utf-8")
all_docs = pdf_loader.load() + txt_loader.load()
print(f"共加载 {len(all_docs)} 个页面")

文档读进来后,不能整篇整篇地往向量库里扔,那样检索精度会很差。我们需要把长文档切成小段,每一段控制在几百字,同时段与段之间留一点重叠,这样不会把一个完整的意思给拦腰切断。RecursiveCharacterTextSplitter 会按段落、句子、标点的优先级来切,尽量保持语义完整:

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)
splits = text_splitter.split_documents(all_docs)
print(f"分割后得到 {len(splits)} 个文本块")

chunk_size 我一般针对中文设 300 到 800,这里取 500 比较折中;chunk_overlap 设为 10%~20% 的块大小,能有效避免关键信息被切散。

三、向量之门:嵌入、存储与智能检索

文本切好之后,下一步就是向量化。说白了,就是把每个片段转化成一串数字,语义越接近的片段,它们在向量空间里的距离就越近。这里你可以选择用 OpenAI 的在线嵌入服务,效果不错,就是得联网;也可以选 HuggingFace 的本地模型,完全免费而且数据不出本机。

在线方案:

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

本地方案(我比较喜欢这个):

from langchain_community.embeddings import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(model_name="shibing624/text2vec-base-chinese")

有了嵌入模型,我们就可以把所有文本块转成向量,存进 Chroma 向量数据库,并且持久化到本地目录,下次直接加载,不用重复处理。

from langchain_chroma import Chroma

persist_directory = "chroma_db"
vectordb = Chroma.from_documents(
    documents=splits,
    embedding=embeddings,
    persist_directory=persist_directory
)
print(f"向量库已保存至 {persist_directory},共 {vectordb._collection.count()} 条")

以后启动的时候,这样加载就好:

vectordb = Chroma(persist_directory=persist_directory, embedding_function=embeddings)

配置检索器

向量库里有了数据,就要考虑怎么搜。Chroma 默认算余弦相似度,但我们可以在检索时加一点策略,比如用 MMR(最大边际相关性)算法,让搜出来的几个片段既相关,彼此之间又有一些差异性,避免返回一堆意思重复的内容。

retriever = vectordb.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "fetch_k": 10, "lambda_mult": 0.7}
)

这里 k=4 表示最终拿给模型参考的片段数,fetch_k=10 是先从库里拉出 10 条,再由 MMR 挑选出 4 条。lambda_mult 越接近 1,越看重相关性;越接近 0,越看重多样性。你可以根据实际效果调整。

四、思维约束:构建防幻觉的问答引擎

现在有了检索器,差一个生成答案的大模型,以及一个能死死管住模型的提示词。提示词在这里是“根治幻觉”的关键,我会在提示里明确告诉模型:只准用参考资料里的信息,不知道就直接说不知道,千万别自己发挥。

from langchain_openai import ChatOpenAI
from langchain.prompts import PromptTemplate
from langchain.chains import RetrievalQA

llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, max_tokens=1024)

template = """你是一个严谨的知识库助手,请严格根据以下规则回答问题:

1. 只能使用下面“参考资料”中的信息。
2. 若资料中没有相关信息,请直接说“根据现有资料无法回答”,不得编造或猜测。
3. 回答时尽量引用原文细节,保持条理清晰。

参考资料:
{context}

用户问题:{question}

最终回答:"""

QA_PROMPT = PromptTemplate(template=template, input_variables=["context", "question"])

qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    chain_type_kwargs={"prompt": QA_PROMPT},
    return_source_documents=True
)

我用的是 stuff 方式,也就是直接把所有搜出来的片段拼进提示词里。只要片段总长没超过模型的上下文窗口,这种方式最简单直接。return_source_documents=True 还会把原始片段返回出来,方便你检查答案的来源。

来试几个问题:

def ask(question):
    result = qa_chain.invoke({"query": question})
    answer = result["result"]
    sources = result["source_documents"]
    
    print(f"🤖 回答:{answer}\n")
    print("📚 参考来源:")
    for i, doc in enumerate(sources, 1):
        print(f"  [{i}] {doc.page_content[:120]}...")
    return answer

ask("公司的售后服务政策是什么?")
ask("产品Pro版支持多少并发用户?")
ask("今天天气怎么样?")   # 知识库里肯定没有,看它会不会乱编

对于前两个问题,模型会老老实实从参考资料里找答案,第三个问题它则会回复“根据现有资料无法回答”。这说明我们预设的规则生效了,没有发生幻觉,而且每个答案都能追溯到具体的文档片段。

五、演进之路:从可用到好用的系统淬炼

一套基础 RAG 系统跑通后,实际落地的时候我们还会在很多地方下功夫。

文档处理这边,用 UnstructuredFileLoader 可以一口气支持 PPT、Excel,甚至图片 OCR;切分的时候也不一定死守固定字数,试试 MarkdownHeaderTextSplitter 按标题层级来切,让每个文本块都自带章节信息,检索的时候会准很多。

检索质量这块,纯粹的向量检索有时会漏掉精确的关键词匹配,我习惯引入 BM25 这类稀疏检索算法,把稠密向量和稀疏关键词的搜索结果加权融合,效果往往更好。另外,用户问句通常比较口语化,可以在检索前先用一个 LLM 把问题改写得更贴近文档的行文风格,再拿改写后的句子去搜,命中率明显提升。还有一个小技巧,就是给文档块打上来源、日期之类的元数据,检索的时候加个过滤条件,直接缩窄范围。

生成与评估也不能忽视。高频相似问题可以缓存检索结果(比如用 Redis),既降低延迟又省钱;面向用户输出时打开流式输出,体验会丝滑很多。上线之后一定要做评估,用 RAGAS 这类框架自动检测答案的忠实度、相关性,把每次返回的文档和用户反馈记下来,慢慢形成优化闭环。如果对数据安全有要求,我会用 Ollama 部署本地模型(比如 Llama 3 或 Qwen),整个流程完全在本地跑,稳当又放心。

好了,整个搭建过程就是这样。我们一路从文档加载、切分、向量化、检索,到提示词约束和答案生成,走完了一个能实际运作的 RAG 系统全流程。下一步,你可以把它封装成 FastAPI 服务,接到飞书或者钉钉上,甚至配合 Agent 做一些自动化的复杂任务。RAG 不只是个技术组合,它是一套“让模型说真话”的思路,这个思路在你手里会越用越顺。完整代码我已经整理成可运行的脚本,需要的话可以在评论区告诉我,也欢迎关注我,我会持续分享大模型落地过程中的实战心得。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐