RAG优化2:Chroma 持久化存储 + 多轮对话记忆 RAG

前置回顾:优化后的 RAG 链路:用户问题 → Hybrid 混合检索 → Reranker 重排筛选 → 组装 Prompt → LLM 生成答案 本章解决两个产品化痛点:

  1. Chroma 持久化:之前代码每次运行程序都要重新加载文档、切分、向量化、入库;持久化后向量数据库保存在本地磁盘,程序重启不用重复构建知识库。
  2. 多轮对话记忆:支持连续问答,模型能理解上下文历史,后续提问可以指代上文内容(例如 “那它的调优方式是什么?”)。

1 ChromaDB 持久化存储配置

Chroma 默认内存模式,程序退出向量数据全部丢失。

开启持久化,向量、文档、元数据会保存到本地文件夹,使用persist_directory参数指定存储目录。

import chromadb
from chromadb.utils import embedding_functions

# ---------------------- 持久化Embedding函数(本地Embedding模型) ----------------------
# 使用本地Embedding模型,不调用线上API
local_ef = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="./models/bge-small-zh-v1.5" # 本地Embedding模型路径
)

# ---------------------- 创建持久化Chroma客户端 ----------------------
# persist_directory:向量库保存到本地文件夹 chroma_db
client = chromadb.PersistentClient(path="./chroma_db")

# 获取/创建集合;如果集合已经存在,直接加载磁盘上的向量库,不会重复入库
collection = client.get_or_create_collection(
    name="knowledge_base",
    embedding_function=local_ef
)

✅ 两种场景使用

  1. 首次构建知识库(只执行一次)

文档加载、切分,添加到 collection,执行 add方法,即collection.add(),数据自动落盘。后续重启程序,不再运行这段入库代码。

from langchain.text_splitter import RecursiveCharacterTextSplitter

# 1.加载文档、切分文本
raw_text = """这里放你的知识库文档内容"""
text_splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
chunks = text_splitter.split_text(raw_text)

# 2.批量写入向量库,持久化保存
ids = [f"doc_{i}" for i in range(len(chunks))]
collection.add(
    documents=chunks,
    ids=ids
)
print("知识库写入完成,向量数据已持久化到 ./chroma_db")
  1. 程序重启,直接加载已有知识库(日常问答使用)

程序再次启动,只需要执行上面PersistentClient+get_or_create_collection,不需要重新 add 文档,直接读取磁盘向量,节省大量时间。

💡 持久化注意事项

  • 不要手动修改./chroma_db文件夹内文件,会损坏向量库;
  • 新增文档:继续调用collection.add()追加写入;
  • 删除文档:collection.delete(ids=["doc_0"]);
  • 备份知识库:直接复制整个./chroma_db文件夹。

2 多轮对话记忆 RAG 设计思路

普通单轮 RAG:每次提问独立,模型不知道历史对话。 多轮 RAG 核心流程:

  1. 用户输入本轮问题;
  2. 对话记忆模块读取历史问答;
  3. 对本轮原始问题做问题改写(Query Rewrite):把简写、指代类问题,结合历史对话,扩写成完整独立问题,再送入向量检索;

举例: 历史:用户:什么是动态召回流程? 助手:xxxx 用户:那它的优化手段有哪些? 改写后的检索 query:动态召回流程的优化手段有哪些?

  1. 使用改写后的 query 执行 Hybrid 混合检索 + Reranker 重排;
  2. 组装 Prompt:【系统提示词】+【历史对话】+【检索到的参考资料】+【用户本轮问题】;
  3. LLM 生成回答;
  4. 将本轮「用户问题 + 模型回答」存入对话记忆,供下一轮使用。

2.1 对话记忆存储(简单内存版,适合 demo)

# 对话历史,用列表存储,每一条是字典
chat_history = []

def add_chat_history(user_query, llm_answer):
    """新增一轮问答到对话记忆"""
    chat_history.append({"user": user_query, "assistant": llm_answer})
    # 控制最大历史轮数,防止prompt过长,超出上下文窗口
    max_history = 4
    if len(chat_history) > max_history:
        chat_history.pop(0) # 丢弃最早一轮对话

