RAG重新综合整理

文件上传

  • mysql是有两个数据库表,一个是 file_upload 表示文件上传时间,文件名,文件大小,上传状态(是否完成上传),文件file_md5,另外一个 chunk_info,记录分片的索引,分片的 md5,还有在 MinIO 中的存储路径。每一个分块都有 md5,第一个可以检验分块从前端传递过来是否有损失,如果有损失就重新传递,第二个可以避免重复上传相同的分块。还有在 MinIO 合并的时候也会再次从 mysql 中把 md5 提取出来进行比较,看这里的上传有没有损失
  • 完整校验流程(正常):查 MySQL 文件记录 → 无则创建,查 Redis 标记 → 无标记,查 MySQL 分片记录 → 无记录,上传 MinIO(写 MinIO),标记 Redis(写 Redis),插入 MySQL 分片记录(写 MySQL),兜底检查 MySQL
  • 校验情况:
    • Redis有标记 + MySQL有记录,说明该分片已经上传完成
    • Redis有标记 + MySQL无记录:用这个分片构建 MinIO 存储路径,去验证这个文件是否存在,如果存在说明这个分片没有写入 mysql 中,就重新计算这个分片的 md5 补录到mysql 的 chunk_info表中,如果 MinIO 没有文件 ,说明 redis 中是脏数据,进行重新上传
    • redis 无标记,mysql 有记录 : 就从 mysql中进行数据恢复,如果 MinIO 中也没有数据,那就根据存储路径再给 MinIO 进行上传
    • redis 和 mysql 都没有标记:从0开始上传
  • 分块是有编号的,在给 MinIO 存储的路径中会带上 part-index,当我的文件都上传完成之后,会先检查块数是不是和预期数一样,如果一样的话就从for循环0开始遍历,把这个存储路径拼贴出来,然后从 MinIO 中把这些分块都下载下来,用一个文件,建立文件流读取,再把这个文件上传到 MinIO,形成一个完整的文件,最后再把后端建立的这个临时文件和 MinIO 中的分块都删掉。
  • 我本来是考虑把 大文件分块存储在 mysql 里面的,但是 mysql 针对大文件 IO 太慢了,如果在高并发场景下数据库会崩溃的,但是 MinIO 是非结构化存储对象,设计目的就是为了存储大文件的,所以可以应对高并发的场景。其次我们也需要这样一个中间存储的地方,不能说分块直接上传然后就开一个文件流去存储合并成一个文件然后直接发送给 kafka,不然如果网络突然断掉,这个文件流没有存储能力,无法保存原来已经上传了的部分。MinIO 合并文件之后会生成一个 URL 预签名,kafka消费者拿到这个URL可以去MinIO下载文件

文件分割策略

  • Tika 解析器以流式方式逐段推送文本到 StreamingContentHandler。Handler 的缓冲区持续累积文本,当累积量达到1MB(parentChunkSize)时触发一次批量处理,不然如果有一个大文件 100MB 一下子加载内存,容易产生 OOM 溢出
  • 第一要进行语言感知,自动识别中英文,统计文本中有意义的字符,中文字符(Unicode范围 0x4E00-0x9FFF)的占比,排除空格,标点,数字,换行等字符,设定阈值为 20%, 如果只有少量中文注释,那么占比通常不会超过20%,走英文通道 ,如果是中文比例在 30% ~ 60% 之间,就需要使用 HanLP 语义分词,这个对英文也有效果,按空格分词,不会完全失效。但是英文赛道对中文分割就很无力
  • 中文:段落级按 \n\n+ 分割,句子级按中文标点 。!?; 分割,词语级使用 HanLP StandardTokenizer 进行语义分词(如"智能文档处理"会被分成"智能/文档/处理"这样的语义词组)。这套逻辑在纯中文场景下已经验证有效
  • 第一步,使用改进的正则表达式断句:只有当点号后面紧跟空格和大写字母(新句子开头的标志)时才判定为句子边界。第二步,后处理修正——使用英文缩写词词典。词典包含了常见的缩写词:国家缩写(U.S.、U.K.)、称谓(Dr.、Mr.、Mrs.、Ms.、Prof.)、学术缩写(e.g.、i.e.、etc.、Ph.D.)、公司缩写(Inc.、Ltd.、Co.、Corp.)等约30个高频词。后处理逻辑逐个检查断句结果,如果发现某个"句子"以缩写词结尾(比如切出来的是"Dr."),说明这个点号不是句号而是缩写词的一部分,就把这个"句子"合并到前一个真正的句子中去。这样"Dr. Smith went to U.S."会被保持为一个完整的句子,不会被切成碎片。
  • 分块是512个字符进行分块,一个汉字是一个字符,英文一个字母是一个字符包括空格。绝对大多数 Embedding模型都有最大 token 限制,512个字符约等于 200 ~ 400 tokens,切片太大会包含太多无关信息,太小会丢失上下文。
  • 当英文句子超过512字符(在学术论文、法律文件等长文档中可能出现),需要进一步切割。英文通道不再调用HanLP,而是使用专门的空格分词方法:按空格将句子拆成单词列表,然后逐词累积到当前chunk中,当累积长度接近512字符上限时封装为一个chunk,开启下一个chunk。这种方法保证了每个英文单词的完整性——不会出现"intelli"和"gent"被硬切到两个chunk中的情况。虽然不如中文HanLP的语义分词那么"智能",但对于英文来说,单词本身就是最小的语义单元(英文天然以空格分隔词语),按空格分词就是最合适的粒度。
  • 重叠策略:50-80字符是 LangChain、LlamaIndex 等主流 RAG 框架验证过的经验最优区间。对于中文,50字符大约包含2-3个完整句子;对于英文,50字符大约包含1-2个完整句子,足以覆盖绝大多数边界场景。总共 512 个字符,0~20重叠太小,边界信息仍然缺失,150字符以上太多,相邻重复太多,浪费空间。

