0 为什么开始

基于学习ai&agent的想法,想动手实操个项目,想着期间可以应用harness架构和思想解决agent典型问题,增强下自己对于各概念的理解。

1 项目介绍

1.1 流程图


用户问题
   ↓
LLM 决策(有工具可用)
   ├── 调用 search_knowledge_base → 返回本地 chunk
   ├── 调用 search_web           → 返回网页摘要
   └── 直接回答(无需工具)
   ↓(工具结果写回 messages)
LLM 再次决策 → 循环至 max_tool_rounds 或直接输出答案

1.2 索引构建部分

技术选型:

  • 向量数据库:本地ChromeDb->云端qdrant存储

  • 倒排索引:本地自建倒排索引,存分词->文档内容。用的bm25

索引冷启动构建流程(启动前检查,qdrant数据库为空才进行):

  • 拿到本地docs下文档
  • chunk分为多段,单chunk加了数量限制(800),核心逻辑是按双换行符切段落,贪心合并,首先按照段落chunk,单段落超出会退到句子维度截断,目前没加overlap,因为1)已经做了分段落chunk&整句chunk,叠加有向量化+倒排双检索,会缓解跨段语义缺失问题。2)目前内化文档长度有限,非大篇幅的技术文档,overlap收益低。3)overlap会带来存储增长,BM25 语料有重复文本,相邻 chunk 相似度高会互相竞争排名。
  • chunk后的文档进行embedding,存入qdrant中,包含原文内容。

启动时的倒排索引构建流程:

  1. 获取文档数,大于0才构建
  2. 拉取所有向量文档
  3. jieba 分词 → BM25Okapi 构建倒排索引
  4. 结果存到本地缓存中,保存向量数据库的point_id。

1.2.1 向量库部分

概念

Payload 是附加在每个向量点(Point)上的结构化元数据,本质就是一个 JSON 对象,和向量数据一起存储。

Point 存了三部分:
id:数值型 ID(由字符串 md5 转换而来)
vector:512维的embedding向量
payload:附加的结构化数据,包含:

  • document:原始文本内容(chunk 的实际文字)
  • _str_id:原始字符串 ID(方便反查)
  • meta:从文档frontmatter 解析出来的元数据,比如 source(来源文件名)、type(knowledge_base 或 web_cache)等
检索算法
  • HNSW: ChromaDB 默认用的
  • IVF:先K-Means 聚类,查询时只搜最近的几个聚类中心
    适合内存受限的大规模场景
存储数据类型
  1. knowledge_base:内化后的总结文档
  2. web cache:网络搜索缓存。有两个过期删除策略:1)数量上限,满200条自动删除。2)TTL上线,7天自动删除。
写入流程
  • embedding模型:BAAI/bge-small-zh-v1.5,512维度,模型参数约95MB,专为中文设计,MTEB中文榜第一梯队

chunk ID 按数据类型分两种生成策略:

类型 ID 格式 示例 设计原因
knowledge_base(本地文档) 全量:chunk_{i}
增量:chunk_{filename}_{i}
chunk_0chunk_rag_intro.md_0 全量重建先清空整库再写入,连续序号即可,文件名在 payload source 字段中;增量更新需按文件删旧写新,用文件名做前缀隔离命名空间,精准定位该文件的所有 chunks
web_cache(联网搜索结果) web_{md5(source+text[:64])} web_3a9f... 内容寻址,相同来源+相同内容生成相同 ID,天然实现幂等 upsert,同一页面多次搜索不重复写入

本地文档类更新:

  1. 文档路由,llm根据总结内容路由到指定文档(根据文档description),找不到就按照分类标准新建文档
  2. 提炼回答内容,加入指定文档
  3. 根据文档名字,删除向量数据库内容,然后再将本次内容新增到向量数据库
  4. 触发本地bm25索引失效,异步开始索引构建。

网络cache类更新:

  1. 获取文档前N个字符的md5编码格式,作为文档id
  2. 根据文档id覆盖新增
  3. 获取所有cache类文件,根据数量上限&TTL删除无效cache索引

