为什么做这个项目
RAG 的入门示例通常很短:把文档转成向量,按问题检索几个片段,再把片段交给大模型。这样的代码足以解释概念,却未必能成为一个可以反复演示的程序。文件编码不对怎么办?向量写入失败后还能不能重试?两个浏览器会话会不会共享上下文?历史文件损坏时,页面是给出可理解的错误,还是直接抛出堆栈?这些问题与“模型能否回答”同样决定体验。
Nailong 保留了一个易理解的技术组合:Streamlit 提供上传和问答页面,LangChain 编排链路,ChromaDB 保存向量,DashScope 提供嵌入与通义聊天模型。工程化工作的目标不是把本地原型包装成生产平台,而是把边界说清、失败状态收住,并让核心行为能够离线回归。最终得到的是一个可靠的本地演示:它只接收 UTF-8 编码的 .txt 和 .md 文本,外部服务仍然不可避免,但多数逻辑不再依赖网络才能测试。
两条数据流看懂系统
理解这个项目,最省力的方式不是从页面组件开始,而是先拆成两条方向相反的数据流。第一条负责把可信文本变成可检索的知识片段:
上传 .txt/.md -> 校验并解码 UTF-8 -> 内容去重 -> 按需切块
-> DashScope 嵌入 -> 写入 ChromaDB -> 成功后记录指纹
第二条从用户问题出发,用检索结果约束生成,并把对话纳入当前会话:
用户问题 -> ChromaDB 检索前 3 个片段 -> 格式化正文与元数据
-> 合并当前会话历史 -> 通义模型流式生成 -> 保存问答历史
这两个流程分别由独立的 Streamlit 页面触发。上传页面不负责回答,问答页面也不悄悄导入文件。分开之后,故障定位更直接:文档校验、嵌入或向量写入属于入库侧;检索、提示词、模型生成或历史保存属于问答侧。测试也可以在边界处注入替身,而无需每次启动全部外部依赖。
文本如何进入 ChromaDB
入口先于模型做验证。decode_document 根据扩展名限制输入类型,使用 utf-8-sig 解码,因此普通 UTF-8 与带 BOM 的 UTF-8 都能处理;无效字节、空白文件和其他扩展名会得到明确的领域错误。这里没有 PDF、Word、OCR 或图片解析能力,页面的文件选择器与后端验证保持同一口径。
通过验证后,KnowledgeBaseService 对完整文本计算内容指纹。已存在的指纹直接返回重复状态,不再调用向量库。超过阈值的文本交给 RecursiveCharacterTextSplitter,当前块大小为 1000、重叠为 100,并按中英文标点和空白逐级寻找切分点。每个片段带有来源文件名和创建时间等元数据,再由 text-embedding-v4 生成嵌入并写入同一个 ChromaDB collection。
真正关键的是副作用顺序。只有 add_texts 成功后才持久化内容指纹;如果向量写入抛错,指纹文件不会提前留下“已经导入”的假象,用户下次仍可重试。核心分支可以概括为:
def upload_by_str(self, data: str, filename: str) -> UploadResult:
digest = get_string_md5(data)
if check_md5(digest, self.md5_path):
return UploadResult(UploadStatus.DUPLICATE, 0)
chunks = (
self.splitter.split_text(data)
if len(data) > self.max_split_chars
else [data]
)
metadata = {
"source": filename,
"create_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"operator": "Aliya",
}
chunk_ids = [f"{digest}:{index}" for index, _ in enumerate(chunks)]
self.vector_store.add_texts(
chunks, metadatas=[metadata.copy() for _ in chunks], ids=chunk_ids
)
save_md5(digest, self.md5_path)
return UploadResult(UploadStatus.ADDED, len(chunks))
这段摘录省略了构造过程,并统一了局部变量名,但保留了事务语义:先完成主要写入,再提交去重标记。它不是跨存储的真正数据库事务,却消除了本地演示中最容易造成永久误判的失败窗口。
LangChain 如何组织一次回答
问答侧的 RagService 把检索、格式化、提示词、模型和输出解析器组合为一条 Runnable 链。检索器只接收当前 input,默认通过 search_kwargs={"k": 3} 取三个结果。format_documents 将每个 Document 的正文和元数据拼入参考资料;若没有结果,它不会假装存在知识,而是写入“没有足够依据”的明确提示。
提示词由四层消息组成:系统角色先声明只能依据参考资料回答,第二条系统消息注入检索上下文,然后放置当前会话的历史,最后加入这次用户问题。生成结果经过 StrOutputParser 变成字符串,再由历史包装器记录输入和输出。组合关系如下:
retrieval = (
RunnableLambda(itemgetter("input"))
| self.vector_service.get_retriever()
| RunnableLambda(format_documents)
)
base_chain = (
RunnablePassthrough.assign(context=retrieval)
| prompt
| self.chat_model
| StrOutputParser()
)
return RunnableWithMessageHistory(
base_chain, self.history_factory,
input_messages_key="input", history_messages_key="history",
)
依赖注入在这里不是为了追求抽象。它让测试能够换入固定文档、可记录提示词的本地 Runnable 和内存历史,从链的公开入口验证“问题确实触发检索、资料确实进入提示词、回答确实进入指定会话”,同时避免在单元测试里消耗模型配额。
多轮会话与流式输出
页面为每个浏览器会话生成独立标识,并在每次 chain.stream 调用时通过 configurable.session_id 显式传入。点击“新建会话”会轮换标识并重置页面上可见的消息,而不是复用进程级全局会话。后端只接受由字母、数字、下划线和连字符组成的有限长度标识,避免标识被解释成任意文件路径。
历史以 UTF-8 JSON 保存。写入时先在目标目录创建临时文件,完整序列化后再用原子替换覆盖正式文件;替换失败会清理临时文件并保留原历史。无效 UTF-8、损坏 JSON 或无法还原的消息类型统一转换为 HistoryStoreError,UI 边界再给出面向用户的失败信息。
流式展示也处理了“显示”和“保存”的差别:生成块一边交给 write_stream,一边被收集,结束后拼成完整助手消息加入页面状态。这样用户能立即看到增量输出,刷新前的界面记录也不会只剩最后一个分片。需要注意,流式体验不等于回答正确;正确性仍受上传材料、召回结果与模型行为共同限制。
从代码审阅中发现的五个问题
这次复盘最有价值的部分,不是列出使用了哪些库,而是把可靠性问题改写成可验证的契约。五类问题、修复方式与回归证据如下:
| 可靠性问题 | 修复 | 回归测试 |
|---|---|---|
| 配置路径、密钥和输入格式缺少清晰边界 | 运行时存储路径改为项目绝对路径;集中校验环境变量;独立解码并限制 UTF-8 .txt、.md;会话标识可创建和轮换 | 7 项配置、输入与会话测试覆盖缺失密钥、BOM、无效编码、空文件、扩展名和标识轮换 |
| 去重标记可能与向量写入状态不一致 | 返回明确的新增/重复结果;向量写入成功后才记录指纹;切块元数据逐份复制 | 4 项入库测试覆盖默认嵌入装配、重复跳过、写入失败可重试和长文切块 |
| 检索数量没有按 LangChain 约定传递 | 通过 search_kwargs 转发配置值,并允许显式覆盖;注入的存储即使为假值也不被替换 | 2 项检索器测试精确断言默认值与覆盖值的参数形状 |
| 文件历史可能被路径穿越、损坏内容和中断写入破坏 | 限制会话标识;统一损坏错误;临时文件加原子替换,失败时保留旧数据 | 6 项历史测试覆盖往返、清空、坏 JSON、坏编码、未知类型、路径穿越和替换失败 |
| RAG 链与页面生命周期耦合,依据、会话和失败状态难以验证 | 检索资料与元数据进入受约束提示词;依赖可注入;服务延迟到用户动作后创建;每次调用显式传会话标识 | 2 项链测试验证资料、空检索和历史,5 项 Streamlit 测试验证启动、缺失密钥与损坏历史提示 |
表格中的数量按测试文件分组描述,部分测试会同时断言多个结果,例如替换失败时旧记录仍可读取且临时文件已清理。重点不在“测试越多越好”,而在每个修复都有能在未来重构时报警的观察点。
修复之后,测试验证了什么
完整离线套件的最终结果是 26 passed,1 warning。唯一警告来自第三方 pytz 对 datetime.utcfromtimestamp() 的弃用提示,不是项目代码的失败。测试使用临时目录、固定向量存储、可记录 Runnable 与内存历史,不发起 DashScope 请求;它覆盖了输入、入库事务顺序、检索参数、历史持久化、RAG 组合、两个 Streamlit 页面的初始渲染和损坏历史提示。全部源码与测试也通过了 compileall。
两个页面随后分别做了无头健康检查,Streamlit 健康端点都返回 HTTP 200 和 ok。这说明入口可以启动,但健康端点不证明嵌入、检索或回答质量。因为当时环境已经配置 DashScope 密钥,另做了一次受控实时检查:使用真实 ChatTongyi,配合固定的非敏感检索器和内存历史,连续两次调用 RagService.chain.stream,得到两个非空流式回答,并在同一会话中观察到 4 条消息。
这项实时证据只覆盖聊天模型与链的连续调用。它没有读取或验证现有 ChromaDB,也没有读取或验证已有文件历史,更没有验证真实上传文档的召回质量。若执行环境没有配置密钥,报告应明确写“实时 DashScope 验证不可用”,而不能把离线替身测试说成线上验证;本次不属于这种情况,密钥值也从未输出。
这个演示还不是生产系统
“可靠的本地演示”是一个刻意收紧的结论。系统没有身份认证、授权、租户隔离、加密存储、审计、限流或配额治理;单机文件和单个本地向量目录也没有备份、迁移、保留策略与并发写入协调。会话隔离依赖随机标识,不等于安全身份。错误提示保护了界面细节,却没有替代结构化日志和可观测性。
数据能力同样有限。系统只支持 UTF-8 .txt 与 .md,没有复杂文档版面解析;固定取前三个片段,没有重排、过滤、引用核验或召回评估;“只能依据资料”的提示能降低无依据回答,却不能从机制上消除幻觉。外部 DashScope 的可用性、网络、凭据和额度仍会影响真实运行。
隐私边界尤其不能模糊。本地向量、对话记录与内容指纹都可能携带或关联用户资料,不应作为示例资产发布。本文只依据源码、隔离测试和非敏感受控检查撰写,没有查看这些运行时内容。换句话说,工程化让失败更可控、行为更可测,却没有自动赋予系统生产级安全与数据治理能力。
下一步怎么走
下一阶段应先建立可量化的检索评估集:为一组非敏感问题标注相关片段,分别测量召回率、答案依据覆盖和拒答质量,再决定是否增加混合检索、重排或查询改写。没有基线就直接叠加组件,只会让链更长而不一定更准。
如果要从单机演示走向多人使用,还需把身份、权限、密钥管理、持久化后端、并发控制、备份恢复、数据删除和审计纳入同一设计,并为外部调用增加超时、重试、限流和成本观测。对当前项目而言,最值得保留的原则已经很清楚:先画出数据流和失败边界,再把副作用推到可控位置,最后用离线测试证明每个关键契约。RAG 的工程质量,最终体现在资料不完整、服务失败和会话交错时,系统仍然能给出诚实且可恢复的行为。

没有回应