用postgreSQL插件实现自己的RAG知识库

在医疗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时,英文的效果比中文好。

希望我的记录,对大家有所帮助。