4、文档检索bug修复
可以看到,llm根据知识库文档检索出了内容并给出了答案,但是会发现,为什么检索的四篇相关的文档都是同一片文档呢?
根本原因就在于:根本原因在于:
我们的文档在分块(text splitting)时,被切成了多个重叠或高度相似的 chunk,而这些 chunk 都被向量化并存入了向量数据库。检索时,它们都与查询高度相似,因此都被返回了。
我们的java-thread-pool.md 文件开头是:
# Java 线程池最佳实践
## 场景与目标
- 统一管理线程...
- 控制并发度...
## 核心参数与含义
- 核心线程数 corePoolSize...
这种标题 + 列表的结构,在分块时容易导致多个 chunk 都包含相同的开头部分(尤其是当 chunk_size 不够大时)。
而我们的分块策略是**CharacterTextSplitter 的分块策略**
text_splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=500,
chunk_overlap=80
)
separator="\n\n":按空行切分- 但如果文档中段落很长,或者开头没有空行,它会 fallback 到按字符切(每 500 字切一次)
chunk_overlap=80:前后块重叠 80 字符 → 进一步增加重复- 结果:文件开头的大段内容被切成多个“几乎一样”的 chunk。
Chroma 默认返回 top-k 个最相似结果
我们设置了 search_kwargs={"k": 4},所以即使 4 个 chunk 内容高度重复,只要它们的 embedding 相似度都排前 4,就会全部返回。
那我们怎么去解决这个问题呢,最简单的是使用专业的MarkdownTextSplitter,这是langchain中专门分割md文档的分割器,还有就是调整我们的分割策略:
text_splitter = CharacterTextSplitter(
separator="\n## ", # 在二级标题处分割
chunk_size=800, # 增大块大小,减少碎片
chunk_overlap=100,
length_function=len,
is_separator_regex=False
)
我们这里呢就使用MarkdownTextSplitter,修改对应的文档分割器代码
from langchain.text_splitter import MarkdownTextSplitter
# 如果主要是 .md 文件
text_splitter = MarkdownTextSplitter(
chunk_size=500,
chunk_overlap=50
)
然后在运行代码:
0it [00:00, ?it/s]
100%|██████████| 8/8 [00:00<00:00, 5464.00it/s]
0it [00:00, ?it/s]
/Users/fenghuanwang/PythonProject/langchain-test/agent/ai_client.py:75: LangChainDeprecationWarning: The class `Chroma` was deprecated in LangChain 0.2.9 and will be removed in 1.0. An updated version of the class exists in the :class:`~langchain-chroma package and should be used instead. To use it run `pip install -U :class:`~langchain-chroma` and import as `from :class:`~langchain_chroma import Chroma``.
vectorstore = Chroma(
新增 23 个文档片段
RAG 链初始化完成
✅ 向量数据库检索到 4 篇相关文档:
📄 [1] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-thread-pool.md | 预览: "# Java 线程池最佳实践 ## 场景与目标 - 统一管理线程,避免频繁创建/销毁的开销 - 控制并发度与任务排队策略,匹配业务峰谷 ## 核心参数与含义 - 核心线程数 corePoolSize:常驻线程数量 - 最大线程数 maximumPoolSize:峰值并发上限 - 存活时间 kee..."
📄 [2] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-thread-pool.md | 预览: "``` ## 监控与降级 - 指标:活跃线程、队列长度、任务耗时分位、拒绝次数 - 降级:限流/丢弃非关键任务/降级功能开关 ## 常见坑 - 使用 Executors 工具类默认配置可能导致 OOM(无界队列/最大线程数不受控) - 线程泄漏:任务异常未捕获,线程死锁 - 任务不可中断:IO..."
📄 [3] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-thread-pool.md | 预览: "## 典型构造 ```java ExecutorService pool = new ThreadPoolExecutor( corePoolSize, maximumPoolSize, 60L, TimeUnit.SECONDS, new LinkedBlockin..."
📄 [4] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-concurrency.md | 预览: "## 场景示例 - 高并发计数:LongAdder - 高读低写缓存:ReadWriteLock 或 Caffeine - 任务编排:CompletableFuture 并行拉取 + 超时兜底..."
自定义线程池需设置核心线程数、最大线程数、空闲存活时间、任务队列(建议有界)、线程工厂(命名规范)和拒绝策略。根据业务类型选择参数:CPU密集型用较小核心线程数,IO密集型适当增加。避免使用Executors默认无界队列,防止OOM。建议监控队列长度和拒绝次数,动态调整。
Process finished with exit code 0
这时候四篇文章就不会是相同的了,
ps:如果没有改变的话,是因为存在之前运行代码所缓存的向量数据,把存储向量数据的文件夹内的所有文件都删了就好了。
但是,这还不够,我们会发现,如果我们在问完一个问题之后,在想接着询问的时候llm不知道我们再说什么,也就是llm没有把之前的对话作为上下文。专业的说就是,现在我们所开发的llm还没有对话记忆的功能,那么下面,我们就来实现一下。
5、对话记忆实现(基于内存)
要想实现对话记忆,又很多方式,包括基于内存的对话存储,基于redis的,mysql的,sqllite的以及向量存储等诸多方式。那么我们先来尝试实现一下基于内存的记忆:
首先呢,在langchain的langchain.memory包下面的ChatMessageHistory,提供了一些方便的函数,可以保存人类的消息llm的消息。但是呢,这个只是底层的存储结构,只管存消息。我们真正使用的时候,使用的是ConversationBufferMemory这个是一个高层的记忆组建,内部默认使用的ChatMessageHistory来存储消息,并提供了与langchain链集成的接口(但是他还是不能嵌入到新式的Runnable中,也就是使用类似于Unix管道符的方式一层一层的向下传递处理好的信息),由于我们现在所使用的是新式的Runnable,所以我们不使用这个方式。我们来使用**RunnableWithMessageHistory**。
它是 LangChain 为 新时代 Runnable 专门设计的记忆包装器,底层可以接:
ChatMessageHistory(内存)RedisChatMessageHistory(Redis)- 或任何
BaseChatMessageHistory子类
它不依赖 ConversationBufferMemory,而是直接操作 ChatMessageHistory,更轻量、更符合 Runnable 范式。
我们导入所需要的包:
from langchain.memory import ChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.messages import HumanMessage
然后初始化一个会话历史存储器,来实现单用户的多轮对话
# 内存中的会话存储:session_id -> ChatMessageHistory
# 目前我们只用一个固定 session_id,实现单用户多轮对话
store = {}
def get_session_history(session_id: str) -> ChatMessageHistory:
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
说明:
store是一个字典,模拟多用户(虽然我们现在只用一个);get_session_history("user1")会返回该用户的对话历史对象;- 所有消息(Human/AI)都存在
ChatMessageHistory.messages列表中。
这种写法是
然后修改原来的系统提示词
system_prompt = (
"你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
"请基于以下检索到的上下文和之前的对话历史回答用户的问题。\n"
"如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
"上下文:\n{context}\n\n"
"对话历史:\n{chat_history}"
)
接着,修改提示词模板中的用户输入变量为input
from langchain_core.prompts import MessagesPlaceholder
prompt = ChatPromptTemplate.from_messages([
("system",
"你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
"请基于以下检索到的上下文回答用户的问题。\n"
"如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
"上下文:\n{context}"
),
# 历史消息占位
MessagesPlaceholder(variable_name="chat_history"), # ← 自动插入历史
("human", "{input}")
])
为什么必须要用input 呢,这是因为RunnableWithMessageHistory 默认从输入中取 "input" 字段作为用户消息。
然后,重构rag链(不带有底层记忆,只会处理一次问答),后续呢我们会把他升级成支持对话记忆的链。
# 单次问答的 RAG 链(无记忆)
from langchain_core.runnables import RunnablePassthrough
# 单次问答链()
rag_chain = (
RunnablePassthrough.assign(context=itemgetter("input") | retriever | format_docs)
| prompt
| llm
| StrOutputParser()
)
升级链:
# 带记忆的完整 RAG 链
rag_with_history = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key="input", # 用户输入字段
history_messages_key="chat_history" # 历史消息字段(对应 MessagesPlaceholder)
)
get_session_history:告诉它如何根据session_id获取历史;input_messages_key="input":用户输入在字典中的 key;history_messages_key="chat_history":这个值会被自动注入到 prompt 的{chat_history}中。
下面是改造完成之后的完整的代码(包含测试用例):
# ai_client.py
import os
from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import CharacterTextSplitter
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.prompts import MessagesPlaceholder
from langchain.memory import ChatMessageHistory
from langchain_core.runnables import RunnablePassthrough
from langchain_core.runnables.history import RunnableWithMessageHistory
from operator import itemgetter
import config
# 注入 DashScope API Key
if config.DASHSCOPE_API_KEY:
os.environ["DASHSCOPE_API_KEY"] = config.DASHSCOPE_API_KEY
# 初始化 LLM
llm = ChatTongyi(
model=config.MODEL,
temperature=config.TEMPERATURE,
)
# ========== 加载文档 ==========
all_docs = []
try:
txt_docs = DirectoryLoader(
config.DOC_DIR,
glob="**/*.txt",
loader_cls=TextLoader,
show_progress=True,
use_multithreading=True,
silent_errors=True
).load()
md_docs = DirectoryLoader(
config.DOC_DIR,
glob="**/*.md",
loader_cls=TextLoader,
show_progress=True,
use_multithreading=True,
silent_errors=True
).load()
pdf_docs = DirectoryLoader(
config.DOC_DIR,
glob="**/*.pdf",
loader_cls=config.DOC_DIR,
show_progress=True,
use_multithreading=True,
silent_errors=True
).load()
all_docs = (txt_docs or []) + (md_docs or []) + (pdf_docs or [])
except Exception as e:
print(f"⚠️ 文档加载失败: {e}")
all_docs = []
# 分块
text_splitter = CharacterTextSplitter(separator="\n\n", chunk_size=500, chunk_overlap=80)
texts = text_splitter.split_documents(all_docs) if all_docs else []
# 初始化 Embeddings
embeddings = DashScopeEmbeddings(model="text-embedding-v2")
# 初始化向量数据库(Chroma)
if texts:
vectorstore = Chroma.from_documents(
documents=texts,
embedding=embeddings,
persist_directory=config.EMBEDDINGS_DIR
)
vectorstore.persist()
else:
# 如果没有文档,尝试从已有数据库加载(避免报错)
try:
vectorstore = Chroma(
persist_directory=config.EMBEDDINGS_DIR,
embedding_function=embeddings
)
except Exception as e:
print("⚠️ 无文档且无法加载向量库,将使用纯 LLM 模式(无检索)")
vectorstore = None
# 初始化检索器
if vectorstore:
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
else:
# 无检索器时,返回空列表
retriever = lambda query: []
# 格式化检索结果
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# ========== 构建带记忆的 RAG 链 ==========
# Step 1: 修改 Prompt,加入聊天历史
system_prompt = (
"你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
"请基于以下检索到的上下文和之前的对话历史回答用户的问题。\n"
"如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
"上下文:\n{context}\n\n"
"对话历史:\n{chat_history}"
)
prompt = ChatPromptTemplate.from_messages([
("system",
"你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
"请基于以下检索到的上下文回答用户的问题。\n"
"如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
"上下文:\n{context}"
),
# 历史消息占位
MessagesPlaceholder(variable_name="chat_history"), # ← 自动插入历史
("human", "{input}")
])
# Step 2: 单次问答 RAG 链(无记忆)
rag_chain = (
RunnablePassthrough.assign(context=itemgetter("input") | retriever | format_docs)
| prompt
| llm
| StrOutputParser()
)
# Step 3: 内存中的会话历史存储
store = {}
def get_session_history(session_id: str) -> ChatMessageHistory:
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
# Step 4: 用 RunnableWithMessageHistory 包装,自动管理历史
rag_with_history = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key="input", # 用户输入字段
history_messages_key="chat_history" # 历史消息字段(对应 MessagesPlaceholder)
)
# ========== 多轮对话测试 ==========
if __name__ == "__main__":
SESSION_ID = "user_session_1" # 固定会话 ID,实现单用户多轮
print("💬 欢迎使用 Java 专家问答系统(支持多轮对话)\n")
# 第一轮
question1 = "怎么自定义线程池,不用给我代码,简单说不超过200个字"
print(f"👤 用户: {question1}")
print("🤖 AI: ", end="", flush=True)
for chunk in rag_with_history.stream(
{"input": question1},
config={"configurable": {"session_id": SESSION_ID}}
):
print(chunk, end="", flush=True)
print("\n")
# 第二轮
question2 = "那核心线程数一般设多少?"
print(f"\n👤 用户: {question2}")
print("🤖 AI: ", end="", flush=True)
for chunk in rag_with_history.stream(
{"input": question2},
config={"configurable": {"session_id": SESSION_ID}}
):
print(chunk, end="", flush=True)
print("\n")
# 第三轮
question3 = "如果任务很多,队列会满吗?"
print(f"\n👤 用户: {question3}")
print("🤖 AI: ", end="", flush=True)
for chunk in rag_with_history.stream(
{"input": question3},
config={"configurable": {"session_id": SESSION_ID}}
):
print(chunk, end="", flush=True)
print("\n")
print("\n✅ 多轮对话测试完成!")
llm的回复如下:
现在,我们的ai应用已经支持基于本地内存的多轮会话了!!!
6、对话记忆实现(基于Redis)
那我们接着来思考一下,仅仅是基于本地内存又有什么弊端呢?
我们的服务如果是部署在多个服务器上呢,如果我们的服务器内存不足呢,那么问题立马就暴露出来了,如果部署在多台服务器上的话,用户A第一次的请求被nginx负载均衡算法转发到了服务器A,那如果还是用户A,第二次请求被转发到了服务器B呢,这时候服务器B的内存中是没有用户A上一次的会话记忆的,就会出现一个类似于我们的多轮会话失效了的现象。
好,问题既然已经发现了,那怎么去解决呢,其实在上面我们提到了,LangChain不仅仅提供了基于本地内存的会话记忆,而且提供了基于Redis的会话记忆。想必Redis大家都应该听过吧,尤其是做Java后端开发的同学,什么缓存击穿,缓存雪崩,缓存穿透啊,但是现在我们用不到这些😄。因为我们就是把对话记录在本地内存存储的方式换成了存到Redis中,Redis是单线程(不过跟咱们现在这个项目没关系)的,并且可以搭建集群,同时呢Redis可以把对话通过生成rdb文件的方式持久化到磁盘,不管我们部署了多少个AI应用服务,部署了多少台服务器,都可以去Redis中查询对话记录。
首先我们要安装Redis,在这里,由于我现在使用的是macOS,所以就用docker的方式安装Redis了。win平台的可以自行去网上搜索安装方式
docker run -d \
--name my-redis \
-p 6379:6379 \
-v redis-data:/data \
--restart unless-stopped \
redis:7.0 redis-server --appendonly yes
出现这个界面,就代表Redis docker容器成功运行了
然后使用一款可视化的Redis管理工具:Another Redis Desktop Manager 连接到本地的Redis实例默认6379端口
安装好Redis实例之后,我们还需要使用LangChain去操作redis,就需要在项目中安装langchain-redis,使用pip命令,或者使用PyCharm可视化安装:
这里我就使用pip命令安装了,进入到当前项目的路径,在终端中输入:
pip install langchain-redis
好了,我们回到ai_client.py,导入所需要的包:RedisChatMessageHistory
from langchain_community.chat_message_histories import RedisChatMessageHistory
删除原来的sore和函数get_session_history,改为下面的代码
def get_session_history(session_id: str) -> RedisChatMessageHistory:
return RedisChatMessageHistory(
session_id=session_id,
url=config.REDIS_URL, # e.g., "redis://localhost:6379/0"
ttl=config.REDIS_TTL # 可选:过期时间(秒),如 3600
)
说明:
- 每次调用都会创建一个绑定到
session_id的Redis历史对象 - LangChain会自动在Redis中以
message_store:{session_id}为key存储消息 - ttl,也就是存活时间,可以自动清理过期的会话,避免占用过多的内存
在config.py中添加下面的配置:
# redis连接地址
REDIS_URL = "redis://localhost:6379/0"
# redis key过期时间
REDIS_TTL = 10000000
保持rag链不变,
修改之后完整的代码如下(包含测试用例):
# ai_client.py
import os
from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import CharacterTextSplitter
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnablePassthrough
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from operator import itemgetter
import config
# 设置 API Key
if config.DASHSCOPE_API_KEY:
os.environ["DASHSCOPE_API_KEY"] = config.DASHSCOPE_API_KEY
# 初始化 LLM
llm = ChatTongyi(
model=config.MODEL,
temperature=config.TEMPERATURE,
)
# ========== 加载文档 ==========
all_docs = []
try:
txt_docs = DirectoryLoader(
config.DOC_DIR,
glob="**/*.txt",
loader_cls=TextLoader,
show_progress=True,
use_multithreading=True,
silent_errors=True
).load()
md_docs = DirectoryLoader(
config.DOC_DIR,
glob="**/*.md",
loader_cls=TextLoader,
show_progress=True,
use_multithreading=True,
silent_errors=True
).load()
pdf_docs = DirectoryLoader(
config.DOC_DIR,
glob="**/*.pdf",
loader_cls=PyPDFLoader,
show_progress=True,
use_multithreading=True,
silent_errors=True
).load()
all_docs = (txt_docs or []) + (md_docs or []) + (pdf_docs or [])
except Exception as e:
print(f"⚠️ 文档加载失败: {e}")
all_docs = []
# 分块
text_splitter = CharacterTextSplitter(separator="\n\n", chunk_size=500, chunk_overlap=80)
texts = text_splitter.split_documents(all_docs) if all_docs else []
# 初始化 Embeddings
embeddings = DashScopeEmbeddings(model="text-embedding-v2")
# 初始化向量数据库
if texts:
vectorstore = Chroma.from_documents(
documents=texts,
embedding=embeddings,
persist_directory=config.DB_PATH
)
vectorstore.persist()
else:
try:
vectorstore = Chroma(
persist_directory=config.DB_PATH,
embedding_function=embeddings
)
except Exception as e:
print("⚠️ 无文档且无法加载向量库,将使用纯 LLM 模式")
vectorstore = None
# 初始化检索器
if vectorstore:
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
else:
retriever = lambda query: []
# 格式化检索结果
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# ========== 构建 Prompt ==========
prompt = ChatPromptTemplate.from_messages([
("system",
"你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
"请基于以下检索到的上下文回答用户的问题。\n"
"如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
"上下文:\n{context}"
),
MessagesPlaceholder(variable_name="chat_history"), # 自动插入历史消息
("human", "{input}")
])
# ========== 构建 RAG 链 ==========
rag_chain = (
RunnablePassthrough.assign(
context=itemgetter("input") | retriever | format_docs
)
| prompt
| llm
| StrOutputParser()
)
# ========== Redis 会话历史 ==========
def get_session_history(session_id: str):
return RedisChatMessageHistory(
session_id=session_id,
url=config.REDIS_URL,
ttl=config.REDIS_TTL # 自动过期(秒)
)
# ========== 带记忆的完整链 ==========
rag_with_history = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key="input",
history_messages_key="chat_history"
)
# ========== 测试多轮对话 ==========
if __name__ == "__main__":
SESSION_ID = "user_12345" # 实际项目中可用用户ID或UUID
print("💬 Java 专家问答系统(Redis 多轮对话)\n")
# 第一轮
q1 = "Java 中如何创建一个固定大小的线程池?"
print(f"👤 用户: {q1}")
print("🤖 AI: ", end="", flush=True)
for chunk in rag_with_history.stream(
{"input": q1},
config={"configurable": {"session_id": SESSION_ID}}
):
print(chunk, end="", flush=True)
print("\n")
# 第二轮
q2 = "那它和 cachedThreadPool 有什么区别?"
print(f"\n👤 用户: {q2}")
print("🤖 AI: ", end="", flush=True)
for chunk in rag_with_history.stream(
{"input": q2},
config={"configurable": {"session_id": SESSION_ID}}
):
print(chunk, end="", flush=True)
print("\n")
# 第三轮
q3 = "如果任务抛异常,线程池会怎样?"
print(f"\n👤 用户: {q3}")
print("🤖 AI: ", end="", flush=True)
for chunk in rag_with_history.stream(
{"input": q3},
config={"configurable": {"session_id": SESSION_ID}}
):
print(chunk, end="", flush=True)
print("\n")
print("✅ 对话已持久化到 Redis!")
运行代码控制台输出如下:
Redis中已经成功存储了对话
至此,基于Redis的多轮对话记忆开发完毕!!