向量存储与召回

  • 经过分块之后,会被保存到 mysql 的 document_vector 表中,要进行向量化时只需要通过 file_md5 找到所有已经保存的子切片即可。调用 Embedding API 生成向量。
  • 构建 ES 对象,包括 fileMD5, 分块编号,文本内容,向量内容,模型版本(如果更换模型或者升级就可以重新生成向量),ES 建立索引可以区分内容,就相当于 mysql 不同的表,我现在使用的索引是 konwledge_base 是基本的知识库索引
  • 向量模型使用的是阿里云通义千问,项目使用 1024 维度。高维度(2048 - 3072)表达能力强精度高,但是空间大,检索慢,中维度(1024~1536)平衡性能和精度。低纬度(512-768)存储小检索快,精度低。一个 float 是4个字节,那么 2048 维度就是 8KB 文档。当然如果文档很多,就要提高维度,避免向量碰撞。维度也不是越高越好,要在精度,成本,速度之间找到平衡点
  • 第一路语义召回:基于向量的 KNN 相似度匹配,取出相似度最高的 topk * 30 比如用户想要 10个文档,实际召回30个,第二路:精准重排:基于 BM25 算法的文本相关性重排序。混合分数=KNN分数 * 0.2 + BM25 分数 * 1;
  • BM25算法:以前老办法是 TF-IDF,他单纯根据关键词出现的次数进行打分,那么文章如果越长,他关键次出现次数越多,分数就越高,这显然有问题的。所以 BM25 算法,关键词次数出现到一定次数就会停止加分,也就是有固定词频,然后文章如果太长会进行扣分。
  • 比如在二维空间有一个点(x,y)坐标,但是这个项目是把文本映射到 2048 维空间当中,语义相近的文本,在这个高位空间中距离就会更近,这个相似度是采用余弦相似度计算的,也就是两个向量的 点积 * (两向量模相乘)。余弦相似度更关注向量的方向,对长度不敏感,但是欧式距离是向量的绝对位置。比如:短文本:"AI发展" → 向量 A = [0.6, 0.8],长文本:"人工智能的快速发展" → 向量 B = [1.2, 1.6](数值更大但方向相同),余弦相似度:cos(θ) = 1.0,但是欧式距离 distance = √[(1.2-0.6)² + (1.6-0.8)²] = 1.0(距离较远,可能误判)
  • KNN(K 个最近邻) 召回的底层实现是 HNSW索引,就像在图书馆找书,如果是暴力搜索就是一本本找,但是这个算法是先找到这个书的大致区间,再在这个区间内一步步向下找,接近 O(log n)复杂度,是一种层级的检索,比如我最下层有100个点,向上层次逐渐递减只有10个点,我们检索从上往下检索,找到一条离我们目标节点最近的一个从上向下的路径
  • 其他的向量数据库,Milvus(米尔维斯),Pinecone(Painkoun派扣),zilliz cloud。其中 Milvus 和 Pinecone 都不支持 BM25算法,所以说 ES 更方便,既能实现 BM25算法进行关键字检索,也能存储向量进行召回

WebSocket + WebFlux + Redis

  • http协议是请求-响应模式,服务端无法主动向客户端推送数据,WebSocket 建立全双工通道,服务端可以随时向客户端发送消息片段。使用 webClient 发送 Post 请求,设置参数 stream :true 参数
  • WebSocket = 不挂断的电话,随时能说话;WebFlux = 流水线作业,做好一点传一点;ConcurrentHashMap = 临时便签,快速记录;Redis = 永久账本,安全可靠;用户感觉 AI 在"边想边说",很自然
  • 如果每一次传回来 chunk 都要序列化写入 redis, I/O 开销巨大,高频写入会导致 redis 成为瓶颈,所以需要 ConcurrentHashMap临时存储。
  • 这个 concurrentHashMap 有三个,他们的 key 都是会话 ID(随机生成的 UUID),第一个value是 StringBuffer,不断累积返回回来的 chunk,第二个是记录状态,这里有三个状态,异常,正常完成,用户停止。第三个是停止是否开启,如果开启就说明用户按下了暂停。后台有一个单独线程一直在监测第一个 StringBuffer 的长度,比如现在获取长度,然后等待3秒,再检测长度,如果长度不变了就说明完成了对话,如果 catch 捕获到异常就标记为异常结束。但是每次在推送给前端的时候会检测这个 flag 是否发生变化,用户按下了暂停就不会向前端发送并且结束这个聊天,把StringBuffer里面的内容记录到 redis中。
  • 使用 Redis list 的好处: String + JSON 的方案,每一次添加新的记录,都要去 redis 中把之前所有的数据全部都读取出来然后进行操作,但是 list 只需要在最后进行追加就可以了。同时我可以灵活删除和读取,比如我要读取 前5条信息,或者我项目设置的是保存最近30条消息,如果是 String 我需要把所有的都读取出来然后进行操作,但是list可以直接删除和读取。
posted @ 2026-06-16 18:11  Huangyien  阅读(3)  评论(0)    收藏  举报