上周我把一本三国历史小说扔进了一个 RAG 系统,发现它居然能直接回答"诸葛亮第几次北伐在哪年"这类问题——这篇文章记录我是怎么做到的。
一、RAG是什么
大模型的知识是有截止日期的,训练完成后就固定了,无法访问私有数据。RAG 就是为了解决这个问题:把私有文档喂给模型,让它结合这些内容生成回答。
提供额外知识的过程叫检索增强(RA),模型生成回答的过程是 G,合起来就是检索增强生成——RAG(Retrieval Augmented Generation)。
二、技术选型和准备工作
工具列表:
| 工具 | 用途 |
|---|---|
| LangChain | 大模型应用开发框架 |
| ChromaDB | 向量数据库 |
| BAAI/bge-m3 | Embedding 模型 |
| DeepSeek | 大语言模型 |
| 硅基流动API | 模型 API 平台 |
| Streamlit | 界面设计 |
为何选用这几个工具:
ChromaDB 部署简单、纯 Python、本地运行无需额外服务,适合快速验证;
BAAI/bge-m3 是目前中文效果最好的开源 Embedding 模型之一,通过硅基流动调用有免费额度;
硅基流动兼容 OpenAI 接口格式,切换成本低;
Streamlit 不需要前端基础,几十行代码就能做出可用的界面。
三、核心代码实现
整体流程:
-
加载文件-- 上传并读取文件内容
-
文档切片-- 将文档切分成小片段,片段大小可以指定
-
向量化-- 将文档内容转换为多维向量数据
-
存数据库-- 将向量化的数据存如数据库
-
检索相关片段-- 用问题从向量数据库中查找Top-k个文档片段
-
生成回答-- 问题+片段送给大模型生成回答
- Langchain核心对象
对象 作用 Document 文档片段,包含page_content、metadata等 Splitter 将长文档切分成小片段 Embeddings 把文字变成向量(封装了 API 调用) VectorStore 向量数据库封装(ChromaDB 等) Retriever 检索器,从 VectorStore 里检索相关文档 PromtTemplate 格式化创建提示词 LLM / ChatModel 调用大模型 API - 安装依赖
pip install langchain langchain-community langchain-openai chromadb
- 1.文件解析、切片、向量化、存库
def parse_uploaded_file(uploaded_file):
"""解析上传的文件,返回 Document 对象,失败返回 None。"""
filename = uploaded_file.name
data = uploaded_file.read()
if filename.endswith('.txt'):
try: text = data.decode('utf-8')
except: text = data.decode('gbk', errors='ignore')
elif filename.endswith('.pdf'):
import pypdf
reader = pypdf.PdfReader(io.BytesIO(data))
text = '\n\n'.join([p.extract_text() or '' for p in reader.pages])
else:
return None
text = text.strip()
if not text:
return None
return Document(page_content=text, metadata={'source': filename})
def build_from_documents(docs_list):
"""
从 Document 列表构建内存向量库。
"""
# 文档切片
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=50, length_function=len
)
chunks = splitter.split_documents(docs_list)
emb, _ = get_models() # 向量化工具
vs = Chroma.from_documents(
documents=chunks,
embedding=emb,
persist_directory=None # 内存模式
) # 存入数据库
return vs, chunks, len(chunks)
-
- 检索、生成
RAG_PROMPT = """你是文档问答助手。请严格基于以下文档内容回答问题。
如文档中没有相关信息,请说"文档中未提及此内容",不要编造。
文档内容:
{context}
对话历史:
{history}
当前问题:{question}
回答:"""
def rag_answer(question, vectorstore, all_chunks, chat_history, k=3):
# 向量检索
relevant_docs = vectorstore.as_retriever(
search_kwargs={"k": k}
).invoke(question)
context = "\n\n---\n\n".join([
f"[{d.metadata.get('source','?')}]\n{d.page_content}"
for d in relevant_docs
])
recent = chat_history[-6:]
history_text = "\n".join([
f"{m['role'].upper()}: {m['content']}" for m in recent
]) if recent else "(无历史)"
prompt = ChatPromptTemplate.from_template(RAG_PROMPT)
_, llm = get_models()
response = llm.invoke(prompt.format_messages(
context=context, history=history_text, question=question
))
return response.content, relevant_docs
-
- 界面设计
界面使用streamlit实现,安装依赖
pip install streamlit # 验证安装成功 streamlit hello主页面UI:
st.title("历史知识助手") if st.session_state.vs and st.session_state.doc_count > 0: st.caption(f"知识库已创建,文档{st.session_state.doc_count}个,切片{st.session_state.chunk_count}个") else: st.caption("👈🏻知识库未创建,请点左侧按钮创建/重建") # 对话历史 for item in st.session_state.chat_history: with st.chat_message(item["role"]): st.write(item["content"]) # 显示来源,可折叠 if item["role"] == "assistant" and item.get("sources"): sources = item.get("sources") with st.expander("📚 参考来源"): for i, source in enumerate(sources): src = source.metadata.get("source", "未知") st.write(f"**{i+1}. {src}**") st.caption(f"{source.page_content[:30]}...") st.markdown("---") # 输入框 if question := st.chat_input("请根据知识库提问:"): if not st.session_state.vs: st.error("知识库未创建,请点左侧按钮创建/重建") st.stop() # 展示用户消息 st.session_state.chat_history.append({"role": "user", "content": question}) with st.chat_message("user"): st.write(question) # 展示助手消息 with st.chat_message("assistant"): with st.spinner("正在检索文献并生成回答..."): answer, sources = rag_answer( question, st.session_state.vs, [m for m in st.session_state.chat_history if m['role'] != 'system'], k=k_value ) st.write(answer) with st.expander("📚 参考来源"): for i, source in enumerate(sources): src = source.metadata.get("source", "未知") st.write(f"**{i+1}. {src}**") st.caption(f"{source.page_content[:30]}...") st.markdown("---") st.session_state.chat_history.append( {"role": "assistant", "content": answer, "sources": sources} )
四、界面展示
五、踩坑记录、未来改进
坑一:看不懂链式调用
第一次看到这段代码完全懵了:
prompt = ChatPromptTemplate.from_template(RAG_PROMPT)
chain = prompt | llm | StrOutputParser()
answer = chain.invoke({
"context": context,
"history": history_text,
"question": question
})
| 在这里不是"或",是 LangChain 定义的连接符,把左边的输出传给右边作为输入。拆开来写就清楚了:
formatted_prompt = prompt.invoke({
"context": context,
"history": history_text,
"question": question
})
llm_response = llm.invoke(formatted_prompt)
answer = StrOutputParser().invoke(llm_response)
链式写法的好处是替换某个步骤时只改一行,比如换个模型或换个输出解析器,其他代码完全不动。
坑二:重建向量库报 readonly 错误
用持久化模式(persist_directory 指定路径)时,第二次点"重建知识库"按钮就报错:
chromadb.errors.InternalError: Query error: Database error: error returned from database: (code: 1032) attempt to write a readonly database
我先后试了三种方案都失败了:
shutil.rmtree删目录——失败,删完重建还是报错gc.collect()强制回收旧对象——失败,对象还在 session_state 里被引用着- 把新库建到临时目录再
shutil.move——失败,move 操作本身也触发了同样的错误
最后搞明白了根本原因:错误码 1032 是 SQLITE_READONLY_DBMOVED,SQLite 通过 inode 追踪文件,只要对 chroma.sqlite3 执行了删除或移动,inode 就变了,旧连接检测到就拒绝写入。
正确解法是根本不碰文件系统,用 ChromaDB 自己的 API:
existing_vs.delete_collection() # 在 SQLite 内部删数据,文件 inode 不变
坑三:向量检索"认错人"
上传了三篇文档——曹操介绍、诸葛亮介绍、赤壁之战介绍。问曹操能答,问诸葛亮却回答"文档中未提及"。
打印检索日志后发现:问"诸葛亮是哪里人"时,检索到的 3 个片段全部来自 caocao.txt。
原因是这三篇文档内容高度相关,人名互相穿插——诸葛亮文章里多次提到曹操,曹操文章里也提到了赤壁和诸葛亮。向量语义检索计算的是整体语义相似度,在这种互相交织的文档里容易"认错人"。
这个问题单靠调参解决不了,需要从检索策略上改进:
六、下一步改进方向
- 混合检索:向量语义检索 + BM25 关键词检索,两路结果用 RRF 算法融合。BM25 基于词频,"诸葛亮"这个词在哪篇文档出现多,得分就高,不受语义干扰
- Reranking 重排序:检索召回 20 个片段后,用专门的重排序模型精选最相关的 3 个,比直接截断效果更好
- Query 改写:用户提问"说说你知道的诸葛亮"这类口语化问题,直接用来检索效果差,先用大模型改写成"诸葛亮 生平 历史成就"再检索