agent-ai搜项目介绍
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中,包含原文内容。
启动时的倒排索引构建流程:
- 获取文档数,大于0才构建
- 拉取所有向量文档
- jieba 分词 → BM25Okapi 构建倒排索引
- 结果存到本地缓存中,保存向量数据库的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 聚类,查询时只搜最近的几个聚类中心
适合内存受限的大规模场景
存储数据类型
- knowledge_base:内化后的总结文档
- 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_0、chunk_rag_intro.md_0 |
全量重建先清空整库再写入,连续序号即可,文件名在 payload source 字段中;增量更新需按文件删旧写新,用文件名做前缀隔离命名空间,精准定位该文件的所有 chunks |
| web_cache(联网搜索结果) | web_{md5(source+text[:64])} |
web_3a9f... |
内容寻址,相同来源+相同内容生成相同 ID,天然实现幂等 upsert,同一页面多次搜索不重复写入 |
本地文档类更新:
- 文档路由,llm根据总结内容路由到指定文档(根据文档description),找不到就按照分类标准新建文档
- 提炼回答内容,加入指定文档
- 根据文档名字,删除向量数据库内容,然后再将本次内容新增到向量数据库
- 触发本地bm25索引失效,异步开始索引构建。
网络cache类更新:
- 获取文档前N个字符的md5编码格式,作为文档id
- 根据文档id覆盖新增
- 获取所有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 在线查询步骤
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 项目中遇到的问题和解决
工程方面
性能提升
- 接入embedding后,用户第一次查询性能慢(需要去笑脸网下载embedding模型参数)
- 启动时预热,前置下载过程
- 增加模型参数缓存,单进程只获取一次,无需每次联网下载
- 侧边栏渲染时拉取全量数据内容,导致渲染慢
- 换方法,只拉数量,不获取内容
- 联网搜cache类写入时有去重,每次拉全量数据,性能差。
- 根据类型拉取对应数据
harness架构方面
稳定性建设
- 模型调用失败兜底。分为调用api失败&模型输出异常两类。
- 调用失败:捕获异常后对异常内容分类,模型不可用类型进入模型降级链路,选择备用模型进入下一轮重试。
- 模型输出异常:事前通过prompt限制模型只能输出tool调用或文档总结两类内容。事后在最终输出前检测输出内容,若为空从流式降级到批示调用,保证有结果输出。
- 模型行为限制:通过prompt限制模型行为,项目中涉及到判断的地方(如工具选用,总结输出,文档内化总结,写入文档路由等环节)基本是规则匹配+模型实现的,规则匹配的优势是快&便宜,模型面对复杂问题判断要优于规则匹配。
- 代码提交前的自动验收:测试覆盖五类核心行为:实时信息路由、知识库路由、KB 空结果降级、停止信号响应、流式空内容降级。所有测试 mock 掉 LLM 和 Tavily,无需 API Key,CI 环境可直接运行。
context上下文管理&多轮追问
目前仅有简单的N轮上下文保留,由于是搜索,不会出现token过大的问题,但后续需注意过长上下文带来噪声的影响,这块待优化!
效果评估
- 基于ragas框架提出的的评估指标体系进行离线评估。
- 结果和问题相关性
- 结果忠于事实能力
- tool调用过程有效性
- 记录检索结果和两类本地索引的比例关系,作为判断本地索引质量的标准。这个比例直接反映混合检索的实际贡献分布:如果 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"。
评估局限与改进方向
当前评估的局限:
- 测试集质量依赖 LLM 生成:自动生成的 ground_truth 本身也可能不准确,LLM 对同一问题可能在不同运行中给出不同答案
- 测试集与知识库耦合强:知识库变更后 questions.json 需要重新生成,否则会出现上面的"文件已删除但题目还在"问题
- Answer Relevancy 对中文支持有限:RAGAS 用 Embedding 余弦相似度计算,短答案、文言式中文都会导致偏低
下一步改进方向:
- 维护一批手工标注的"黄金问题集",与知识库版本绑定
- 引入
Context Precision(精准率,检索结果中有多少真正有用)补充 Recall 的视角 - 把评估纳入 CI,每次合并知识库变更时自动跑评估,曲线有回退则 Block
4 欢迎体验
项目地址:https://github.com/my-zzylearner/rag-agent
有说明文档介绍如何本地使用,网页版由于tavily次数限制暂未开放,大家有问题欢迎github或csdn提问哦~
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)