2.2 Query 改写:多轮问答关键步骤

如果直接拿简写问题去向量检索,检索效果很差。使用本地大模型,基于对话历史,重构检索 query。

def rewrite_query(original_user_query, chat_history):
    """
    结合对话历史,改写用户问题,生成适合向量检索的独立完整query
    """
    if not chat_history:
        return original_user_query
    
    rewrite_prompt = f"""
下面是历史对话记录:
{chat_history}
用户当前提问:{original_user_query}
任务:基于历史对话,把当前用户问题改写为一个独立完整的问题,用于知识库检索。
只输出改写后的问题,不要多余解释。
"""
    # 调用本地LLM,得到改写后的query
    output = llm.create_completion(prompt=rewrite_prompt, max_tokens=128, temperature=0)
    new_query = output["choices"][0]["text"].strip()
    return new_query

 

2.3 多轮 Prompt 模板组装

Prompt 里分为三块:系统约束 + 历史对话 + 检索参考资料 + 当前用户问题

def build_multi_round_prompt(chat_history, context_content, current_user_query):
    history_text = ""
    for item in chat_history:
        history_text += f"用户:{item['user']}\n助手:{item['assistant']}\n"

    prompt_template = """
你是知识库问答助手,请严格根据【参考资料】回答用户问题。
如果参考资料没有相关信息,直接回答知识库无相关内容,禁止编造信息。

【历史对话】
{history_text}

【参考资料】
{context_content}

【当前用户问题】
{current_user_query}

请简洁回答:
"""
    final_prompt = prompt_template.format(
        history_text=history_text,
        context_content=context_content,
        current_user_query=current_user_query
    )
    return final_prompt

3 完整多轮问答主流程代码

# ========== 多轮问答主函数 ==========
def multi_round_chat(user_question):
    # Step1:改写query(带历史对话)
    rewrite_q = rewrite_query(user_question, chat_history)
    print(f"原始问题:{user_question},改写后检索query:{rewrite_q}")

    # Step2:混合检索+Reranker重排(复用第七章代码)
    # 向量检索 + BM25检索 →合并候选文档 →Reranker打分筛选Top3
    vector_results = collection.query(query_texts=[rewrite_q], n_results=5)
    vector_retrieved_docs = vector_results['documents'][0]
    # BM25检索逻辑省略,复用上节代码
    query_tokens = jieba_cut(rewrite_q)
     bm25_results = bm25_search(query_tokens)
    # Reranker重排得到精选上下文context_content
     context_content = reranker_rank(retrieved_docs, rewrite_q)
    # Step3:组装多轮Prompt
    prompt = build_multi_round_prompt(chat_history, context_content, user_question)

    # Step4:本地大模型生成答案
    output = llm.create_completion(prompt=prompt, max_tokens=512, temperature=0.1)
    answer = output["choices"][0]["text"].strip()

    # Step5:保存本轮问答到对话历史
    add_chat_history(user_question, answer)
    return answer

# -------- 测试对话 --------
if __name__ == "__main__":
    while True:
        user_input = input("请输入问题:")
        if user_input == "exit":
            break
        res = multi_round_chat(user_input)
        print(f"\n助手回答:{res}\n")

4 记忆方案扩展选型

  1. 内存记忆(上面示例):程序关闭对话记录丢失;适合 Demo、本地测试。
  2. 文件持久记忆:把chat_history写入 json 文件,重启程序加载历史;适合单用户本地使用。
  3. 向量记忆(长对话):对话轮数非常多时,把历史对话向量化检索,只召回相关历史片段,避免 Prompt 过长(长上下文 RAG)。

5 常见踩坑点

  1. Prompt 超长:历史对话 + 检索上下文叠加,容易超过本地 LLM 上下文窗口。
    • 对策:限制最大历史轮数;对历史做摘要压缩;
  2. 改写 Query 引入错误:改写模型理解错误,生成错误检索词;temperature设置 0 降低随机性;
  3. Chroma 持久化版本兼容:Chroma 升级版本后,旧的持久化数据库可能无法加载,注意锁定版本。
posted @ 2026-09-22 15:55  sunshine_coast  阅读(33)  评论(0)    收藏  举报