1.2.2 倒排数据库部分

采用本地BM25倒排存储,bm25ok api。

分词算法:中文jieba + 英文用空白和标点切分

分词算法选择

几种主流中文分词方案:

方案 原理 优点 缺点
按标点/空白切分 正则分割 零依赖,极快 中文整句作为一个词元,BM25 对中文基本失效
jieba 精确模式 HMM + 词典 轻量,分词准确 保留虚词(“的”/“了”/“是”),需配合停用词表
jieba + 手写停用词表 精确模式 + 词表过滤 可控 停用词表需人工维护,覆盖不全,领域迁移成本高
jieba posseg(词性标注) HMM + 词性感知 API 内置虚词识别,无需词表 略慢于纯分词,首次加载有初始化开销
HanLP / LTP 深度学习 分词精度最高 模型体积大(数百 MB),推理慢,引入重依赖
Elasticsearch / Lucene IK 持久化倒排索引 工业级,增量更新 需要独立服务,架构复杂度高

jieba提供的api介绍:

模式 API 切分策略 词元数量 适用场景
精确模式 jieba.cut(text) HMM + 词典,力求最精准切分,不重叠 适中 文本分析、BM25 索引基础
全模式 jieba.cut(text, cut_all=True) 把所有可能的词全部切出,允许重叠 最多 搜索引擎召回扩展
词性标注模式 jieba.posseg.lcut(text) 精确模式基础上同时输出每个词的词性 与精确模式相同 需要过滤虚词(如“的”,“我”等虚词)的 BM25、信息抽取
关键词提取 jieba.analyse.extract_tags(text, topK=N) TF-IDF 打分,只返回权重最高的 topK 个词 最少(受 topK 限制) 摘要生成、文章标签、SEO 关键词

本项目中选择了第3个api,因为第4个会带来一些问题。
1)适合summary类总结,非全文召回类。
2)会带来召回率损失的问题。
3)idf算法是通用的语料库训练出来的,不一定适配技术类文档。

1.2.3 两个数据库的内容同步

向量数据库为主存储,bm25根据向量数据库构建索引。

原因:向量数据库是远程持久化的,且有文档全文。本地bm25刷新后会消失,不能用作主存储介质。

1.3 在线查询步骤

未达到

达到上限

正常

空流降级

① 用户提问

② 构建 messages
─────────────────
system_prompt + 今日日期
+ 历史对话(最近6轮/12条)
+ user: 当前提问

③ 非流式调用 LLM
─────────────────
传入工具定义
等待完整 JSON 响应

有 tool_calls?

④ yield tool_call 事件
前端展示「正在检索...」

⑤ 执行工具(同步)
─────────────────
search_knowledge_base
→ BM25+向量+RRF 混合检索
─────────────────
search_web
→ Tavily API
→ add_chunks 缓存原始内容

⑥ yield tool_result 事件
前端展示来源卡片

⑦ 工具结果追加到 messages

达到 max_tool_rounds=4?

⑨ 流式调用 LLM
─────────────────
stream=True
逐 token yield answer_chunk
前端实时渲染 ▌

chunk_count = 0?

⑩ yield answer 事件
流结束标记

非流式补一次调用
仍为空 → StreamEmptyError
→ 触发模型 fallback

⑪ 异步知识内化(daemon 线程)
─────────────────
LLM 提炼 → 写入 data/docs/
重索引 → invalidate_bm25

rag检索时:

  • 向量检索:query 经 embedding → 512维向量 → Qdrant ANN 查最近邻 → 返回 payload 里的 document
  • BM25 检索:query 分词 → bm25.get_scores(tokens) → 按分数取 top-k → 用 id 去 _bm25_cache 里查原文

两路都直接拿到原文,RRF 融合后返回给 Agent。

1.4 知识自动内化

为什么要内化

RAG 系统的知识库如果是静态的,每次遇到知识库没有覆盖的问题都要走网络搜索,延迟高、费用贵。知识内化的目标是把"搜过的内容"沉淀为本地知识,下次同类问题优先从本地命中。

