在医疗AI项目中遇到要LLM基于自己的知识库来输出结果,综合考虑,决定采用PostgreSQL实现自己的RAG库。
关于技术选型
主要是觉得AI能力落地应用、或者企业内部时,大部分企业应该做好的是系统的整合,而不是对AI本身的调优。更关注企业特有的数据,更重要的是数据的独特性,增强模型的容错性,而不是过于关注数据的清洗和数据的数量。举个例子,在医疗论文搜索中,对标题、摘要、正文和引用,可以用不同的方式进行查询和使用;或者对特定内容,比如公司介绍、产品介绍、治疗方案介绍,有不同的查询方式等等。
先说为什么要选这样的技术方案:
1,能用RAG的,就坚决不用微调。RAG的成本远低于微调,而且保留足够的灵活度,便于后续替换和升级。目前各大LLM模型的上下文窗口越来越长,效果越来越好,价格也越来越低,绝大部分情况下,已经没有必要去自己微调、部署;
2,项目不大,对postgreSQL又比较熟悉,研究了一下pg的全文搜索和pgvector两个插件,发现够用,而且本来在项目中已经有了一个pg数据库,就直接拿来用,不用额外成本。
关于全文搜索
再说说为什么在使用语义搜索的同时,还要考虑全文搜索的支持,我想主要是几个原因:一个是项目实现阶段,可以使用全文搜索对语义搜索的结果进行校验;一个是实际应用种,可以使用全文搜索对语义搜索出来的内容,调整权重,实现一些特殊的需求。
既然用了全文搜索,那么就有分词的问题,项目里使用的是jieba分词,直接用pg的插件支持就可以,还能够自定义字典,非常好用。可以直接在SQL语句中完成分词:
SELECT to_tsvector('jiebacfg', %s);
要注意的一点是,项目中文本可能会有英文,也会有中文,遇到英文时,要用英文的分词,写成这样
SELECT to_tsvector('english', %s);
举个表结构的字段,article_part_ts就是to_tsvector存储字段,同时建立一个索引:
CREATE TABLE public.fy_article_part_index (
fy_article_part_id serial4 NOT NULL,
fy_article_info_id int4 NOT NULL,
article_part_text text NOT NULL,
article_part_ts tsvector NOT NULL,
article_part_em public.vector NOT NULL,
CONSTRAINT fy_article_part_index_pk PRIMARY KEY (fy_article_part_id)
);
CREATE INDEX fy_article_part_ts_index ON public.fy_article_part_index USING rum (article_part_ts);
搜索时,同样需要对待搜索内容进行分词,然后对分词内容进行组合(与、或都可以,效果差别很大,在不同场景下适用),再对搜索结果相关性排序,取前N个结果就可以,比如:
SELECT fy_article_info_id, article_part_ts <=> to_tsquery('jiebacfg', %s) AS rank
FROM fy_article_part_index
WHERE article_part_ts @@ to_tsquery('jiebacfg', %s)
ORDER BY rank
LIMIT 100
关于语义搜索
其实前面的创建表的SQL中,已经包含了语义搜索用的字段,也就是article_part_em字段,格式是public.vector。
这里先说embedding,他是用一个高维向量来表示一个对象的内涵意义,常见的word2vec,或者openai提供的’text-embedding-ada-002’等,都是embedding。项目里,我直接使用了阿里提供的embedding服务接口(不要自己去训练,完全没必要),不过要注意,一般大家用的embedding都是1536个维度的,但是阿里不知道为什么,把最新的模型改成了1024维度,为了保留项目的兼容性,我使用了它上一个版本,也就是1536维度的版本。记得对存储embedding的字段加上索引:
CREATE INDEX fy_article_part_em_index ON public.fy_article_part_index USING hnsw (article_part_em vector_l2_ops) WITH (m='16', ef_construction='64');
查询的时候,也先调用接口对待查询内容进行embedding,然后执行SQL:
SELECT fy_article_info_id, article_part_em <#> %s::vector AS rank
FROM fy_article_part_index
ORDER BY rank
LIMIT 100
这里要注意,我们知道两个矢量之间的距离有好几种计算方法,常见的包括:欧式距离、曼哈顿距离、余弦距离、内积等等,在项目应用中,略微有些差别,且计算开销也不一样。我这里用的操作符<#>计算的是内积,表示两个向量的相似度。
关于文本切片
无论是从数据存储、搜索还是收API服务的接口限制上来说,我们都要对文本内容进行切片。
切片可以固定长度切,也可以根据语句来切,切片的长度太短会造成语义搜索效果变差,长度太长,又会造成性能问题,所以要结合项目进行调优。
我采取的策略是先进行分句,然后把长度限制内的语句,组合成一个切片。
对于英文内容,项目里选择的分句方法是Python的NLTK库:
from nltk.tokenize import sent_tokenize
sentences = sent_tokenize(article_en)
对于中文,没有找到现成的分句方法,就自己实现了一个,考虑了一些特殊情况
def split_chinese_sentences(text):
sentences = []
current_sentence = []
i = 0
n = len(text)
# 状态跟踪
in_quote = False # 是否在引号中
quote_chars = {'“': '”', '‘': '’'} # 对应的引号匹配
while i < n:
char = text[i]
# 处理引号状态
if char in quote_chars:
# 遇到开引号,进入引号状态
current_sentence.append(char)
expected_end_quote = quote_chars[char]
i += 1
# 寻找对应的闭引号
while i < n and text[i] != expected_end_quote:
current_sentence.append(text[i])
i += 1
if i < n:
current_sentence.append(text[i]) # 添加闭引号
i += 1
continue
# 处理省略号(六连点)
if i <= n-5 and text[i:i+6] == '......':
current_sentence.append('......')
if not in_quote:
sentences.append(''.join(current_sentence).strip())
current_sentence = []
i += 6
continue
# 处理中文省略号(三连顿号)
if i <= n-2 and text[i] == '⋯' and text[i+1] == '⋯' and text[i+2] == '⋯':
current_sentence.append('⋯⋯⋯')
if not in_quote:
sentences.append(''.join(current_sentence).strip())
current_sentence = []
i += 3
continue
# 处理普通结束符
if char in {'。', '!', '?', '…'}:
current_sentence.append(char)
if not in_quote:
sentences.append(''.join(current_sentence).strip())
current_sentence = []
i += 1
continue
# 默认情况:积累字符
current_sentence.append(char)
i += 1
# 处理最后未完结的句子
if current_sentence:
sentences.append(''.join(current_sentence).strip())
return [s for s in sentences if s]
权重调优
接下来,把两种方法搜索出来的内容,通过设定一个最小的rank数值,过滤掉无用的内容,再把两份内容结合起来,调整权重,最终找到最适合的几篇内容。
在文章切片以后,我选择的是通过切片内容,找到原文全部内容,整体喂给LLM。毕竟现在LLM的接口,入参的token数量大的已经到了1M,完全可以支持几万字,成本可以接受的情况下,为什么不充分利用呢?
PROMPT工程
最后一步就是调用LLM接口啦,这里主要是对prompt进行调优,特别是告诉LLM他的角色,用户的历史会话记录,以及刚才搜索出来的相关文档内容,设置合适的温度值,以及对输出内容的要求。这里有两个注意的地方,一个是我发现平台提供的LLM接口,即使使用同一个版本,也要定期检查它的输出内容,他一定几率会变得不可控,要及时调整prompt的写法;另一个是有些接口在设置角色、书写prompt时,英文的效果比中文好。
希望我的记录,对大家有所帮助。