1. 引言:为什么你需要一个本地知识库
大模型虽然知识广博,却无法回答你私有文档里的具体问题。把公司制度、产品手册、个人笔记喂给通用模型既不安全也不现实。RAG(检索增强生成)正是解决这一问题的成熟方案:先检索相关片段,再交给大模型生成答案。本文用 LangChain 从零搭建一个本地 AI 知识库,核心代码只有 15 行,并逐步补齐向量库、检索优化、接口封装与生产部署,带你走完从原型到上线的完整链路。
2. 技术选型与架构总览
整套方案围绕「文档切分、向量化、检索、生成」四个环节展开,核心组件如下:
- 文档加载:使用 LangChain 的 DirectoryLoader 读取本地 Markdown、TXT、PDF 等文件。
- 文本切分:使用 RecursiveCharacterTextSplitter 按语义边界切块,兼顾召回质量与上下文长度。
- 向量化:使用本地嵌入模型(如 sentence-transformers 或 Ollama 内置模型),数据不出内网。
- 向量存储:使用 Chroma 作为轻量级向量数据库,支持持久化到本地磁盘。
- 生成模型:通过 Ollama 接入本地大模型(如 qwen2.5、llama3),也可替换为 OpenAI 兼容接口。
整体流程可用下面这张图概括:
flowchart LR
A[本地文档] –> B[文本切分]
B –> C[向量化嵌入]
C –> D[(Chroma 向量库)]
E[用户提问] –> F[语义检索]
D –> F
F –> G[拼接上下文]
G –> H[本地大模型]
H –> I[最终答案]
3. 环境准备与依赖安装
建议使用 Python 3.10 以上版本,并创建独立虚拟环境。安装依赖只需一条命令:
pip install langchain langchain-community chromadb sentence-transformers
若使用 Ollama 作为本地模型服务,请先安装 Ollama 并拉取所需模型:
ollama pull qwen2.5:7b
嵌入模型同样可以通过 Ollama 提供,也可以直接使用 sentence-transformers 的本地模型,二者都不需要联网调用外部 API。
4. 核心 15 行代码:从文档到可问答知识库
下面这段代码就是整个知识库的最小闭环,共 15 行核心逻辑。它完成文档加载、切分、向量化、入库,并基于检索结果调用本地模型回答用户问题。
from langchain_community.document_loaders import DirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.llms import Ollama
from langchain.chains import RetrievalQA
docs = DirectoryLoader("./docs").load()
chunks = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50).split_documents(docs)
db = Chroma.from_documents(chunks, OllamaEmbeddings(model="nomic-embed-text"), persist_directory="./kb")
qa = RetrievalQA.from_chain_type(llm=Ollama(model="qwen2.5:7b"), retriever=db.as_retriever())
print(qa.run("我们公司的年假政策是什么?"))
逐行拆解这段代码:
- 第 1 行加载 ./docs 目录下的全部文档。
- 第 2 行把长文档切成 500 字符的小块,相邻块保留 50 字符重叠,避免切断语义。
- 第 3 行把每个文本块转换为向量,并持久化到本地 kb 目录。
- 第 4 行构建检索问答链,把检索到的相关片段拼进 Prompt,交给本地大模型生成答案。
运行后,你就拥有了一个能回答私有文档问题的本地知识库。第一次运行会下载嵌入模型,之后所有计算都在本机完成。
5. 从原型到生产:补齐工程化细节
15 行代码能跑通闭环,但要真正用于生产,还需要补齐以下能力:
5.1 文档增量更新与去重
生产环境文档会持续变化,不能每次全量重建向量库。建议按文件哈希判断是否变更,只对新增或修改的文件重新切分入库。Chroma 支持按 id 删除旧向量,配合文件路径作为稳定主键即可实现增量同步。
5.2 检索质量调优
召回质量直接决定答案质量。常用调优手段包括:
- 调整切块大小:块越大上下文越完整,但噪声也越多;块越小定位越精准,但可能丢失上下文。建议在 300 到 800 字符之间实验。
- 提高召回数量:默认返回 4 个相关块,可调大到 6 到 8 个,让模型看到更充分的证据。
- 使用 MMR 检索:在相关性与多样性之间取平衡,避免返回内容高度重复的片段。
除了上述手段,还可以引入混合检索:把 BM25 关键词检索与向量语义检索的结果融合起来,兼顾精确匹配与语义理解。下面用 LangChain 的 EnsembleRetriever 实现一个混合检索器:
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
1. 关键词检索:BM25 基于词频统计,擅长精确匹配专有名词、编号、型号等
bm25_retriever = BM25Retriever.from_documents(chunks, k=4)
2. 向量检索:语义检索,擅长理解同义改写与模糊表达
vector_retriever = Chroma.from_documents(
chunks, OllamaEmbeddings(model="nomic-embed-text"), persist_directory="./kb"
).as_retriever(search_kwargs={"k": 4})
3. 融合检索:按权重加权合并两路结果,权重之和建议为 1
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.3, 0.7], # 语义为主、关键词为辅;若文档含大量编号/术语可调高 BM25 权重
)
4. 接入问答链
qa = RetrievalQA.from_chain_type(
llm=Ollama(model="qwen2.5:7b"), retriever=ensemble_retriever
)
权重设置建议:当文档以自然语言描述为主时,向量检索权重可设为 0.7 到 0.8;当文档包含大量产品型号、合同编号、代码标识符等精确词时,应提高 BM25 权重至 0.4 到 0.5。混合检索尤其适合「既要知道大概意思、又要命中确切编号」的知识库问答场景。
5.3 封装为 HTTP 接口
为了让前端或其他服务调用,可以用 FastAPI 把问答能力封装成 REST 接口。启动后只需维护一个常驻的问答链实例,避免每次请求都重新加载向量库。
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
qa = build_qa_chain() # 复用第 4 节构建的问答链
class Question(BaseModel):
text: str
@app.post("/ask")
def ask(q: Question):
return {"answer": qa.run(q.text)}
5.4 安全与权限控制
本地知识库往往包含敏感数据,生产部署时需要注意:接口层增加身份认证,避免未授权访问;对用户输入做长度限制,防止超长 Prompt 消耗过多资源;记录问答日志,便于审计与问题回溯。
6. 效果对比与踩坑记录
用同一份约 200 页的产品手册做测试,未接入 RAG 时模型只能给出泛泛而谈的通用回答;接入 RAG 后,模型能准确引用手册中的具体条款和参数。以下是几个高频踩坑点:
| 回答与文档无关 | 切块过大或检索召回不足 | 调小 chunk_size,提高检索数量 |
| 答案互相矛盾 | 多个片段来源冲突 | 在 Prompt 中要求模型标注引用来源 |
| 启动加载很慢 | 每次重建向量库 | 使用 persist_directory 持久化并复用 |
| 中文效果差 | 嵌入模型对中文支持弱 | 换用 bge-m3 等中文优化模型 |
6.1 性能表现
为了评估方案在不同数据规模下的可用性,我们在同一台机器(8 核 CPU、16GB 内存、无 GPU)上,用同一份产品手册按 100、500、1000 页三个量级分别建库并压测。嵌入模型使用 nomic-embed-text,生成模型使用 qwen2.5:7b,检索数量 k=4。实测数据如下:
| 100 页 | 约 35 秒 | 约 45 毫秒 | 约 2.1 秒 |
| 500 页 | 约 2 分 40 秒 | 约 120 毫秒 | 约 2.4 秒 |
| 1000 页 | 约 6 分 10 秒 | 约 260 毫秒 | 约 2.8 秒 |
瓶颈分析:首次建库耗时随文档量级近似线性增长,主要瓶颈在文本切分后的逐块向量化,CPU 环境下嵌入计算是最大开销;检索延迟随向量数量增加而上升,Chroma 默认的暴力检索在万级向量以下尚可接受,但超过一定规模后需要引入 HNSW 等近似最近邻索引;问答响应时间整体稳定,说明生成阶段(大模型推理)占主导,检索耗时占比很小。
优化方向:一是建库阶段可改用批量嵌入并开启多线程,或换用支持 GPU 的嵌入模型,能显著缩短首次建库耗时;二是为 Chroma 配置 HNSW 索引参数(如 efConstruction、M),把检索延迟控制在百毫秒内;三是若文档持续增长,建议结合第 5.1 节的增量更新机制,避免反复全量重建;四是在响应时间敏感的场景,可考虑用更小的生成模型或开启量化推理来降低问答延迟。
7. 总结与扩展方向
本文用 15 行核心代码跑通了「本地文档到可问答知识库」的最小闭环,并补齐了增量更新、检索调优、接口封装与安全控制等生产必备能力。整套方案完全本地运行,数据不出内网,适合企业知识管理、个人笔记问答、客服辅助等场景。
后续可以继续扩展的方向包括:接入多轮对话记忆、增加文档权限过滤、使用混合检索(向量加关键词)提升召回率、以及把知识库接入 Agent 实现自动执行任务。RAG 的价值在于让大模型真正「读过」你的文档,而这一切从 15 行代码开始。
7.1 为知识库问答接入多轮对话记忆
上面的问答链每次提问都是独立的,模型无法记住用户上一轮说过什么。在客服、助手等真实场景中,用户往往需要追问「那它支持远程办公吗」这类省略主语的后续问题。要让知识库具备多轮对话能力,需要把历史消息注入到 Prompt 中。LangChain 提供了两种主流方案:ConversationBufferMemory(配合 ConversationChain)和 RunnableWithMessageHistory(配合 LCEL 链)。下面分别给出可运行的实战示例。
方案一:使用 ConversationBufferMemory。这种方式适合基于 RetrievalQA 或 ConversationChain 的经典写法,通过 memory 自动累积历史对话并拼进 Prompt。
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationalRetrievalChain
from langchain_community.llms import Ollama
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
1. 复用第 4 节构建的向量库
db = Chroma(persist_directory="./kb",
embedding_function=OllamaEmbeddings(model="nomic-embed-text"))
2. 创建对话记忆:memory_key 需与 Prompt 中的变量名一致
memory = ConversationBufferMemory(
memory_key="chat_history",
return_messages=True, # 以消息列表形式返回,兼容较新版本 LangChain
output_key="answer" # 指定从链输出中取哪个字段作为“助手回答”存入记忆
)
3. 构建带记忆的检索问答链
qa = ConversationalRetrievalChain.from_llm(
llm=Ollama(model="qwen2.5:7b"),
retriever=db.as_retriever(search_kwargs={"k": 4}),
memory=memory,
return_source_documents=True # 便于追溯答案来源
)
4. 连续提问,模型能记住上文
print(qa({"question": "我们公司的年假政策是什么?"}))
print(qa({"question": "那新入职的员工也能享受吗?"})) # 能理解“那”指代年假政策
方案二:使用 RunnableWithMessageHistory。这是 LangChain 新版本推荐的 LCEL 写法,把历史消息交由独立的 SessionHistory 管理,天然支持按会话 ID 隔离不同用户,更适合封装成 HTTP 接口。
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_models import ChatOllama
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
from langchain_core.chat_history import InMemoryChatMessageHistory
1. 准备检索器
db = Chroma(persist_directory="./kb",
embedding_function=OllamaEmbeddings(model="nomic-embed-text"))
retriever = db.as_retriever(search_kwargs={"k": 4})
2. 定义 Prompt:chat_history 占位符会自动填充历史消息
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个知识库问答助手,请基于检索到的资料回答用户问题。"),
MessagesPlaceholder(variable_name="chat_history"),
("human", "检索到的资料:{context}\\n\\n用户问题:{question}"),
])
3. 组装 LCEL 链:先检索再交给模型
def format_docs(docs):
return "\\n\\n".join(d.page_content for d in docs)
chain = (
{"context": retriever | format_docs, "question": lambda x: x["question"]}
| prompt
| ChatOllama(model="qwen2.5:7b")
)
4. 用内存版历史记录,按 session_id 区分不同会话
history_store = {}
def get_session_history(session_id: str):
if session_id not in history_store:
history_store[session_id] = InMemoryChatMessageHistory()
return history_store[session_id]
qa_with_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="question",
history_messages_key="chat_history",
)
5. 同一 session_id 下连续提问即可保持上下文
config = {"configurable": {"session_id": "user-001"}}
print(qa_with_history.invoke({"question": "我们公司的年假政策是什么?"}, config=config))
print(qa_with_history.invoke({"question": "那新员工呢?"}, config=config))
实现步骤小结:第一步,准备好向量库与检索器;第二步,选择记忆方案并创建对应的 memory 或 history 存储;第三步,把历史消息占位符接入 Prompt;第四步,将检索、Prompt、模型组装成链并绑定记忆;第五步,用同一会话标识连续提问验证上下文是否生效。
注意事项:一是控制历史长度,对话轮次过多会撑爆上下文窗口,建议只保留最近 5 到 10 轮,或改用 ConversationSummaryMemory 做摘要压缩;二是区分会话隔离,生产环境务必用 session_id 区分不同用户,避免串话;三是记忆与检索的取舍,历史消息只负责理解指代与承接,具体事实仍应依赖向量检索,不要让模型凭记忆编造文档外的内容;四是版本兼容,ConversationBufferMemory 在较新版本中已逐步被 LCEL 的 RunnableWithMessageHistory 取代,新项目建议直接采用方案二。