流程
search_web 结果
    ↓
Step 0: 实时性判断 → 是实时类?跳过
    ↓
Step 1: 根据query的md5前置判断,当前已经有该query的内化文档了,直接跳过内化流程
    ↓
Step 2: LLM 提炼 → 结构化 markdown(≤500字)
    ↓
Step 2.5: 质量过滤 → 拒绝语 / 重复度 / LLM 打分
    ↓
Step 3: 动态路由 → 选目标 .md 文件(或新建)
	description` 字段就是这个文件的"技能描述",LLM 根据它判断内容应该归到哪里。
    ↓
Step 4: 增量写入 → 写入前再次进行md5 去重(处理并发内化相同query的问题,后期频率高了需要加锁保证),追加到文档和文件.hashes文档
    ↓
Step 5: 重索引 → index_single_document + invalidate_bm25
    ↓
Step 6: Gist 审计 → 记录内化记录供侧边栏展示

质量过滤环节:三层过滤,逐层收紧。

# 层 1:拒绝语检测(LLM 提炼失败时的常见输出)
REFUSAL_PHRASES = ["无法提炼", "无相关内容", "未找到相关", "unable to extract", ...]
for phrase in REFUSAL_PHRASES:
    if phrase.lower() in refined.lower():
        return False  # 跳过

# 层 2:与 query 字符重叠率 >= 0.85(提炼内容就是 query 本身,没有增量价值)
ratio = len(set(refined[:200]) & set(query)) / max(len(set(refined[:200])), len(set(query)))
if ratio >= 0.85:
    return False

# 层 3:LLM 打分(LLM_JUDGE 环境变量配置,有自己的 fallback)
# 全部 judge 模型失败时降级为硬规则通过,不阻断内化
去重的设计

目前没有进行去重,主要目的是允许内化文档的内容更新。去重通过另一个定时任务执行文档总结和重复清理。

内化文档的合并整理

新建一个定时任务
LLM 整理 .md 文件(_process_file)↓覆写文件内容
_reindex(filepath)

index_single_document(filepath)
├── _delete_by_filter(“source”, filename) # 删 Qdrant 中该文件所有旧 chunks
└── _index_docs_list()├── chunk → embed
├── payload带上 indexed_at = now()← 刷新时间戳
└── upsert 进 Qdrant

2 项目中遇到的问题和解决

工程方面

性能提升

  1. 接入embedding后,用户第一次查询性能慢(需要去笑脸网下载embedding模型参数)
    • 启动时预热,前置下载过程
    • 增加模型参数缓存,单进程只获取一次,无需每次联网下载
  2. 侧边栏渲染时拉取全量数据内容,导致渲染慢
    • 换方法,只拉数量,不获取内容
  3. 联网搜cache类写入时有去重,每次拉全量数据,性能差。
    • 根据类型拉取对应数据

harness架构方面

稳定性建设

  1. 模型调用失败兜底。分为调用api失败&模型输出异常两类。
    1. 调用失败:捕获异常后对异常内容分类,模型不可用类型进入模型降级链路,选择备用模型进入下一轮重试。
    2. 模型输出异常:事前通过prompt限制模型只能输出tool调用或文档总结两类内容。事后在最终输出前检测输出内容,若为空从流式降级到批示调用,保证有结果输出。
  2. 模型行为限制:通过prompt限制模型行为,项目中涉及到判断的地方(如工具选用,总结输出,文档内化总结,写入文档路由等环节)基本是规则匹配+模型实现的,规则匹配的优势是快&便宜,模型面对复杂问题判断要优于规则匹配。
  3. 代码提交前的自动验收:测试覆盖五类核心行为:实时信息路由、知识库路由、KB 空结果降级、停止信号响应、流式空内容降级。所有测试 mock 掉 LLM 和 Tavily,无需 API Key,CI 环境可直接运行。

context上下文管理&多轮追问

目前仅有简单的N轮上下文保留,由于是搜索,不会出现token过大的问题,但后续需注意过长上下文带来噪声的影响,这块待优化!

效果评估

  1. 基于ragas框架提出的的评估指标体系进行离线评估。
    • 结果和问题相关性
    • 结果忠于事实能力
    • tool调用过程有效性
  2. 记录检索结果和两类本地索引的比例关系,作为判断本地索引质量的标准。这个比例直接反映混合检索的实际贡献分布:如果 vec_only 始终接近 100%,说明 BM25 实际没有起作用,需要检查分词器或 fetch_k 参数。

ragas评估体系如下表:

指标 衡量什么 计算方式
Faithfulness(忠实度) 回答是否"只说"了检索内容里有的内容,没有幻觉 把答案拆成若干陈述,LLM 判断每条是否可以从 context 推出,取比例
Answer Relevancy(答案相关性) 回答是否真正回答了用户的问题 LLM 根据答案反推若干问题,用 Embedding 计算这些问题与原问题的余弦相似度均值
Context Recall(上下文召回率) 检索到的内容是否覆盖了标准答案所需的信息 把 ground truth 拆成若干句,LLM 判断每句是否能从 context 中找到,取比例

3 效果评估

实测结果(2026-04-12,19 题)

指标 均值 期望范围
Faithfulness 0.882 ≥ 0.85 ✅
Answer Relevancy 0.120 ≥ 0.70 ❌
Context Recall 0.263 ≥ 0.60 ❌

Faithfulness 达标,说明 LLM 的生成阶段比较克制——在检索内容存在时,不倾向于编造。

第二次评测(2026-04-19):混合召回+换 Embedding 模型后的变化

在完成以下改动后重新评测:

  • Embedding 模型从 all-MiniLM-L6-v2(384维,英文)→ BAAI/bge-small-zh-v1.5(512维,中文)
  • 知识库扩充(366条 vs 之前更少)
  • 混合检索(BM25 + 向量 + RRF)已上线
指标 第一次(4-12) 第二次(4-19) 变化
Faithfulness 0.882 0.982 ↑ +0.100
Answer Relevancy 0.120 0.566 ↑ +0.446
Context Recall 0.263 0.895 +0.632

三项全部大幅提升,其中 Context Recall 提升最显著(+0.632),直接验证了第一次分析的判断:检索召回率不足是主要瓶颈,根本原因是 Embedding 模型与语料语言不匹配

典型 case:Q17「BM25 算法的缺点是什么?」

第一次 第二次
Context Recall 0.00 正常
Answer Relevancy 0.00 正常

第一次:all-MiniLM-L6-v2 是英文模型,对中文问题的 embedding 和中文文档的 embedding 语义距离很远,向量检索完全没有召回到 search_algorithms.md 里关于 BM25 缺点的段落,模型只能回答"参考内容中未提及",Context Recall 和 Answer Relevancy 双双为 0。

第二次:换用 bge-small-zh-v1.5 后,中文语义理解准确,正确召回了相关 chunk,两项指标恢复正常。

一句话结论:Embedding 模型的语言匹配程度直接决定 RAG 的召回质量上限,在中文知识库上用英文模型,召回率可能从"有"变成"0"。

评估局限与改进方向

当前评估的局限:

  1. 测试集质量依赖 LLM 生成:自动生成的 ground_truth 本身也可能不准确,LLM 对同一问题可能在不同运行中给出不同答案
  2. 测试集与知识库耦合强:知识库变更后 questions.json 需要重新生成,否则会出现上面的"文件已删除但题目还在"问题
  3. Answer Relevancy 对中文支持有限:RAGAS 用 Embedding 余弦相似度计算,短答案、文言式中文都会导致偏低

下一步改进方向:

  • 维护一批手工标注的"黄金问题集",与知识库版本绑定
  • 引入 Context Precision(精准率,检索结果中有多少真正有用)补充 Recall 的视角
  • 把评估纳入 CI,每次合并知识库变更时自动跑评估,曲线有回退则 Block

4 欢迎体验

项目地址:https://github.com/my-zzylearner/rag-agent
有说明文档介绍如何本地使用,网页版由于tavily次数限制暂未开放,大家有问题欢迎github或csdn提问哦~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